Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 0 additions & 7 deletions bin/phpstan
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,6 @@ use Symfony\Component\Console\Helper\ProgressBar;
}
$devOrPharLoader->unregister();

// fix missing key for vendor/hoa/protocol/Wrapper.php
$existingComposerAutoloadFiles = $GLOBALS['__composer_autoload_files'] ?? [];
$GLOBALS['__composer_autoload_files'] = array_merge(
$existingComposerAutoloadFiles,
array_fill_keys(['3e76f7f02b41af8cea96018933f6b7e3'], true),
);

$autoloaderInWorkingDirectory = $vendorDirectory . '/autoload.php';
$composerAutoloaderProjectPaths = [];

Expand Down
1 change: 0 additions & 1 deletion compiler/build/scoper-namespaces.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
'PHPStanTurbo',
'PHPUnit',
'PhpParser',
'Hoa',
'Symfony\Polyfill\Php80',
'Symfony\Polyfill\Php81',
'Symfony\Polyfill\Php83',
Expand Down
5 changes: 0 additions & 5 deletions compiler/build/scoper.inc.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
$namespaces = require __DIR__ . '/scoper-namespaces.php';

$stubs = [
'../../vendor/hoa/consistency/Prelude.php',
'../../vendor/composer/InstalledVersions.php',
'../../vendor/composer/installed.php',
];
Expand Down Expand Up @@ -106,10 +105,6 @@ function (string $filePath, string $prefix, string $content): string {
return $class;
}

if (strpos($class, 'Hoa\\') === 0) {
return $class;
}

if (strpos($class, '@') === 0) {
return $class;
}
Expand Down
8 changes: 4 additions & 4 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

21 changes: 20 additions & 1 deletion src/Command/IgnoredRegexValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,11 @@ public function __construct(
public function validate(string $regex): IgnoredRegexValidatorResult
{
$delimiter = substr($regex, 0, 1);
$modifiers = $this->modifiersOf($regex);
$regex = $this->removeDelimiters($regex);

try {
$root = $this->reader->read($regex, '', $delimiter);
$root = $this->reader->read($regex, $modifiers, $delimiter);
} catch (RegexException) {
return new IgnoredRegexValidatorResult(ignoredTypes: [], anchorsInTheMiddle: false, allErrorsIgnored: false);
}
Expand Down Expand Up @@ -96,6 +97,24 @@ private function getIgnoredTypes(NodeInterface $ast, string $source): array
return $types;
}

/**
* The letters that follow the closing delimiter, so a pattern in
* extended mode reads with its comments and its blanks stripped by the
* parser, not as literal spaces.
*/
private function modifiersOf(string $regex): string
{
$delimiter = substr($regex, 0, 1);
$endDelimiterPosition = strrpos($regex, $delimiter);
if ($endDelimiterPosition === false) {
throw new ShouldNotHappenException();
}

$modifiers = substr($regex, $endDelimiterPosition + 1);

return preg_replace('/[^a-zA-Z]/', '', $modifiers) ?? '';
}

private function removeDelimiters(string $regex): string
{
$delimiter = substr($regex, 0, 1);
Expand Down
6 changes: 4 additions & 2 deletions src/Php/PcreVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ public function __construct(private int $versionId, private int $source = self::
*/
public static function fromString(string $version, int $source = self::SOURCE_UNKNOWN): self
{
// two digits on each side, as PCRE2 writes releases: 10.42, 11.00
if (preg_match('/^(\d{2})\.(\d{2})$/', $version, $matches) !== 1) {
// two digits on each side, as PCRE2 writes releases: 10.42, 11.00.
// A release candidate carries its date after a dash - "10.49-RC1
// 2026-08-14" - and reads as the release it carries the features of
if (preg_match('/^(\d{2})\.(\d{2})(?:[^\d].*)?$/', $version, $matches) !== 1) {
throw new \InvalidArgumentException(sprintf(
'"%s" is not a PCRE2 release. Expected a version like "10.42".',
$version,
Expand Down
20 changes: 18 additions & 2 deletions src/Php/PcreVersionFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use PHPRegex\Parser\PcreTarget;
use PHPStan\DependencyInjection\AutowiredService;
use const PHP_VERSION_ID;
use function sprintf;

#[AutowiredService(factory: '@PHPStan\Php\PcreVersionFactoryFactory::create')]
final class PcreVersionFactory
Expand All @@ -20,11 +21,26 @@ public function __construct(
public function create(): PcreVersion
{
if ($this->version !== null) {
return PcreVersion::fromString($this->version, PcreVersion::SOURCE_CONFIG);
try {
return PcreVersion::fromString($this->version, PcreVersion::SOURCE_CONFIG);
} catch (\InvalidArgumentException $exception) {
// the message names the knob to fix, not only the value
throw new \InvalidArgumentException(sprintf(
'Invalid pcreVersion configuration: %s Fix the "pcreVersion" parameter, or remove it to read the running engine.',
$exception->getMessage(),
), previous: $exception);
}
}

if ($this->composerPcreVersion !== null) {
return PcreVersion::fromString($this->composerPcreVersion, PcreVersion::SOURCE_COMPOSER_PLATFORM_PCRE);
try {
return PcreVersion::fromString($this->composerPcreVersion, PcreVersion::SOURCE_COMPOSER_PLATFORM_PCRE);
} catch (\InvalidArgumentException $exception) {
throw new \InvalidArgumentException(sprintf(
'Invalid composer platform package: %s Fix "config.platform.lib-pcre" in composer.json, or remove it to read the running engine.',
$exception->getMessage(),
), previous: $exception);
}
}

try {
Expand Down
9 changes: 8 additions & 1 deletion src/Type/Php/RegexArrayShapeMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,14 @@ public function matchSubjectExpr(Expr $patternExpr, Scope $scope): ?Type
return null;
}

$subjectTypes[] = $astWalkResult->getSubjectBaseType();
// the subject narrows only where the pattern anchors both ends —
// null says it does not, and the subject keeps its own type
$subjectNarrowType = $astWalkResult->getSubjectNarrowType();
if ($subjectNarrowType === null) {
return null;
}

$subjectTypes[] = $subjectNarrowType;
}

return TypeCombinator::union(...$subjectTypes);
Expand Down
45 changes: 40 additions & 5 deletions src/Type/Regex/PHPRegexReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use PHPRegex\Parser\Exception\RegexException;
use PHPRegex\Parser\Node\RegexNode;
use PHPRegex\Parser\Syntax\Delimiters;
use PHPRegex\Parser\PcreTarget;
use PHPRegex\Parser\RegexParser;
use PHPStan\DependencyInjection\AutowiredService;
Expand Down Expand Up @@ -46,6 +45,18 @@ public function read(string $rawRegex, string $modifiers, string $delimiter): Re
return $this->parser->parsePattern($rawRegex, $modifiers, $delimiter);
}

/**
* The engine the patterns read here are judged for: the baseline the
* analysed PHP carries, narrowed or widened by what the pin or the
* witness says.
*/
public function judgedPcreVersion(): string
{
$this->parser ??= RegexParser::create($this->targetOptions());

return $this->parser->target()->pcreVersion;
}

/**
* Whether the engine the judged target runs accepts the pattern: the
* parser validates for the target it reads for, so a construct the
Expand All @@ -55,21 +66,45 @@ public function accepts(string $rawRegex, string $modifiers, string $delimiter):
{
$this->parser ??= RegexParser::create($this->targetOptions());

$regex = $delimiter . $rawRegex . Delimiters::closing($delimiter) . $modifiers;
return $this->parser->validate($this->delimited($rawRegex, $modifiers, $delimiter))->isValid;
}

/**
* The pattern as it was written: a bracket delimiter closes with its
* pair, any other one closes with itself.
*/
private function delimited(string $rawRegex, string $modifiers, string $delimiter): string
{
static $pairs = ['(' => ')', '[' => ']', '{' => '}', '<' => '>'];

return $this->parser->validate($regex)->isValid;
return $delimiter . $rawRegex . ($pairs[$delimiter] ?? $delimiter) . $modifiers;
}

/**
* @return array<string, int|string>
* The parser options that judge the pattern for the analysed project.
*
* A named engine is a statement about that project — the config or the
* composer platform says which engine it runs — and is honoured in both
* directions. The running engine is only a witness: it speaks for the
* analysed PHP when the two share a minor, because a patch release
* carries whatever engine was current when it was cut, and the baseline
* map only knows the minor's first. From another minor the witness
* stays silent and the baseline judges.
*
* @return array{php_version: int, pcre_version?: string}
*/
private function targetOptions(): array
{
$phpVersionId = $this->phpVersion->getVersionId();
$bundled = PcreTarget::bundledWith($phpVersionId);

$options = ['php_version' => $phpVersionId];
if (!$this->pcreVersion->atLeast($bundled->pcreVersion)) {

$isStatement = \in_array($this->pcreVersion->getSource(), [PcreVersion::SOURCE_CONFIG, PcreVersion::SOURCE_COMPOSER_PLATFORM_PCRE], true);
$speaksForAnalysed = $this->pcreVersion->getSource() === PcreVersion::SOURCE_RUNTIME
&& intdiv($phpVersionId, 100) === intdiv(\PHP_VERSION_ID, 100);

if ($isStatement || $speaksForAnalysed || !$this->pcreVersion->atLeast($bundled->pcreVersion)) {
$options['pcre_version'] = $this->pcreVersion->getVersionString();
}

Expand Down
48 changes: 26 additions & 22 deletions src/Type/Regex/RegexAstWalkResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,17 @@ final class RegexAstWalkResult
/**
* @param array<int, RegexCapturingGroup> $capturingGroups
* @param list<string> $markVerbs
* @param Type|null $subjectNarrowType what the subject itself narrows to: null while
* the pattern anchors no end — a subject only ever contains the match —
* for an anchored one the accessories the match still leaves open, for a
* subject is never the match: $ admits one final newline
*/
public function __construct(
private int $alternationId,
private int $captureGroupId,
private array $capturingGroups,
private array $markVerbs,
private Type $subjectBaseType,
private ?Type $subjectNarrowType = null,
)
{
}
Expand All @@ -27,8 +31,6 @@ public static function createEmpty(): self
{
return new self(
-1,
// use different start-index for groups to make it easier to distinguish groupids from other ids
100,
[],
[],
new StringType(),
Expand All @@ -39,21 +41,10 @@ public function nextAlternationId(): self
{
return new self(
$this->alternationId + 1,
$this->captureGroupId,
$this->capturingGroups,
$this->markVerbs,
$this->subjectBaseType,
);
}

public function nextCaptureGroupId(): self
{
return new self(
$this->alternationId,
$this->captureGroupId + 1,
$this->capturingGroups,
$this->markVerbs,
$this->subjectBaseType,
$this->subjectNarrowType,
);
}

Expand All @@ -64,10 +55,10 @@ public function addCapturingGroup(RegexCapturingGroup $group): self

return new self(
$this->alternationId,
$this->captureGroupId,
$capturingGroups,
$this->markVerbs,
$this->subjectBaseType,
$this->subjectNarrowType,
);
}

Expand All @@ -78,7 +69,6 @@ public function markVerb(string $markVerb): self

return new self(
$this->alternationId,
$this->captureGroupId,
$this->capturingGroups,
$verbs,
$this->subjectBaseType,
Expand All @@ -89,21 +79,26 @@ public function withSubjectBaseType(Type $subjectBaseType): self
{
return new self(
$this->alternationId,
$this->captureGroupId,
$this->capturingGroups,
$this->markVerbs,
$subjectBaseType,
);
}

public function getAlternationId(): int
public function withSubjectNarrowType(?Type $subjectNarrowType): self
{
return $this->alternationId;
return new self(
$this->alternationId,
$this->capturingGroups,
$this->markVerbs,
$this->subjectBaseType,
$subjectNarrowType,
);
}

public function getCaptureGroupId(): int
public function getAlternationId(): int
{
return $this->captureGroupId;
return $this->alternationId;
}

/**
Expand All @@ -127,4 +122,13 @@ public function getSubjectBaseType(): Type
return $this->subjectBaseType;
}

/**
* The subject narrowed by a match on the pattern: null while the pattern
* anchors no end, for an anchored one the accessories alone.
*/
public function getSubjectNarrowType(): ?Type
{
return $this->subjectNarrowType;
}

}
Loading