From 12f3ef1ff0ae03cce251b0010da0cbd98dc4d403 Mon Sep 17 00:00:00 2001 From: Younes ENNAJI Date: Fri, 23 Oct 2026 21:49:25 +0000 Subject: [PATCH 1/2] Read the match shapes from the parser's capture shape analysis The group types and the whole-match type now come from the capture shape analysis the parser computes beside the patterns it reads, and only the topology - which group sits in which branch of which alternation, under which optional quantifier - is walked here. An optional quantifier marks the one node it wraps the way the matcher reads it, a capturing parent carries its optionality to its children, and a group a (?(DEFINE)...) block defines stays optional forever. The subject narrows on its own channel: $matches[0] is the match the engine wrote, constants included, while the subject contains the match and narrows to the accessories the match leaves open - never to the match's constants or digits, and $ admits one final newline. The fixtures re-pin what the sharper shapes read: constants for literals and alternations, numeric-string for digit runs, non-falsy-string for values a mandatory neighbour pins off zero. --- composer.lock | 8 +- src/Type/Php/RegexArrayShapeMatcher.php | 9 +- src/Type/Regex/RegexAstWalkResult.php | 29 + src/Type/Regex/RegexGroupParser.php | 925 ++++-------------- tests/PHPStan/Analyser/nsrt/bug-11311.php | 26 +- tests/PHPStan/Analyser/nsrt/bug-12210.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14314.php | 10 +- tests/PHPStan/Analyser/nsrt/bug-14575.php | 36 +- tests/PHPStan/Analyser/nsrt/bug-14710.php | 2 +- tests/PHPStan/Analyser/nsrt/bug-14809.php | 8 +- tests/PHPStan/Analyser/nsrt/bug-14820.php | 18 +- tests/PHPStan/Analyser/nsrt/bug-15297.php | 4 +- tests/PHPStan/Analyser/nsrt/bug11384.php | 2 +- .../nsrt/by-ref-writeback-native-type.php | 8 +- .../nsrt/equality-narrowing-new-world.php | 6 +- .../Analyser/nsrt/preg_match_shapes.php | 232 ++--- .../Analyser/nsrt/preg_match_shapes_php80.php | 8 +- .../Analyser/nsrt/preg_match_shapes_php82.php | 16 +- .../nsrt/preg_replace_callback_shapes.php | 10 +- .../Type/Regex/RegexShapeReadingTest.php | 61 +- 20 files changed, 478 insertions(+), 942 deletions(-) diff --git a/composer.lock b/composer.lock index 5f4aa345861..2e504eaebdc 100644 --- a/composer.lock +++ b/composer.lock @@ -1542,12 +1542,12 @@ "source": { "type": "git", "url": "https://github.com/php-regex/regex-parser-php74.git", - "reference": "57a0ef6e7be0ec5730b70d6b5052b756515a72d6" + "reference": "f30a3dfd7d9adea4e1d2bd0117b678950b89a7a9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-regex/regex-parser-php74/zipball/57a0ef6e7be0ec5730b70d6b5052b756515a72d6", - "reference": "57a0ef6e7be0ec5730b70d6b5052b756515a72d6", + "url": "https://api.github.com/repos/php-regex/regex-parser-php74/zipball/f30a3dfd7d9adea4e1d2bd0117b678950b89a7a9", + "reference": "f30a3dfd7d9adea4e1d2bd0117b678950b89a7a9", "shasum": "" }, "require": { @@ -1606,7 +1606,7 @@ "url": "https://github.com/sponsors/yoeunes" } ], - "time": "2026-10-23T17:57:16+00:00" + "time": "2026-10-23T21:40:03+00:00" }, { "name": "phpstan/php-8-stubs", diff --git a/src/Type/Php/RegexArrayShapeMatcher.php b/src/Type/Php/RegexArrayShapeMatcher.php index b5350746422..c8b16be618c 100644 --- a/src/Type/Php/RegexArrayShapeMatcher.php +++ b/src/Type/Php/RegexArrayShapeMatcher.php @@ -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); diff --git a/src/Type/Regex/RegexAstWalkResult.php b/src/Type/Regex/RegexAstWalkResult.php index ff234b6092c..bafe505648c 100644 --- a/src/Type/Regex/RegexAstWalkResult.php +++ b/src/Type/Regex/RegexAstWalkResult.php @@ -12,6 +12,10 @@ final class RegexAstWalkResult /** * @param array $capturingGroups * @param list $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, @@ -19,6 +23,7 @@ public function __construct( private array $capturingGroups, private array $markVerbs, private Type $subjectBaseType, + private ?Type $subjectNarrowType = null, ) { } @@ -43,6 +48,7 @@ public function nextAlternationId(): self $this->capturingGroups, $this->markVerbs, $this->subjectBaseType, + $this->subjectNarrowType, ); } @@ -54,6 +60,7 @@ public function nextCaptureGroupId(): self $this->capturingGroups, $this->markVerbs, $this->subjectBaseType, + $this->subjectNarrowType, ); } @@ -68,6 +75,7 @@ public function addCapturingGroup(RegexCapturingGroup $group): self $capturingGroups, $this->markVerbs, $this->subjectBaseType, + $this->subjectNarrowType, ); } @@ -96,6 +104,18 @@ public function withSubjectBaseType(Type $subjectBaseType): self ); } + public function withSubjectNarrowType(?Type $subjectNarrowType): self + { + return new self( + $this->alternationId, + $this->captureGroupId, + $this->capturingGroups, + $this->markVerbs, + $this->subjectBaseType, + $subjectNarrowType, + ); + } + public function getAlternationId(): int { return $this->alternationId; @@ -127,4 +147,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; + } + } diff --git a/src/Type/Regex/RegexGroupParser.php b/src/Type/Regex/RegexGroupParser.php index 8dbc4b3dce3..df17b2063b2 100644 --- a/src/Type/Regex/RegexGroupParser.php +++ b/src/Type/Regex/RegexGroupParser.php @@ -2,39 +2,23 @@ namespace PHPStan\Type\Regex; -use Nette\Utils\Strings; +use PHPRegex\Parser\Analysis\CaptureGroupShape; +use PHPRegex\Parser\Analysis\CaptureShape; +use PHPRegex\Parser\Analysis\CaptureShapeAnalyzer; use PHPRegex\Parser\Exception\RegexException; use PHPRegex\Parser\Node\AlternationNode; use PHPRegex\Parser\Node\AnchorNode; -use PHPRegex\Parser\Node\AssertionNode; -use PHPRegex\Parser\Node\BackrefNode; -use PHPRegex\Parser\Node\CalloutNode; -use PHPRegex\Parser\Node\CharClassNode; -use PHPRegex\Parser\Node\CharLiteralNode; -use PHPRegex\Parser\Node\CharTypeNode; -use PHPRegex\Parser\Node\CommentNode; use PHPRegex\Parser\Node\ConditionalNode; -use PHPRegex\Parser\Node\ControlCharNode; use PHPRegex\Parser\Node\DefineNode; -use PHPRegex\Parser\Node\DotNode; use PHPRegex\Parser\Node\GroupNode; use PHPRegex\Parser\Node\GroupType; use PHPRegex\Parser\Node\KeepNode; -use PHPRegex\Parser\Node\LiteralNode; use PHPRegex\Parser\Node\NodeInterface; -use PHPRegex\Parser\Node\PcreVerbNode; -use PHPRegex\Parser\Node\PosixClassNode; use PHPRegex\Parser\Node\QuantifierBounds; use PHPRegex\Parser\Node\QuantifierNode; -use PHPRegex\Parser\Node\RangeNode; use PHPRegex\Parser\Node\RegexNode; -use PHPRegex\Parser\Node\SequenceNode; -use PHPRegex\Parser\Node\SubroutineNode; -use PHPRegex\Parser\Node\UnicodePropNode; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; -use PHPStan\ShouldNotHappenException; -use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Accessory\AccessoryNumericStringType; @@ -45,13 +29,18 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use function array_key_exists; -use function array_merge; use function count; -use function in_array; -use function mb_str_split; +use function spl_object_id; use function str_contains; use function trim; +/** + * Reads a pattern into the group topology the array shape matcher folds + * into $matches types: what each group captures comes from the parser's + * own capture shape analysis, where it lives beside the patterns it reads, + * and only the topology — which group sits in which branch of which + * alternation — is walked here. + */ #[AutowiredService] final class RegexGroupParser { @@ -60,12 +49,8 @@ final class RegexGroupParser 'J', // rare modifier too complicated to support ]; - // upper bound on the number of constant string literals enumerated from a group, - // to avoid combinatorial explosion from nested optional/bounded quantifications - private const LITERALS_LIMIT = 100; - - /** @var array */ - private static array $parsedAst = []; + /** @var array */ + private static array $parsedShapes = []; public function __construct( private PhpVersion $phpVersion, @@ -77,22 +62,22 @@ public function __construct( public function parseGroups(string $regex): ?RegexAstWalkResult { - // the walk is judged for a PHP version: the same regex read for two - // versions must not share a tree + // the shape is judged for a PHP version: the same regex read for two + // versions must not share a shape $cacheKey = $this->phpVersion->getVersionId() . "\n" . $regex; - if (array_key_exists($cacheKey, self::$parsedAst)) { - $ast = self::$parsedAst[$cacheKey]; - if ($ast === null) { + if (array_key_exists($cacheKey, self::$parsedShapes)) { + $cached = self::$parsedShapes[$cacheKey]; + if ($cached === null) { return null; } - $modifiers = $this->regexExpressionHelper->getPatternModifiers($regex) ?? ''; + [$shape, $ast] = $cached; } else { $modifiers = $this->regexExpressionHelper->getPatternModifiers($regex) ?? ''; foreach (self::NOT_SUPPORTED_MODIFIERS as $notSupportedModifier) { if (str_contains($modifiers, $notSupportedModifier)) { - return self::$parsedAst[$cacheKey] = null; + return self::$parsedShapes[$cacheKey] = null; } } @@ -104,58 +89,20 @@ public function parseGroups(string $regex): ?RegexAstWalkResult // says about it — the invalid-pattern rule reports on the // running engine, and each part speaks for its own judge if (!$this->reader->accepts($rawRegex, $modifiers, $delimiter)) { - return self::$parsedAst[$cacheKey] = null; + return self::$parsedShapes[$cacheKey] = null; } try { - $ast = self::$parsedAst[$cacheKey] = $this->reader->read( - $rawRegex, - $modifiers, - $delimiter, - ); + $ast = $this->reader->read($rawRegex, $modifiers, $delimiter); } catch (RegexException) { - return self::$parsedAst[$cacheKey] = null; + return self::$parsedShapes[$cacheKey] = null; } - } - - $captureOnlyNamed = false; - if ($this->phpVersion->supportsPregCaptureOnlyNamedGroups()) { - $captureOnlyNamed = str_contains($modifiers, 'n'); - } - $astWalkResult = $this->walkRegexAst( - $ast->pattern, - null, - 0, - false, - null, - $captureOnlyNamed, - false, - $modifiers, - RegexAstWalkResult::createEmpty(), - ); - - $subjectAsGroupResult = $this->walkGroupAst( - $ast->pattern, - false, - $modifiers, - RegexGroupWalkResult::createEmpty(), - ); - - if (!$subjectAsGroupResult->mightContainEmptyStringLiteral() && !$this->containsEscapeK($ast->pattern)) { - // we could handle numeric-string, in case we know the regex is delimited by ^ and $ - if ($subjectAsGroupResult->isNonFalsy()->yes()) { - $astWalkResult = $astWalkResult->withSubjectBaseType( - new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]), - ); - } elseif ($subjectAsGroupResult->isNonEmpty()->yes()) { - $astWalkResult = $astWalkResult->withSubjectBaseType( - new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]), - ); - } + $shape = (new CaptureShapeAnalyzer())->analyze($ast); + self::$parsedShapes[$cacheKey] = [$shape, $ast]; } - return $astWalkResult; + return $this->buildWalkResult($shape, $ast->pattern); } private static function delimiterOf(string $regex): string @@ -165,734 +112,238 @@ private static function delimiterOf(string $regex): string return $trimmed === '' ? '/' : $trimmed[0]; } - private function containsEscapeK(NodeInterface $node): bool - { - if ($node instanceof KeepNode) { - return true; - } - - foreach ($node->getChildren() as $child) { - if ($this->containsEscapeK($child)) { - return true; - } - } - - return false; - } - - private function walkRegexAst( - NodeInterface $ast, - ?RegexAlternation $alternation, - int $combinationIndex, - bool $inOptionalQuantification, - RegexCapturingGroup|RegexNonCapturingGroup|null $parentGroup, - bool $captureOnlyNamed, - bool $repeatedMoreThanOnce, - string $patternModifiers, - RegexAstWalkResult $astWalkResult, - ): RegexAstWalkResult + /** + * The shape says what every group captures; the tree says where every + * group sits. Together they are the walk result the matcher folds. + */ + private function buildWalkResult(CaptureShape $shape, NodeInterface $pattern): RegexAstWalkResult { - $group = null; - if ($ast instanceof GroupNode) { - if ($ast->type === GroupType::Capturing()) { - $astWalkResult = $astWalkResult->nextCaptureGroupId(); - - $group = new RegexCapturingGroup( - $astWalkResult->getCaptureGroupId(), - null, - $alternation, - $inOptionalQuantification, - $parentGroup, - $this->createGroupType( - $ast, - $this->allowConstantTypes($patternModifiers, $repeatedMoreThanOnce, $parentGroup), - $patternModifiers, - ), - ); - $parentGroup = $group; - } elseif ($ast->type === GroupType::Named()) { - $astWalkResult = $astWalkResult->nextCaptureGroupId(); - - $group = new RegexCapturingGroup( - $astWalkResult->getCaptureGroupId(), - $ast->name, - $alternation, - $inOptionalQuantification, - $parentGroup, - $this->createGroupType( - $ast, - $this->allowConstantTypes($patternModifiers, $repeatedMoreThanOnce, $parentGroup), - $patternModifiers, - ), - ); - $parentGroup = $group; - } elseif ($ast->type === GroupType::BranchReset()) { - $group = new RegexNonCapturingGroup( - $alternation, - $inOptionalQuantification, - $parentGroup, - true, - ); - $parentGroup = $group; - } elseif ($ast->type !== GroupType::ScanSubstring()) { - $group = new RegexNonCapturingGroup( - $alternation, - $inOptionalQuantification, - $parentGroup, - false, - ); - $parentGroup = $group; - } - } - - $inOptionalQuantification = false; - if ($ast instanceof QuantifierNode) { - $bounds = QuantifierBounds::parse($ast->quantifier); - if ($bounds === null) { - throw new ShouldNotHappenException(); - } - - if ($bounds->min === 0) { - $inOptionalQuantification = true; - } + $topology = $this->collectTopology($pattern, null, null, 0, []); - if ($bounds->max === null || $bounds->max > 1) { - $repeatedMoreThanOnce = true; - } - } - - if ($ast instanceof AlternationNode) { - $astWalkResult = $astWalkResult->nextAlternationId(); - $alternation = new RegexAlternation($astWalkResult->getAlternationId(), count($ast->alternatives)); - } - - if ($ast instanceof PcreVerbNode && str_starts_with($ast->verb, 'MARK:')) { - return $astWalkResult->markVerb(substr($ast->verb, 5)); + $walkResult = RegexAstWalkResult::createEmpty(); + foreach ($shape->marks as $mark) { + $walkResult = $walkResult->markVerb($mark); } - if ( - $group instanceof RegexCapturingGroup && - (!$captureOnlyNamed || $group->isNamed()) - ) { - $astWalkResult = $astWalkResult->addCapturingGroup($group); - - if ($alternation !== null) { - $alternation->pushGroup($combinationIndex, $group); - } - } - - // a conditional holds its yes and no branches as the engine reads - // them: the groups they carry sit in one alternation - if ($ast instanceof ConditionalNode || $ast instanceof DefineNode) { - $astWalkResult = $this->walkRegexAst( - $ast instanceof ConditionalNode ? $ast->condition : $ast->content, - $alternation, - $combinationIndex, - $inOptionalQuantification, - $parentGroup, - $captureOnlyNamed, - $repeatedMoreThanOnce, - $patternModifiers, - $astWalkResult, + $alternations = []; + foreach ($topology['alternationNodes'] as $alternationNode) { + $walkResult = $walkResult->nextAlternationId(); + $alternations[spl_object_id($alternationNode)] = new RegexAlternation( + $walkResult->getAlternationId(), + count($alternationNode->alternatives), ); } - $branches = $ast instanceof AlternationNode - ? $ast->alternatives - : $ast->getChildren(); - - foreach ($branches as $i => $branch) { - $astWalkResult = $this->walkRegexAst( - $branch, - $alternation, - $ast instanceof AlternationNode ? $i : $combinationIndex, - $inOptionalQuantification, - $parentGroup, - $captureOnlyNamed, - $repeatedMoreThanOnce, - $patternModifiers, - $astWalkResult, + // numbered groups first, then the non-capturing ones above them, + // so every parent object exists before its children ask for it + $objects = []; + foreach ($topology['nonCapturing'] as $id => $nonCapturing) { + $objects[$id] = new RegexNonCapturingGroup( + $nonCapturing['alternation'] !== null ? $alternations[spl_object_id($nonCapturing['alternation'])] ?? null : null, + $nonCapturing['inOptional'], + $objects[$nonCapturing['parent']] ?? null, + $nonCapturing['resets'], ); } - if ($ast instanceof ConditionalNode || $ast instanceof DefineNode) { - $yesNo = $ast instanceof ConditionalNode - ? [$ast->yes, $ast->no] - : [$ast->content]; - $astWalkResult = $astWalkResult->nextAlternationId(); - $conditionAlternation = new RegexAlternation($astWalkResult->getAlternationId(), count($yesNo)); - - foreach ($yesNo as $i => $branch) { - $astWalkResult = $this->walkRegexAst( - $branch, - $conditionAlternation, - $i, - $inOptionalQuantification, - $parentGroup, - $captureOnlyNamed, - $repeatedMoreThanOnce, - $patternModifiers, - $astWalkResult, - ); - } - } - - return $astWalkResult; - } - - private function allowConstantTypes( - string $patternModifiers, - bool $repeatedMoreThanOnce, - RegexCapturingGroup|RegexNonCapturingGroup|null $parentGroup, - ): bool - { - if (str_contains($patternModifiers, 'i')) { - // if caseless, we don't use constant types - // because it likely yields too many combinations - return false; - } - - if ($repeatedMoreThanOnce) { - return false; - } - - if ($parentGroup !== null && $parentGroup->resetsGroupCounter()) { - return false; - } - - return true; - } - - private function createGroupType(GroupNode $group, bool $maybeConstant, string $patternModifiers): Type - { - $rootAlternation = $this->getRootAlternation($group); - if ($rootAlternation !== null) { - $types = []; - foreach ($rootAlternation->alternatives as $alternative) { - $types[] = $this->createGroupTypeFromNode($alternative, $maybeConstant, $patternModifiers); - } - - return TypeCombinator::union(...$types); - } - - return $this->createGroupTypeFromNode($group->child, $maybeConstant, $patternModifiers); - } - - private function createGroupTypeFromNode(NodeInterface $node, bool $maybeConstant, string $patternModifiers): Type - { - $walkResult = $this->walkGroupAst( - $node, - false, - $patternModifiers, - RegexGroupWalkResult::createEmpty(), - ); - - if ($maybeConstant && $walkResult->getOnlyLiterals() !== null && $walkResult->getOnlyLiterals() !== []) { - $result = []; - foreach ($walkResult->getOnlyLiterals() as $literal) { - $result[] = new ConstantStringType($literal); - - } - return TypeCombinator::union(...$result); - } - - if ($walkResult->isNumeric()->yes()) { - if ($walkResult->isNonFalsy()->yes()) { - return new IntersectionType([ - new StringType(), - new AccessoryNumericStringType(), - new AccessoryNonFalsyStringType(), - ]); - } - - $result = new IntersectionType([new StringType(), new AccessoryNumericStringType()]); - if (!$walkResult->isNonEmpty()->yes()) { - return new UnionType([new ConstantStringType(''), $result]); - } - return $result; - } elseif ($walkResult->isNonFalsy()->yes()) { - return new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]); - } elseif ($walkResult->isNonEmpty()->yes()) { - return new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]); - } - - return new StringType(); - } - - private function getRootAlternation(GroupNode $group): ?AlternationNode - { - if ($group->child instanceof AlternationNode) { - return $group->child; - } - - return null; - } - - private function walkGroupAst( - NodeInterface $ast, - bool $inClass, - string $patternModifiers, - RegexGroupWalkResult $walkResult, - ): RegexGroupWalkResult - { - $children = $ast->getChildren(); - $quantifiedLiterals = null; - - if ( - $ast instanceof SequenceNode - && count($children) > 0 - && !$walkResult->isInOptionalQuantification() - ) { - $meaningfulTokens = 0; - foreach ($children as $child) { - $nonFalsy = false; - if ($this->isMaybeEmptyNode($child, $patternModifiers, $nonFalsy)) { - continue; - } - - $meaningfulTokens++; - - if (!$nonFalsy) { - continue; - } - - // a single token non-falsy on its own - $walkResult = $walkResult->nonFalsy(TrinaryLogic::createYes()); - break; - } - - if ($meaningfulTokens > 0) { - $walkResult = $walkResult->nonEmpty(TrinaryLogic::createYes()); - - // two non-empty tokens concatenated results in a non-falsy string - if ($meaningfulTokens > 1) { - $walkResult = $walkResult->nonFalsy(TrinaryLogic::createYes()); - } - } - } elseif ($ast instanceof QuantifierNode) { - $bounds = QuantifierBounds::parse($ast->quantifier); - [$min, $max] = $bounds === null ? [null, null] : [$bounds->min, $bounds->max]; - - if ($min === 0) { - $walkResult = $walkResult->inOptionalQuantification(true); - } - - if (!$walkResult->isInOptionalQuantification()) { - if ($min !== null && $min >= 1) { - $walkResult = $walkResult->nonEmpty(TrinaryLogic::createYes()); - } - if ($min !== null && $min >= 2) { - $walkResult = $walkResult->nonFalsy(TrinaryLogic::createYes()); - } - } - - // "a?" yields 'a'|'', "a{1,2}" yields 'a'|'aa', etc. so a bounded quantification - // over constant literals can be combined with the surrounding literals - $quantifiedLiterals = $this->getQuantifiedLiterals($ast, $min, $max, $inClass, $patternModifiers, $walkResult); - - $walkResult = $walkResult->onlyLiterals(null); - } elseif ($ast instanceof CharClassNode && !$ast->isNegated && $walkResult->getOnlyLiterals() !== null) { - $inClass = true; - - $newLiterals = []; - $members = $ast->expression instanceof AlternationNode - ? $ast->expression->alternatives - : [$ast->expression]; - foreach ($this->classMembers($members) as $member) { - $oldLiterals = $walkResult->getOnlyLiterals(); - - $this->getLiteralValue($member, $oldLiterals, true, $patternModifiers, true); - foreach ($oldLiterals ?? [] as $oldLiteral) { - $newLiterals[] = $oldLiteral; - } - } - $walkResult = $walkResult->onlyLiterals($newLiterals); - } elseif ($ast instanceof LiteralNode) { - $onlyLiterals = $walkResult->getOnlyLiterals(); - $literalValue = $this->getLiteralValue($ast, $onlyLiterals, !$inClass, $patternModifiers, false); - $walkResult = $walkResult->onlyLiterals($onlyLiterals); - - if ($literalValue !== null) { - if (Strings::match($literalValue, '/^\d+$/') === null) { - $walkResult = $walkResult->numeric(TrinaryLogic::createNo()); - } elseif ($walkResult->isNumeric()->maybe()) { - $walkResult = $walkResult->numeric(TrinaryLogic::createYes()); - } - - if (!$walkResult->isInOptionalQuantification() && $literalValue !== '') { - $walkResult = $walkResult->nonEmpty(TrinaryLogic::createYes()); - } - } - } elseif ( - !($ast instanceof GroupNode - && ($ast->type === GroupType::Capturing() || $ast->type === GroupType::Named())) - && !$ast instanceof AlternationNode - && !$ast instanceof LiteralNode - ) { - // every other leaf and group the walk does not fold into constants - // drops them: an escape, a wildcard, a backreference, a verb — - // even a \d that reads as '0' — just as every token outside the - // literal family did - $literalValue = $this->getLiteralValue($ast, $onlyLiterals, !$inClass, $patternModifiers, false); - $walkResult = $walkResult->onlyLiterals(null); - - if ($literalValue !== null) { - if (Strings::match($literalValue, '/^\d+$/') === null) { - $walkResult = $walkResult->numeric(TrinaryLogic::createNo()); - } elseif ($walkResult->isNumeric()->maybe()) { - $walkResult = $walkResult->numeric(TrinaryLogic::createYes()); - } - - if (!$walkResult->isInOptionalQuantification() && $literalValue !== '') { - $walkResult = $walkResult->nonEmpty(TrinaryLogic::createYes()); - } - } - } - - if ($ast instanceof AlternationNode) { - if (count($ast->alternatives) === 0) { - return $walkResult; + $groupObjects = []; + foreach ($shape->groups as $groupShape) { + $entry = $topology['groups'][$groupShape->number] ?? null; + if ($entry === null) { + continue; } - // literals accumulated before the alternation form a common prefix that has to be - // combined with every branch, e.g. "a(b|c)" yields "ab"|"ac", not "b"|"c" - $prefixLiterals = $walkResult->getOnlyLiterals(); - $newLiterals = []; - $nonEmpty = TrinaryLogic::createYes(); - $nonFalsy = TrinaryLogic::createYes(); - $numeric = TrinaryLogic::createYes(); - foreach ($ast->alternatives as $alternative) { - $childResult = $this->walkGroupAst( - $alternative, - $inClass, - $patternModifiers, - $walkResult->onlyLiterals([]) - ->nonEmpty(TrinaryLogic::createMaybe()) - ->nonFalsy(TrinaryLogic::createMaybe()) - ->numeric(TrinaryLogic::createMaybe()), - ); - - $nonEmpty = $nonEmpty->and($childResult->isNonEmpty()); - $nonFalsy = $nonFalsy->and($childResult->isNonFalsy()); - $numeric = $numeric->and($childResult->isNumeric()); - - if ($newLiterals === null) { - continue; - } + $walkResult = $walkResult->nextCaptureGroupId(); - $childLiterals = $childResult->getOnlyLiterals(); - if ($prefixLiterals === null || $childLiterals === null || count($childLiterals) === 0) { - $newLiterals = null; - continue; - } - - foreach ($childLiterals as $childLiteral) { - if ($prefixLiterals === []) { - $newLiterals[] = $childLiteral; - } else { - foreach ($prefixLiterals as $prefixLiteral) { - $newLiterals[] = $prefixLiteral . $childLiteral; - } - } + $group = new RegexCapturingGroup( + $groupShape->number, + $groupShape->name, + $entry['alternation'] !== null ? $alternations[spl_object_id($entry['alternation'])] ?? null : null, + $entry['inOptional'], + $objects[$entry['parent']] ?? null, + $this->stringTypeOf($groupShape->values, $groupShape->digitsOnly, $groupShape->nonFalsy, $groupShape->minLength), + ); + $groupObjects[$groupShape->number] = $group; + // a nested group reaches its optionality through its parent, and + // a capturing parent carries its own optionality — both must sit + // in the same map the children look them up from + $objects[spl_object_id($entry['node'])] = $group; + + $walkResult = $walkResult->addCapturingGroup($group); + + if ($entry['alternation'] !== null) { + $alternationObject = $alternations[spl_object_id($entry['alternation'])] ?? null; + if ($alternationObject !== null) { + $alternationObject->pushGroup($entry['branch'], $group); } } - - return $walkResult - ->onlyLiterals($newLiterals) - ->nonEmpty($walkResult->isNonEmpty()->or($nonEmpty)) - ->nonFalsy($walkResult->isNonFalsy()->or($nonFalsy)) - ->numeric($walkResult->isNumeric()->and($numeric)); } - // [^0-9] should not parse as numeric-string, and [^list-everything-but-numbers] is technically - // doable but really silly compared to just \d so we can safely assume the string is not numeric - // for negative classes - if ($ast instanceof CharClassNode && $ast->isNegated) { - $walkResult = $walkResult->numeric(TrinaryLogic::createNo()); - } + // a non-capturing parent learns its capturing children only through + // them: they exist above, the objects array already holds the link + $whole = $shape->whole; + $matchType = $this->containsEscapeK($pattern) + ? new StringType() + : $this->stringTypeOf($whole->values, $whole->digitsOnly, $whole->nonFalsy, $whole->minLength); - // the members of a class the combination above already folded walk one - // by one below, as they did before, without the wrapper that groups - // them in the tree - $descend = $ast instanceof CharClassNode - ? $this->classMembers($ast->expression instanceof AlternationNode - ? $ast->expression->alternatives - : [$ast->expression]) - : $children; - - foreach ($descend as $child) { - $walkResult = $this->walkGroupAst( - $child, - $inClass, - $patternModifiers, - $walkResult, - ); - } + // the subject contains the match, whatever anchors hold: its accessories + // — non-empty, non-falsy — carry over by containment, while nothing + // sharper survives: the match's constants and its digits are the + // match's own, and $ admits one final newline ("a\n" on /^(a|b|c)$/) + $subjectNarrowType = $this->containsEscapeK($pattern) + ? new StringType() + : $this->stringTypeOf(null, false, $whole->nonFalsy, $whole->minLength); - if ($ast instanceof QuantifierNode) { - // the bottom walk above nulls the literals via the quantifier node, - // so restore the literals enumerated up-front for bounded quantifications - $walkResult = $walkResult->onlyLiterals($quantifiedLiterals); - } - - return $walkResult; + return $walkResult + ->withSubjectBaseType($matchType) + ->withSubjectNarrowType($subjectNarrowType); } /** - * Enumerate the constant strings a bounded quantification (like "a?", "a{2}", "a{1,3}") - * produces, combined with the literals accumulated so far. Returns null when the result - * cannot be enumerated (unbounded quantifier, non-literal atom, or too many combinations). + * Where every group sits: its parent group node, the alternation above + * it, the branch it captures in. The shape numbers the groups the way + * the engine does; the topology walks the tree the same way, so a + * number finds its node. * - * @return array|null + * @param array{alternationNodes: list, groups: array, nonCapturing: array} $topology + * @return array{alternationNodes: list, groups: array, nonCapturing: array} */ - private function getQuantifiedLiterals(QuantifierNode $ast, ?int $min, ?int $max, bool $inClass, string $patternModifiers, RegexGroupWalkResult $walkResult): ?array + private function collectTopology( + NodeInterface $node, + ?int $parentId, + ?AlternationNode $alternation, + int $branch, + array $topology, + bool $inOptionalQuantification = false, + ): array { - $prefixLiterals = $walkResult->getOnlyLiterals(); - if ($prefixLiterals === null || $min === null || $max === null) { - return null; - } + $topology['alternationNodes'] ??= []; + $topology['groups'] ??= []; + $topology['nonCapturing'] ??= []; - // walk the quantified atom standalone; the atom itself is not optional, - // so reset the flag to let concatenations accumulate literals - $atomResult = $walkResult->onlyLiterals([])->inOptionalQuantification(false); - $atomResult = $this->walkGroupAst($ast->node, $inClass, $patternModifiers, $atomResult); - - $atomLiterals = $atomResult->getOnlyLiterals(); - if ($atomLiterals === null) { - return null; - } - - $repeatedLiterals = $this->repeatLiterals($atomLiterals, $min, $max); - if ($repeatedLiterals === null) { - return null; - } - - $newLiterals = []; - foreach ($repeatedLiterals as $repeatedLiteral) { - if ($prefixLiterals === []) { - $newLiterals[] = $repeatedLiteral; - } else { - foreach ($prefixLiterals as $prefixLiteral) { - $newLiterals[] = $prefixLiteral . $repeatedLiteral; - } + if ($node instanceof AlternationNode) { + $topology['alternationNodes'][] = $node; + foreach ($node->alternatives as $i => $alternative) { + $topology = $this->collectTopology($alternative, $parentId, $node, $i, $topology); } - if (count($newLiterals) > self::LITERALS_LIMIT) { - return null; - } + return $topology; } - return $newLiterals; - } - - /** - * @param array $literals - * @return array|null - */ - private function repeatLiterals(array $literals, int $min, int $max): ?array - { - $collected = []; - if ($min === 0) { - $collected[''] = ''; + if ($node instanceof DefineNode) { + // the engine never fills what a (?(DEFINE)...) block defines: the + // group it counts stays optional forever, and nothing else in the + // topology can tell — the shape says the rest + return $this->collectTopology($node->content, $parentId, null, 0, $topology, true); } - $current = ['']; - for ($k = 1; $k <= $max; $k++) { - $next = []; - foreach ($current as $prefix) { - foreach ($literals as $literal) { - $next[] = $prefix . $literal; - } - - if (count($next) > self::LITERALS_LIMIT) { - return null; - } + if ($node instanceof ConditionalNode) { + // the engine numbers the groups of both branches and never the + // condition itself + foreach ([$node->yes, $node->no] as $body) { + $topology = $this->collectTopology($body, $parentId, null, 0, $topology); } - $current = $next; - if ($k < $min) { - continue; - } - - foreach ($current as $value) { - $collected[$value] = $value; - } - - if (count($collected) > self::LITERALS_LIMIT) { - return null; - } + return $topology; } - return array_values($collected); - } - - private function isMaybeEmptyNode(NodeInterface $node, string $patternModifiers, bool &$isNonFalsy): bool - { + // an optional quantifier marks the one node it wraps, and only that + // one: a group further below learns its optionality from its parent + // chain, the way the matcher reads it if ($node instanceof QuantifierNode) { $bounds = QuantifierBounds::parse($node->quantifier); - $min = $bounds === null ? null : $bounds->min; - - if ($min !== null && $min > 0) { - return false; - } - if ($min === 0) { - return true; - } - } - - $literal = $this->getLiteralValue($node, $onlyLiterals, false, $patternModifiers, false); - if ($literal !== null) { - if ($literal !== '' && $literal !== '0') { - $isNonFalsy = true; - } - return $literal === ''; + return $this->collectTopology( + $node->node, + $parentId, + $alternation, + $branch, + $topology, + $bounds !== null && $bounds->min === 0, + ); } - // an alternation is maybe-empty if any of its branches is maybe-empty, - // and non-falsy only if every branch is non-falsy - if ($node instanceof AlternationNode) { - $maybeEmpty = false; - $allNonFalsy = true; - foreach ($node->alternatives as $alternative) { - $childNonFalsy = false; - if ($this->isMaybeEmptyNode($alternative, $patternModifiers, $childNonFalsy)) { - $maybeEmpty = true; - } - $allNonFalsy = $allNonFalsy && $childNonFalsy; - } - - if ($allNonFalsy) { - $isNonFalsy = true; + if ($node instanceof GroupNode) { + if ($node->type === GroupType::Capturing() || $node->type === GroupType::Named()) { + $number = count($topology['groups']) + 1; + $topology['groups'][$number] = [ + 'node' => $node, + 'parent' => $parentId, + 'alternation' => $alternation, + 'branch' => $branch, + 'inOptional' => $inOptionalQuantification, + ]; + $parentId = spl_object_id($node); + } else { + $topology['nonCapturing'][spl_object_id($node)] = [ + 'parent' => $parentId, + 'alternation' => $alternation, + 'inOptional' => $inOptionalQuantification, + 'resets' => $node->type === GroupType::BranchReset(), + ]; + $parentId = spl_object_id($node); } - - return $maybeEmpty; } foreach ($node->getChildren() as $child) { - if (!$this->isMaybeEmptyNode($child, $patternModifiers, $isNonFalsy)) { - return false; - } + $topology = $this->collectTopology($child, $parentId, $alternation, $branch, $topology); } - return true; + return $topology; } - /** - * The members a character class enumerates, one array entry per - * character, as the engine reads them. A range keeps its own node: it - * enumerates no constant of its own and drops the constants around it, - * as it always did. - * - * @param list $members - * @return list - */ - private function classMembers(array $members): array + private function stringTypeOf(?array $values, bool $digitsOnly, bool $nonFalsy, int $minLength): Type { - $result = []; - foreach ($members as $member) { - if ($member instanceof LiteralNode) { - foreach (mb_str_split($member->value, encoding: 'UTF-8') as $char) { - $result[] = new LiteralNode($char, $member->getStartPosition(), $member->getEndPosition()); - } - continue; + if ($values !== null && $values !== []) { + $types = []; + foreach ($values as $value) { + $types[] = new ConstantStringType($value); } - $result[] = $member; + return TypeCombinator::union(...$types); } - return $result; - } - - /** - * @param array|null $onlyLiterals - */ - private function getLiteralValue(NodeInterface $node, ?array &$onlyLiterals, bool $appendLiterals, string $patternModifiers, bool $inCharacterClass): ?string - { - if ($node instanceof LiteralNode) { - if (str_contains($patternModifiers, 'x') && trim($node->value) === '') { - return null; + if ($digitsOnly) { + if ($nonFalsy) { + return new IntersectionType([ + new StringType(), + new AccessoryNumericStringType(), + new AccessoryNonFalsyStringType(), + ]); } - // one literal per character, as the walk counts tokens, the whole - // run back as the value; an empty run reads as the empty string, - // as the empty branch of an alternation always did; a literal - // "." outside a class is a constant, the wildcard is its own node - $chars = $node->value === '' ? [''] : mb_str_split($node->value, encoding: 'UTF-8'); - - if ($appendLiterals && $onlyLiterals !== null) { - foreach ($chars as $char) { - if ($onlyLiterals === []) { - $onlyLiterals = [$char]; - } else { - foreach ($onlyLiterals as &$literal) { - $literal .= $char; - } - } - } + $result = new IntersectionType([new StringType(), new AccessoryNumericStringType()]); + if ($minLength === 0) { + return new UnionType([new ConstantStringType(''), $result]); } - return $node->value; - } - - if ($node instanceof DotNode) { - // the wildcard matches anything: no constant literals survive it - $onlyLiterals = null; - - return '.'; + return $result; } - if ($node instanceof CharTypeNode) { - if (!$inCharacterClass && $node->value === 'd') { - return '0'; - } - - return '\\' . $node->value; + if ($nonFalsy) { + return new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]); } - if ($node instanceof UnicodePropNode) { - return '\\' . ($node->negatedSyntax ? 'P' : 'p') . $node->prop; + if ($minLength >= 1) { + return new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]); } - if ($node instanceof CharLiteralNode) { - return mb_chr($node->codePoint, 'UTF-8') ?? ''; - } + return new StringType(); + } - if ($node instanceof ControlCharNode) { - return mb_chr($node->codePoint, 'UTF-8') ?? ''; + private function containsEscapeK(NodeInterface $node): bool + { + if ($node instanceof KeepNode) { + return true; } - // [:digit:] and the like - if ($node instanceof PosixClassNode) { - if ($node->class === 'digit') { - return '0'; - } - if (in_array($node->class, ['alpha', 'alnum', 'upper', 'lower', 'word', 'ascii', 'print', 'xdigit', 'graph'], true)) { - return 'a'; - } - if ($node->class === 'blank') { - return " \t"; - } - if ($node->class === 'cntrl') { - return "\x00\x1F"; - } - if ($node->class === 'space') { - return " \t\r\n\v\f"; - } - if ($node->class === 'punct') { - return '!"#$%&\'()*+,\-./:;<=>?@[\]^_`{|}~'; + foreach ($node->getChildren() as $child) { + if ($this->containsEscapeK($child)) { + return true; } } - if ($node instanceof AnchorNode || $node instanceof AssertionNode || $node instanceof KeepNode) { - return ''; - } - - // backreferences, subroutine calls, verbs, callouts: their content is - // not a constant, and no constant literals survive them - $onlyLiterals = null; - - return null; + return false; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-11311.php b/tests/PHPStan/Analyser/nsrt/bug-11311.php index 79fbf3aca44..ed86b25da5f 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11311.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11311.php @@ -14,9 +14,9 @@ function doFoo(string $s) { function doUnmatchedAsNull(string $s): void { if (preg_match('/(foo)?(bar)?(baz)?/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType("array{string, 'foo'|null, 'bar'|null, 'baz'|null}", $matches); + assertType("array{''|'bar'|'barbaz'|'baz'|'foo'|'foobar'|'foobarbaz'|'foobaz', 'foo'|null, 'bar'|null, 'baz'|null}", $matches); } - assertType("array{}|array{string, 'foo'|null, 'bar'|null, 'baz'|null}", $matches); + assertType("array{}|array{''|'bar'|'barbaz'|'baz'|'foo'|'foobar'|'foobarbaz'|'foobaz', 'foo'|null, 'bar'|null, 'baz'|null}", $matches); } // see https://3v4l.org/VeDob @@ -85,21 +85,21 @@ function (string $size): void { if (preg_match('/ab(\d){2,4}xx([0-9])?e?/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{non-falsy-string, numeric-string, numeric-string|null}', $matches); + assertType('array{non-falsy-string, numeric-string, \'0\'|\'1\'|\'2\'|\'3\'|\'4\'|\'5\'|\'6\'|\'7\'|\'8\'|\'9\'|null}', $matches); }; function (string $size): void { if (preg_match('/a(\dAB){2}b(\d){2,4}([1-5])([1-5a-z])e?/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{non-falsy-string, non-falsy-string, numeric-string, numeric-string, non-empty-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string, numeric-string, \'1\'|\'2\'|\'3\'|\'4\'|\'5\', \'1\'|\'2\'|\'3\'|\'4\'|\'5\'|\'a\'|\'b\'|\'c\'|\'d\'|\'e\'|\'f\'|\'g\'|\'h\'|\'i\'|\'j\'|\'k\'|\'l\'|\'m\'|\'n\'|\'o\'|\'p\'|\'q\'|\'r\'|\'s\'|\'t\'|\'u\'|\'v\'|\'w\'|\'x\'|\'y\'|\'z\'}', $matches); }; function (string $size): void { if (preg_match('/ab(ab(\d)){2,4}xx([0-9][a-c])?e?/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{non-falsy-string, non-falsy-string, numeric-string, non-falsy-string|null}', $matches); + assertType('array{non-falsy-string, non-falsy-string, numeric-string, \'0a\'|\'0b\'|\'0c\'|\'1a\'|\'1b\'|\'1c\'|\'2a\'|\'2b\'|\'2c\'|\'3a\'|\'3b\'|\'3c\'|\'4a\'|\'4b\'|\'4c\'|\'5a\'|\'5b\'|\'5c\'|\'6a\'|\'6b\'|\'6c\'|\'7a\'|\'7b\'|\'7c\'|\'8a\'|\'8b\'|\'8c\'|\'9a\'|\'9b\'|\'9c\'|null}', $matches); }; function (string $size): void { @@ -134,7 +134,7 @@ function (string $size): void { if (preg_match('/ab(\s)e?/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{non-falsy-string, non-empty-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string}', $matches); }; function (string $size): void { @@ -167,13 +167,13 @@ function (string $size): void { function (string $s): void { if (preg_match('/Price: ([2-5])/i', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{non-falsy-string, numeric-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string&numeric-string}', $matches); } }; function (string $s): void { if (preg_match('/Price: ([2-5A-Z])/i', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{non-falsy-string, non-empty-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string}', $matches); } }; @@ -201,26 +201,26 @@ function (string $s): void { function (string $s): void { if (preg_match('~a|(\d)|(\s)~', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType("array{non-empty-string, numeric-string|null, non-empty-string|null}", $matches); + assertType("array{non-empty-string, numeric-string|null, non-falsy-string|null}", $matches); } else { assertType("array{}", $matches); } - assertType("array{}|array{non-empty-string, numeric-string|null, non-empty-string|null}", $matches); + assertType("array{}|array{non-empty-string, numeric-string|null, non-falsy-string|null}", $matches); }; function (string $s): void { if (preg_match('~a|(\d)|(\s)~', $s, $matches, PREG_UNMATCHED_AS_NULL|PREG_OFFSET_CAPTURE) === 1) { - assertType("array{array{non-empty-string|null, int<-1, max>}, array{numeric-string|null, int<-1, max>}, array{non-empty-string|null, int<-1, max>}}", $matches); + assertType("array{array{non-empty-string|null, int<-1, max>}, array{numeric-string|null, int<-1, max>}, array{non-falsy-string|null, int<-1, max>}}", $matches); } }; function (string $s): void { if (preg_match('~a|((u)x)|((v)y)~', $s, $matches, PREG_UNMATCHED_AS_NULL) === 1) { - assertType("array{non-empty-string, 'ux'|null, 'u'|null, 'vy'|null, 'v'|null}", $matches); + assertType("array{'a'|'ux'|'vy', 'ux'|null, 'u'|null, 'vy'|null, 'v'|null}", $matches); } }; function (string $s): void { preg_match('~a|(\d)|(\s)~', $s, $matches, PREG_UNMATCHED_AS_NULL); - assertType("array{}|array{non-empty-string, numeric-string|null, non-empty-string|null}", $matches); + assertType("array{}|array{non-empty-string, numeric-string|null, non-falsy-string|null}", $matches); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-12210.php b/tests/PHPStan/Analyser/nsrt/bug-12210.php index 460ef458062..b30b50be1b8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-12210.php +++ b/tests/PHPStan/Analyser/nsrt/bug-12210.php @@ -8,7 +8,7 @@ function bug12210a(string $text): void { assert(preg_match('(((sum|min|max)))', $text, $match) === 1); - assertType("array{non-falsy-string, 'max'|'min'|'sum', 'max'|'min'|'sum'}", $match); + assertType("array{'max'|'min'|'sum', 'max'|'min'|'sum', 'max'|'min'|'sum'}", $match); } function bug12210b(string $text): void { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14314.php b/tests/PHPStan/Analyser/nsrt/bug-14314.php index ea451aa59e8..28725f8064e 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14314.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14314.php @@ -8,9 +8,9 @@ function () { preg_match('/^(.)$/', '', $matches) || preg_match('/^(.)(.)(.)$/', '', $matches); - assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}|array{non-falsy-string, non-empty-string}', $matches); + assertType('array{}|array{non-empty-string, non-empty-string}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $matches); if (count($matches) === 2) { - assertType('array{non-falsy-string, non-empty-string}', $matches); + assertType('array{non-empty-string, non-empty-string}', $matches); return; } assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $matches); @@ -86,9 +86,9 @@ public function testIntRangeWithUnionAndEmpty(array $arr, int $twoToFour): void // Test: sequential count checks preserve narrowing correctly function () { preg_match('/^(.)$/', '', $m) || preg_match('/^(.)(.)(.)$/', '', $m) || preg_match('/^(.)(.)(.)(.)(.)(.)$/', '', $m); - assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}|array{non-falsy-string, non-empty-string}', $m); + assertType('array{}|array{non-empty-string, non-empty-string}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $m); if (count($m) === 2) { - assertType('array{non-falsy-string, non-empty-string}', $m); + assertType('array{non-empty-string, non-empty-string}', $m); return; } assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $m); @@ -108,7 +108,7 @@ function (int $x) { if ($x > 0) { assertType('int<1, max>', $x); if (count($matches) === 2) { - assertType('array{non-falsy-string, non-empty-string}', $matches); + assertType('array{non-empty-string, non-empty-string}', $matches); assertType('int<1, max>', $x); return; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14575.php b/tests/PHPStan/Analyser/nsrt/bug-14575.php index 6564cac8b15..3c241c08229 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14575.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14575.php @@ -7,81 +7,81 @@ function doFoo(string $string): void { // Anchors in alternation should not make the match non-empty if (preg_match('(foo|$)', $string, $match)) { - assertType('array{string}', $match); + assertType('array{\'\'|\'foo\'}', $match); } if (preg_match('(^|foo)', $string, $match)) { - assertType('array{string}', $match); + assertType('array{\'\'}', $match); } if (preg_match('(\b|foo)', $string, $match)) { - assertType('array{string}', $match); + assertType('array{\'\'}', $match); } if (preg_match('/foo|$/', $string, $match)) { - assertType('array{string}', $match); + assertType('array{\'\'|\'foo\'}', $match); } if (preg_match('/^|bar/', $string, $match)) { - assertType('array{string}', $match); + assertType('array{\'\'}', $match); } // Anchor in alternation within capturing group if (preg_match('/(foo|$)/', $string, $match)) { - assertType('array{string, string}', $match); + assertType('array{\'\'|\'foo\', \'\'|\'foo\'}', $match); } if (preg_match('/(^|bar)/', $string, $match)) { - assertType('array{string, string}', $match); + assertType('array{\'\', \'\'}', $match); } // Anchor in alternation does not affect parent concatenation if (preg_match('/^abc(def|$)/', $string, $match)) { - assertType("array{non-falsy-string, string}", $match); + assertType("array{'abc'|'abcdef', ''|'def'}", $match); } // All non-empty alternatives should still produce non-empty/non-falsy if (preg_match('/foo|bar/', $string, $match)) { - assertType('array{non-falsy-string}', $match); + assertType('array{\'bar\'|\'foo\'}', $match); } if (preg_match('/foo/', $string, $match)) { - assertType('array{non-falsy-string}', $match); + assertType('array{\'foo\'}', $match); } // Anchor alone if (preg_match('/^$/', $string, $match)) { - assertType('array{string}', $match); + assertType('array{\'\'}', $match); } // Three-way alternation with anchor if (preg_match('/foo|bar|$/', $string, $match)) { - assertType('array{string}', $match); + assertType('array{\'\'|\'bar\'|\'foo\'}', $match); } // All alternatives are single chars (non-falsy not determinable from single tokens) if (preg_match('/a|b/', $string, $match)) { - assertType('array{non-empty-string}', $match); + assertType('array{\'a\'|\'b\'}', $match); } // Empty alternation branches if (preg_match('/(|)/', $string, $match)) { - assertType("array{string, ''}", $match); + assertType("array{'', ''}", $match); } if (preg_match('/(foo|)/', $string, $match)) { - assertType("array{string, ''|'foo'}", $match); + assertType("array{''|'foo', ''|'foo'}", $match); } if (preg_match('/(|bar)/', $string, $match)) { - assertType("array{string, ''|'bar'}", $match); + assertType("array{''|'bar', ''|'bar'}", $match); } if (preg_match('/|/', $string, $match)) { - assertType('array{string}', $match); + assertType('array{\'\'}', $match); } if (preg_match('/foo|/', $string, $match)) { - assertType('array{string}', $match); + assertType('array{\'\'|\'foo\'}', $match); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14710.php b/tests/PHPStan/Analyser/nsrt/bug-14710.php index 711580f5365..8b5e24ac5b5 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14710.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14710.php @@ -89,7 +89,7 @@ function pregMatchWithNonConstantPattern(string $pattern, string $x): void { function pregMatchSubjectSharesVarWithMatches(): void { $matches = ['', '', 'foo']; if (preg_match('/^(a|b|c)$/', $matches[2], $matches)) { - assertType("array{non-falsy-string, 'a'|'b'|'c'}", $matches); + assertType("array{'a'|'b'|'c', 'a'|'b'|'c'}", $matches); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14809.php b/tests/PHPStan/Analyser/nsrt/bug-14809.php index b809a18dc59..6b96d6ceba4 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14809.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14809.php @@ -22,18 +22,18 @@ function doFoo(string $s): void // guard: an alternation whose branches are all non-empty stays non-falsy if (preg_match('~(a(b|c))~', $s, $m)) { - assertType("array{non-falsy-string, 'ab'|'ac', 'b'|'c'}", $m); + assertType("array{'ab'|'ac', 'ab'|'ac', 'b'|'c'}", $m); } if (preg_match('~((a|b)(b|c))~', $s, $m)) { - assertType("array{non-falsy-string, 'ab'|'ac'|'bb'|'bc', 'a'|'b', 'b'|'c'}", $m); + assertType("array{'ab'|'ac'|'bb'|'bc', 'ab'|'ac'|'bb'|'bc', 'a'|'b', 'b'|'c'}", $m); } if (preg_match('~((b|c)d)~', $s, $m)) { - assertType("array{non-falsy-string, 'bd'|'cd', 'b'|'c'}", $m); + assertType("array{'bd'|'cd', 'bd'|'cd', 'b'|'c'}", $m); } if (preg_match('~(a(b|c)d)~', $s, $m)) { - assertType("array{non-falsy-string, 'abd'|'acd', 'b'|'c'}", $m); + assertType("array{'abd'|'acd', 'abd'|'acd', 'b'|'c'}", $m); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14820.php b/tests/PHPStan/Analyser/nsrt/bug-14820.php index 68436e9d868..b8fcdaf1f9e 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14820.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14820.php @@ -9,46 +9,46 @@ function doFoo(string $s): void // a single optional literal yields the literal or the empty string, // combined with the surrounding literals if (preg_match('~(ab?)~', $s, $m)) { - assertType("array{non-falsy-string, 'a'|'ab'}", $m); + assertType("array{'a'|'ab', 'a'|'ab'}", $m); } if (preg_match('~(ab?c)~', $s, $m)) { - assertType("array{non-falsy-string, 'abc'|'ac'}", $m); + assertType("array{'abc'|'ac', 'abc'|'ac'}", $m); } // optional in front if (preg_match('~(a?bc)~', $s, $m)) { - assertType("array{non-falsy-string, 'abc'|'bc'}", $m); + assertType("array{'abc'|'bc', 'abc'|'bc'}", $m); } // two optionals combine into the full cross-product if (preg_match('~(a?b?)~', $s, $m)) { - assertType("array{string, ''|'a'|'ab'|'b'}", $m); + assertType("array{''|'a'|'ab'|'b', ''|'a'|'ab'|'b'}", $m); } // optional over a (sub) group of literals if (preg_match('~(a(bc)?d)~', $s, $m)) { - assertType("array{0: non-falsy-string, 1: 'abcd'|'ad', 2?: 'bc'}", $m); + assertType("array{0: 'abcd'|'ad', 1: 'abcd'|'ad', 2?: 'bc'}", $m); } // optional over an alternation if (preg_match('~(a(b|c)?d)~', $s, $m)) { - assertType("array{0: non-falsy-string, 1: 'abd'|'acd'|'ad', 2?: 'b'|'c'}", $m); + assertType("array{0: 'abd'|'acd'|'ad', 1: 'abd'|'acd'|'ad', 2?: 'b'|'c'}", $m); } // the classic colour/color example if (preg_match('~(colou?r)~', $s, $m)) { - assertType("array{non-falsy-string, 'color'|'colour'}", $m); + assertType("array{'color'|'colour', 'color'|'colour'}", $m); } // exactly-n repetition of a literal if (preg_match('~(ab{2}c)~', $s, $m)) { - assertType("array{non-falsy-string, 'abbc'}", $m); + assertType("array{'abbc', 'abbc'}", $m); } // n-to-m repetition of a literal if (preg_match('~(ab{1,2}c)~', $s, $m)) { - assertType("array{non-falsy-string, 'abbc'|'abc'}", $m); + assertType("array{'abbc'|'abc', 'abbc'|'abc'}", $m); } // unbounded repetition stays non-constant diff --git a/tests/PHPStan/Analyser/nsrt/bug-15297.php b/tests/PHPStan/Analyser/nsrt/bug-15297.php index f3d734fb71a..d2c061a06c9 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15297.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15297.php @@ -14,13 +14,13 @@ function doFoo(string $s): void (?:-(?P[a-z]+))? )$/ix"; if (preg_match($pattern, $s, $matches)) { - assertType("array{0: non-falsy-string, foo1: '', 1: '', bar1: '', 2: '', foo2: non-falsy-string, 3: non-falsy-string, bar2?: non-empty-string, 4?: non-empty-string}|array{0: non-falsy-string, foo1: non-falsy-string, 1: non-falsy-string, bar1?: numeric-string, 2?: numeric-string}", $matches); + assertType("array{0: non-falsy-string, foo1: '', 1: '', bar1: '', 2: '', foo2: non-falsy-string, 3: non-falsy-string, bar2?: non-falsy-string, 4?: non-falsy-string}|array{0: non-falsy-string, foo1: non-falsy-string, 1: non-falsy-string, bar1?: numeric-string, 2?: numeric-string}", $matches); } } function doBar(string $s): void { if (preg_match('/^(?:(?:(a))?(b)|(c))$/', $s, $matches)) { - assertType("array{non-falsy-string, '', '', 'c'}|list{0: non-falsy-string, 1?: ''|'a', 2: 'b'}", $matches); + assertType("array{'ab'|'b'|'c', '', '', 'c'}|list{0: 'ab'|'b'|'c', 1?: ''|'a', 2: 'b'}", $matches); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug11384.php b/tests/PHPStan/Analyser/nsrt/bug11384.php index 709f2986351..b8dc7a856da 100644 --- a/tests/PHPStan/Analyser/nsrt/bug11384.php +++ b/tests/PHPStan/Analyser/nsrt/bug11384.php @@ -14,7 +14,7 @@ class HelloWorld public function sayHello(string $s): void { if (preg_match('{(' . Bar::VAL . ')}', $s, $m)) { - assertType("array{non-empty-string, '3'}", $m); + assertType("array{'3', '3'}", $m); } } } diff --git a/tests/PHPStan/Analyser/nsrt/by-ref-writeback-native-type.php b/tests/PHPStan/Analyser/nsrt/by-ref-writeback-native-type.php index 6ebab6d0b0d..a3334adfb6c 100644 --- a/tests/PHPStan/Analyser/nsrt/by-ref-writeback-native-type.php +++ b/tests/PHPStan/Analyser/nsrt/by-ref-writeback-native-type.php @@ -11,8 +11,8 @@ function offsetCapture(string $s): void return; } - assertType("array{0: array{non-falsy-string, int<-1, max>}, 1: array{'a', int<-1, max>}, 2?: array{'b', int<-1, max>}}", $m); - assertNativeType("array{0: array{non-falsy-string, int<-1, max>}, 1: array{'a', int<-1, max>}, 2?: array{'b', int<-1, max>}}", $m); + assertType("array{0: array{'a'|'ab', int<-1, max>}, 1: array{'a', int<-1, max>}, 2?: array{'b', int<-1, max>}}", $m); + assertNativeType("array{0: array{'a'|'ab', int<-1, max>}, 1: array{'a', int<-1, max>}, 2?: array{'b', int<-1, max>}}", $m); } function noFlags(string $s): void @@ -21,8 +21,8 @@ function noFlags(string $s): void return; } - assertType("array{0: non-falsy-string, 1: 'a', 2?: 'b'}", $m); - assertNativeType("array{0: non-falsy-string, 1: 'a', 2?: 'b'}", $m); + assertType("array{0: 'a'|'ab', 1: 'a', 2?: 'b'}", $m); + assertNativeType("array{0: 'a'|'ab', 1: 'a', 2?: 'b'}", $m); } /** @param-out string $v */ diff --git a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php index 29914710ad1..61413977db4 100644 --- a/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -284,13 +284,13 @@ public function gettypeNarrowing($is, $m): void public function pregMatchNarrowing(string $s): void { if (preg_match('/^a(b)c$/', $s, $matches) === 1) { - assertType("array{non-falsy-string, 'b'}", $matches); + assertType("array{'abc', 'b'}", $matches); } if (1 === preg_match('/^a(b)c$/', $s, $matches2)) { - assertType("array{non-falsy-string, 'b'}", $matches2); + assertType("array{'abc', 'b'}", $matches2); } if (preg_match('/^a(b)c$/', $s, $matches3) === 0) { - assertType("array{}|array{non-falsy-string, 'b'}", $matches3); + assertType("array{}|array{'abc', 'b'}", $matches3); } } diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php index 91620523cad..6b1a802abaa 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php @@ -19,19 +19,19 @@ function doMatch(string $s): void { assertType("array{}|array{non-falsy-string, '£'|'€'}", $matches); if (preg_match('/Price: (£|€)(\d+)/i', $s, $matches)) { - assertType('array{non-falsy-string, non-empty-string, numeric-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string, numeric-string}', $matches); } - assertType('array{}|array{non-falsy-string, non-empty-string, numeric-string}', $matches); + assertType('array{}|array{non-falsy-string, non-falsy-string, numeric-string}', $matches); if (preg_match(' /Price: (£|€)\d+/ i u', $s, $matches)) { - assertType('array{non-falsy-string, non-empty-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string}', $matches); } - assertType('array{}|array{non-falsy-string, non-empty-string}', $matches); + assertType('array{}|array{non-falsy-string, non-falsy-string}', $matches); if (preg_match('(Price: (£|€))i', $s, $matches)) { - assertType('array{non-falsy-string, non-empty-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string}', $matches); } - assertType('array{}|array{non-falsy-string, non-empty-string}', $matches); + assertType('array{}|array{non-falsy-string, non-falsy-string}', $matches); if (preg_match('_foo(.)\_i_i', $s, $matches)) { assertType('array{non-falsy-string, non-empty-string}', $matches); @@ -39,54 +39,54 @@ function doMatch(string $s): void { assertType('array{}|array{non-falsy-string, non-empty-string}', $matches); if (preg_match('/(a)(b)*(c)(d)*/', $s, $matches)) { - assertType("array{0: non-falsy-string, 1: 'a', 2: string, 3: 'c', 4?: non-empty-string}", $matches); + assertType("array{0: non-falsy-string, 1: 'a', 2: ''|'b', 3: 'c', 4?: 'd'}", $matches); } - assertType("array{}|array{0: non-falsy-string, 1: 'a', 2: string, 3: 'c', 4?: non-empty-string}", $matches); + assertType("array{}|array{0: non-falsy-string, 1: 'a', 2: ''|'b', 3: 'c', 4?: 'd'}", $matches); if (preg_match('/(a)(?b)*(c)(d)*/', $s, $matches)) { - assertType("array{0: non-falsy-string, 1: 'a', name: string, 2: string, 3: 'c', 4?: non-empty-string}", $matches); + assertType("array{0: non-falsy-string, 1: 'a', name: ''|'b', 2: ''|'b', 3: 'c', 4?: 'd'}", $matches); } - assertType("array{}|array{0: non-falsy-string, 1: 'a', name: string, 2: string, 3: 'c', 4?: non-empty-string}", $matches); + assertType("array{}|array{0: non-falsy-string, 1: 'a', name: ''|'b', 2: ''|'b', 3: 'c', 4?: 'd'}", $matches); if (preg_match('/(a)(b)*(c)(?d)*/', $s, $matches)) { - assertType("array{0: non-falsy-string, 1: 'a', 2: string, 3: 'c', name?: non-empty-string, 4?: non-empty-string}", $matches); + assertType("array{0: non-falsy-string, 1: 'a', 2: ''|'b', 3: 'c', name?: 'd', 4?: 'd'}", $matches); } - assertType("array{}|array{0: non-falsy-string, 1: 'a', 2: string, 3: 'c', name?: non-empty-string, 4?: non-empty-string}", $matches); + assertType("array{}|array{0: non-falsy-string, 1: 'a', 2: ''|'b', 3: 'c', name?: 'd', 4?: 'd'}", $matches); if (preg_match('/(a|b)|(?:c)/', $s, $matches)) { - assertType("array{0: non-empty-string, 1?: 'a'|'b'}", $matches); + assertType("array{0: 'a'|'b'|'c', 1?: 'a'|'b'}", $matches); } - assertType("array{}|array{0: non-empty-string, 1?: 'a'|'b'}", $matches); + assertType("array{}|array{0: 'a'|'b'|'c', 1?: 'a'|'b'}", $matches); if (preg_match('/(foo)(bar)(baz)+/', $s, $matches)) { - assertType("array{non-falsy-string, 'foo', 'bar', non-falsy-string}", $matches); + assertType("array{non-falsy-string, 'foo', 'bar', 'baz'}", $matches); } - assertType("array{}|array{non-falsy-string, 'foo', 'bar', non-falsy-string}", $matches); + assertType("array{}|array{non-falsy-string, 'foo', 'bar', 'baz'}", $matches); if (preg_match('/(foo)(bar)(baz)*/', $s, $matches)) { - assertType("array{0: non-falsy-string, 1: 'foo', 2: 'bar', 3?: non-falsy-string}", $matches); + assertType("array{0: non-falsy-string, 1: 'foo', 2: 'bar', 3?: 'baz'}", $matches); } - assertType("array{}|array{0: non-falsy-string, 1: 'foo', 2: 'bar', 3?: non-falsy-string}", $matches); + assertType("array{}|array{0: non-falsy-string, 1: 'foo', 2: 'bar', 3?: 'baz'}", $matches); if (preg_match('/(foo)(bar)(baz)?/', $s, $matches)) { - assertType("array{0: non-falsy-string, 1: 'foo', 2: 'bar', 3?: 'baz'}", $matches); + assertType("array{0: 'foobar'|'foobarbaz', 1: 'foo', 2: 'bar', 3?: 'baz'}", $matches); } - assertType("array{}|array{0: non-falsy-string, 1: 'foo', 2: 'bar', 3?: 'baz'}", $matches); + assertType("array{}|array{0: 'foobar'|'foobarbaz', 1: 'foo', 2: 'bar', 3?: 'baz'}", $matches); if (preg_match('/(foo)(bar)(baz){0,3}/', $s, $matches)) { - assertType("array{0: non-falsy-string, 1: 'foo', 2: 'bar', 3?: non-falsy-string}", $matches); + assertType("array{0: 'foobar'|'foobarbaz'|'foobarbazbaz'|'foobarbazbazbaz', 1: 'foo', 2: 'bar', 3?: 'baz'}", $matches); } - assertType("array{}|array{0: non-falsy-string, 1: 'foo', 2: 'bar', 3?: non-falsy-string}", $matches); + assertType("array{}|array{0: 'foobar'|'foobarbaz'|'foobarbazbaz'|'foobarbazbazbaz', 1: 'foo', 2: 'bar', 3?: 'baz'}", $matches); if (preg_match('/(foo)(bar)(baz){2,3}/', $s, $matches)) { - assertType("array{non-falsy-string, 'foo', 'bar', non-falsy-string}", $matches); + assertType("array{'foobarbazbaz'|'foobarbazbazbaz', 'foo', 'bar', 'baz'}", $matches); } - assertType("array{}|array{non-falsy-string, 'foo', 'bar', non-falsy-string}", $matches); + assertType("array{}|array{'foobarbazbaz'|'foobarbazbazbaz', 'foo', 'bar', 'baz'}", $matches); if (preg_match('/(foo)(bar)(baz){2}/', $s, $matches)) { - assertType("array{non-falsy-string, 'foo', 'bar', non-falsy-string}", $matches); + assertType("array{'foobarbazbaz', 'foo', 'bar', 'baz'}", $matches); } - assertType("array{}|array{non-falsy-string, 'foo', 'bar', non-falsy-string}", $matches); + assertType("array{}|array{'foobarbazbaz', 'foo', 'bar', 'baz'}", $matches); } function doNonCapturingGroup(string $s): void { @@ -115,9 +115,9 @@ function doNamedSubpattern(string $s): void { function doOffsetCapture(string $s): void { if (preg_match('/(foo)(bar)(baz)/', $s, $matches, PREG_OFFSET_CAPTURE)) { - assertType("array{array{non-falsy-string, int<-1, max>}, array{'foo', int<-1, max>}, array{'bar', int<-1, max>}, array{'baz', int<-1, max>}}", $matches); + assertType("array{array{'foobarbaz', int<-1, max>}, array{'foo', int<-1, max>}, array{'bar', int<-1, max>}, array{'baz', int<-1, max>}}", $matches); } - assertType("array{}|array{array{non-falsy-string, int<-1, max>}, array{'foo', int<-1, max>}, array{'bar', int<-1, max>}, array{'baz', int<-1, max>}}", $matches); + assertType("array{}|array{array{'foobarbaz', int<-1, max>}, array{'foo', int<-1, max>}, array{'bar', int<-1, max>}, array{'baz', int<-1, max>}}", $matches); } function doUnknownFlags(string $s, int $flags): void { @@ -129,16 +129,16 @@ function doUnknownFlags(string $s, int $flags): void { function doMultipleAlternativeCaptureGroupsWithSameNameWithModifier(string $s): void { if (preg_match('/(?J)(?[a-z]+)|(?[0-9]+)/', $s, $matches)) { - assertType("array{0: non-empty-string, Foo: non-empty-string, 1: non-empty-string}|array{0: non-empty-string, Foo: numeric-string, 1: '', 2: numeric-string}", $matches); + assertType("array{0: non-empty-string, Foo: non-falsy-string, 1: non-falsy-string}|array{0: non-empty-string, Foo: numeric-string, 1: '', 2: numeric-string}", $matches); } - assertType("array{}|array{0: non-empty-string, Foo: non-empty-string, 1: non-empty-string}|array{0: non-empty-string, Foo: numeric-string, 1: '', 2: numeric-string}", $matches); + assertType("array{}|array{0: non-empty-string, Foo: non-falsy-string, 1: non-falsy-string}|array{0: non-empty-string, Foo: numeric-string, 1: '', 2: numeric-string}", $matches); } function doMultipleConsecutiveCaptureGroupsWithSameNameWithModifier(string $s): void { if (preg_match('/(?J)(?[a-z]+)|(?[0-9]+)/', $s, $matches)) { - assertType("array{0: non-empty-string, Foo: non-empty-string, 1: non-empty-string}|array{0: non-empty-string, Foo: numeric-string, 1: '', 2: numeric-string}", $matches); + assertType("array{0: non-empty-string, Foo: non-falsy-string, 1: non-falsy-string}|array{0: non-empty-string, Foo: numeric-string, 1: '', 2: numeric-string}", $matches); } - assertType("array{}|array{0: non-empty-string, Foo: non-empty-string, 1: non-empty-string}|array{0: non-empty-string, Foo: numeric-string, 1: '', 2: numeric-string}", $matches); + assertType("array{}|array{0: non-empty-string, Foo: non-falsy-string, 1: non-falsy-string}|array{0: non-empty-string, Foo: numeric-string, 1: '', 2: numeric-string}", $matches); } // https://github.com/hoaproject/Regex/issues/31 @@ -157,9 +157,9 @@ function hoaBug31(string $s): void { // https://github.com/phpstan/phpstan/issues/10855#issuecomment-2044323638 function testHoaUnsupportedRegexSyntax(string $s): void { if (preg_match('#\QPHPDoc type array of property App\Log::$fillable is not covariant with PHPDoc type array of overridden property Illuminate\Database\E\\\\\QEloquent\Model::$fillable.\E#', $s, $matches)) { - assertType('array{non-falsy-string}', $matches); + assertType('array{\'PHPDoc type array of property App\\\\Log::$fillable is not covariant with PHPDoc type array of overridden property Illuminate\\\\Database\\\\Eloquent\\\\Model::$fillable.\'}', $matches); } - assertType('array{}|array{non-falsy-string}', $matches); + assertType('array{}|array{\'PHPDoc type array of property App\\\\Log::$fillable is not covariant with PHPDoc type array of overridden property Illuminate\\\\Database\\\\Eloquent\\\\Model::$fillable.\'}', $matches); } function testPregMatchSimpleCondition(string $value): void { @@ -233,13 +233,13 @@ function testUnionPattern(string $s): void function doFoo(string $row): void { if (preg_match('~^(a(b))$~', $row, $matches) === 1) { - assertType("array{non-falsy-string, 'ab', 'b'}", $matches); + assertType("array{'ab', 'ab', 'b'}", $matches); } if (preg_match('~^(a(b)?)$~', $row, $matches) === 1) { - assertType("array{0: non-falsy-string, 1: 'a'|'ab', 2?: 'b'}", $matches); + assertType("array{0: 'a'|'ab', 1: 'a'|'ab', 2?: 'b'}", $matches); } if (preg_match('~^(a(b)?)?$~', $row, $matches) === 1) { - assertType("list{0: string, 1?: 'a'|'ab', 2?: 'b'}", $matches); + assertType("list{0: ''|'a'|'ab', 1?: 'a'|'ab', 2?: 'b'}", $matches); } } @@ -265,7 +265,7 @@ function (string $size): void { if (preg_match('~^a\.b(c(\d+)(\d+)(\s+))?d~', $size, $matches) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{non-falsy-string, non-falsy-string, numeric-string, numeric-string, non-empty-string}|array{non-falsy-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string, numeric-string, numeric-string, non-falsy-string}|array{non-falsy-string}', $matches); }; function (string $size): void { @@ -300,7 +300,7 @@ function (string $size): void { if (preg_match('~^a\.(b)?(c)?d~', $size, $matches) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType("list{0: non-falsy-string, 1?: ''|'b', 2?: 'c'}", $matches); + assertType("list{0: 'a.bcd'|'a.bd'|'a.cd'|'a.d', 1?: ''|'b', 2?: 'c'}", $matches); }; function (string $size): void { @@ -338,7 +338,7 @@ function bug11277a(string $value): void function bug11277b(string $value): void { if (preg_match('/^(?:(.+,?)|(x))*$/', $value, $matches)) { - assertType("array{0: string, 1?: non-empty-string}|array{string, '', non-empty-string}", $matches); + assertType("array{0: string, 1?: non-empty-string}|array{string, '', 'x'}", $matches); } } @@ -346,11 +346,11 @@ function bug11277b(string $value): void // https://3v4l.org/09qdT function bug11291(string $s): void { if (preg_match('/(?|(a)|(b)(c)|(d)(e)(f))/', $s, $matches)) { - assertType('list{0: non-empty-string, 1: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches); + assertType('array{\'a\'|\'bc\'|\'def\', \'c\'|\'e\', \'f\'}|array{0: \'a\'|\'bc\'|\'def\', 1?: \'a\'|\'b\'|\'d\'}', $matches); } else { assertType('array{}', $matches); } - assertType('array{}|list{0: non-empty-string, 1: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches); + assertType('array{}|array{\'a\'|\'bc\'|\'def\', \'c\'|\'e\', \'f\'}|array{0: \'a\'|\'bc\'|\'def\', 1?: \'a\'|\'b\'|\'d\'}', $matches); } function bug11323a(string $s): void @@ -447,22 +447,22 @@ function (string $s): void { function (string $s): void { if (preg_match('~^([157])$~', $s, $matches) === 1) { - assertType("array{non-falsy-string, '1'|'5'|'7'}", $matches); + assertType("array{'1'|'5'|'7', '1'|'5'|'7'}", $matches); } }; function (string $s): void { if (preg_match('~^([157XY])$~', $s, $matches) === 1) { - assertType("array{non-falsy-string, '1'|'5'|'7'|'X'|'Y'}", $matches); + assertType("array{'1'|'5'|'7'|'X'|'Y', '1'|'5'|'7'|'X'|'Y'}", $matches); } }; function bug11323(string $s): void { if (preg_match('/([*|+?{}()]+)([^*|+[:digit:]?{}()]+)/', $s, $matches)) { - assertType('array{non-falsy-string, non-empty-string, non-empty-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string, non-empty-string}', $matches); } if (preg_match('/\p{L}[[\]]+([-*|+?{}(?:)]+)([^*|+[:digit:]?{a-z}(\p{L})\a-]+)/', $s, $matches)) { - assertType('array{non-falsy-string, non-empty-string, non-empty-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string, non-empty-string}', $matches); } if (preg_match('{([-\p{L}[\]*|\x03\a\b+?{}(?:)-]+[^[:digit:]?{}a-z0-9#-k]+)(a-z)}', $s, $matches)) { assertType("array{non-falsy-string, non-falsy-string, 'a-z'}", $matches); @@ -477,19 +477,19 @@ function bug11323(string $s): void { assertType('array{non-falsy-string, non-falsy-string}', $matches); } if (preg_match('{([[:digit:]])}', $s, $matches)) { - assertType('array{non-empty-string, numeric-string}', $matches); + assertType('array{numeric-string, numeric-string}', $matches); } if (preg_match('{([\d])(\d)}', $s, $matches)) { - assertType('array{non-falsy-string, numeric-string, numeric-string}', $matches); + assertType('array{non-falsy-string&numeric-string, numeric-string, numeric-string}', $matches); } if (preg_match('{([0-9])}', $s, $matches)) { - assertType('array{non-empty-string, numeric-string}', $matches); + assertType('array{\'0\'|\'1\'|\'2\'|\'3\'|\'4\'|\'5\'|\'6\'|\'7\'|\'8\'|\'9\', \'0\'|\'1\'|\'2\'|\'3\'|\'4\'|\'5\'|\'6\'|\'7\'|\'8\'|\'9\'}', $matches); } if (preg_match('{(\p{L})(\p{P})(\p{Po})}', $s, $matches)) { assertType('array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $matches); } if (preg_match('{(a)??(b)*+(c++)(d)+?}', $s, $matches)) { - assertType("array{non-falsy-string, ''|'a', string, non-empty-string, non-empty-string}", $matches); + assertType("array{non-falsy-string, ''|'a', ''|'b', non-falsy-string, 'd'}", $matches); } if (preg_match('{(.\d)}', $s, $matches)) { assertType('array{non-falsy-string, non-falsy-string}', $matches); @@ -498,7 +498,7 @@ function bug11323(string $s): void { assertType('array{non-falsy-string, non-falsy-string}', $matches); } if (preg_match('{(\d\d)}', $s, $matches)) { - assertType('array{non-falsy-string, non-falsy-string&numeric-string}', $matches); + assertType('array{non-falsy-string&numeric-string, non-falsy-string&numeric-string}', $matches); } if (preg_match('{(.(\d))}', $s, $matches)) { assertType('array{non-falsy-string, non-falsy-string, numeric-string}', $matches); @@ -507,19 +507,19 @@ function bug11323(string $s): void { assertType('array{non-falsy-string, non-falsy-string, numeric-string}', $matches); } if (preg_match('{(\d([1-4])\d)}', $s, $matches)) { - assertType('array{non-falsy-string, non-falsy-string&numeric-string, numeric-string}', $matches); + assertType('array{non-falsy-string&numeric-string, non-falsy-string&numeric-string, \'1\'|\'2\'|\'3\'|\'4\'}', $matches); } if (preg_match('{(x?([1-4])\d)}', $s, $matches)) { - assertType('array{non-falsy-string, non-falsy-string, numeric-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string, \'1\'|\'2\'|\'3\'|\'4\'}', $matches); } if (preg_match('{([^1-4])}', $s, $matches)) { assertType('array{non-empty-string, non-empty-string}', $matches); } if (preg_match("{([\r\n]+)(\n)([\n])}", $s, $matches)) { - assertType('array{non-falsy-string, non-empty-string, "\n", "\n"}', $matches); + assertType('array{non-falsy-string, non-falsy-string, "\\n", "\\n"}', $matches); } if (preg_match('/foo(*:first)|bar(*:second)([x])/', $s, $matches)) { - assertType("array{0: non-falsy-string, 1?: 'x', MARK?: 'first'|'second'}", $matches); + assertType("array{0: 'barx'|'foo', 1?: 'x', MARK?: 'first'|'second'}", $matches); } } @@ -552,7 +552,7 @@ function (string $s): void { } if (preg_match($p, $s, $matches)) { - assertType("array{non-falsy-string, '£', 'abc'}|array{non-falsy-string, numeric-string, 'b'}", $matches); + assertType("array{'Price: £abc', '£', 'abc'}|array{non-falsy-string, numeric-string, 'b'}", $matches); } }; @@ -564,13 +564,13 @@ function (string $s): void { } if (preg_match($p, $s, $matches)) { - assertType("list{0: non-falsy-string, 1: 'x'|'£'|numeric-string, 2?: ''|numeric-string, 3?: 'x'}", $matches); + assertType("list{0: non-falsy-string, 1: non-empty-string, 2?: ''|numeric-string, 3?: 'x'}", $matches); } }; function (string $s): void { if (preg_match('/Price: ([a-z])/i', $s, $matches)) { - assertType("array{non-falsy-string, non-empty-string}", $matches); + assertType("array{non-falsy-string, non-falsy-string}", $matches); } }; @@ -582,25 +582,25 @@ function (string $s): void { function (string $s): void { if (preg_match('/Price: ([xXa])/i', $s, $matches)) { - assertType("array{non-falsy-string, non-empty-string}", $matches); + assertType("array{non-falsy-string, non-falsy-string}", $matches); } }; function (string $s): void { if (preg_match('/Price: ([xXa])/', $s, $matches)) { - assertType("array{non-falsy-string, 'a'|'X'|'x'}", $matches); + assertType("array{'Price: a'|'Price: X'|'Price: x', 'a'|'X'|'x'}", $matches); } }; function (string $s): void { if (preg_match('/Price: (ba[rz])/', $s, $matches)) { - assertType("array{non-falsy-string, 'bar'|'baz'}", $matches); + assertType("array{'Price: bar'|'Price: baz', 'bar'|'baz'}", $matches); } }; function (string $s): void { if (preg_match('/Price: (b[ao][mn])/', $s, $matches)) { - assertType("array{non-falsy-string, 'bam'|'ban'|'bom'|'bon'}", $matches); + assertType("array{'Price: bam'|'Price: ban'|'Price: bom'|'Price: bon', 'bam'|'ban'|'bom'|'bon'}", $matches); } }; @@ -612,49 +612,49 @@ function (string $s): void { function (string $s): void { if (preg_match('/Price: (a|bc?)/', $s, $matches)) { - assertType("array{non-falsy-string, 'a'|'b'|'bc'}", $matches); + assertType("array{'Price: a'|'Price: b'|'Price: bc', 'a'|'b'|'bc'}", $matches); } }; function (string $s): void { if (preg_match('/Price: (?a|bc?)/', $s, $matches)) { - assertType("array{0: non-falsy-string, named: 'a'|'b'|'bc', 1: 'a'|'b'|'bc'}", $matches); + assertType("array{0: 'Price: a'|'Price: b'|'Price: bc', named: 'a'|'b'|'bc', 1: 'a'|'b'|'bc'}", $matches); } }; function (string $s): void { if (preg_match('/Price: (a|0c?)/', $s, $matches)) { - assertType("array{non-falsy-string, '0'|'0c'|'a'}", $matches); + assertType("array{'Price: 0'|'Price: 0c'|'Price: a', '0'|'0c'|'a'}", $matches); } }; function (string $s): void { if (preg_match('/Price: (a|\d)/', $s, $matches)) { - assertType("array{non-falsy-string, 'a'|numeric-string}", $matches); + assertType("array{non-falsy-string, non-empty-string}", $matches); } }; function (string $s): void { if (preg_match('/Price: (?a|\d)/', $s, $matches)) { - assertType("array{0: non-falsy-string, named: 'a'|numeric-string, 1: 'a'|numeric-string}", $matches); + assertType("array{0: non-falsy-string, named: non-empty-string, 1: non-empty-string}", $matches); } }; function (string $s): void { if (preg_match('/Price: (a|0)/', $s, $matches)) { - assertType("array{non-falsy-string, '0'|'a'}", $matches); + assertType("array{'Price: 0'|'Price: a', '0'|'a'}", $matches); } }; function (string $s): void { if (preg_match('/Price: (aa|0)/', $s, $matches)) { - assertType("array{non-falsy-string, '0'|'aa'}", $matches); + assertType("array{'Price: 0'|'Price: aa', '0'|'aa'}", $matches); } }; function (string $s): void { if (preg_match('/( \d+ )/x', $s, $matches)) { - assertType('array{non-empty-string, numeric-string}', $matches); + assertType('array{numeric-string, numeric-string}', $matches); } }; @@ -679,13 +679,13 @@ function (string $s): void { function (string $value): void { if (preg_match('/^(x)*$/', $value, $matches, PREG_OFFSET_CAPTURE)) { - assertType("array{0: array{string, int<-1, max>}, 1?: array{non-empty-string, int<-1, max>}}", $matches); + assertType("array{0: array{string, int<-1, max>}, 1?: array{'x', int<-1, max>}}", $matches); } }; function (string $value): void { if (preg_match('/^(?:(x)|(y))*$/', $value, $matches, PREG_OFFSET_CAPTURE)) { - assertType("array{0: array{string, int<-1, max>}, 1?: array{non-empty-string, int<-1, max>}}|array{array{string, int<-1, max>}, array{'', int<-1, max>}, array{non-empty-string, int<-1, max>}}", $matches); + assertType("array{0: array{string, int<-1, max>}, 1?: array{'x', int<-1, max>}}|array{array{string, int<-1, max>}, array{'', int<-1, max>}, array{'y', int<-1, max>}}", $matches); } }; @@ -712,25 +712,25 @@ static public function sayHello(string $source): void function (string $s): void { if (preg_match('~a|(\d)|(\s)~', $s, $matches) === 1) { - assertType("array{0: non-empty-string, 1?: numeric-string}|array{non-empty-string, '', non-empty-string}", $matches); + assertType("array{0: non-empty-string, 1?: numeric-string}|array{non-empty-string, '', non-falsy-string}", $matches); } }; function (string $s): void { if (preg_match('~a|((u)x)|((v)y)~', $s, $matches) === 1) { - assertType("array{non-empty-string, '', '', 'vy', 'v'}|array{non-empty-string, 'ux', 'u'}|array{non-empty-string}", $matches); + assertType("array{'a'|'ux'|'vy', '', '', 'vy', 'v'}|array{'a'|'ux'|'vy', 'ux', 'u'}|array{'a'|'ux'|'vy'}", $matches); } }; function (string $s): void { if (preg_match('~a|(\d)|(\s)~', $s, $matches, PREG_OFFSET_CAPTURE) === 1) { - assertType("array{0: array{non-empty-string, int<-1, max>}, 1?: array{numeric-string, int<-1, max>}}|array{array{non-empty-string, int<-1, max>}, array{'', int<-1, max>}, array{non-empty-string, int<-1, max>}}", $matches); + assertType("array{0: array{non-empty-string, int<-1, max>}, 1?: array{numeric-string, int<-1, max>}}|array{array{non-empty-string, int<-1, max>}, array{'', int<-1, max>}, array{non-falsy-string, int<-1, max>}}", $matches); } }; function (string $s): void { preg_match('~a|(\d)|(\s)~', $s, $matches); - assertType("array{}|array{0: non-empty-string, 1?: numeric-string}|array{non-empty-string, '', non-empty-string}", $matches); + assertType("array{}|array{0: non-empty-string, 1?: numeric-string}|array{non-empty-string, '', non-falsy-string}", $matches); }; function bug11490 (string $expression): void { @@ -753,7 +753,7 @@ function bug11622 (string $expression): void { $matches = []; if (preg_match('/^abc(def|$)/', $expression, $matches) === 1) { - assertType("array{non-falsy-string, string}", $matches); + assertType("array{'abc'|'abcdef', ''|'def'}", $matches); } } @@ -762,23 +762,23 @@ function bug11604 (string $string): void { return; } - assertType("list{0: non-falsy-string, 1?: ''|'XX', 2?: 'YY'}", $matches); + assertType("list{0: 'XX'|'YYZZ'|'ZZ', 1?: ''|'XX', 2?: 'YY'}", $matches); // could be array{string, '', 'YY'}|array{string, 'XX'}|array{string} } function bug11604b (string $string): void { if (preg_match('/(XX)|(YY)?(ZZ)/', $string, $matches)) { - assertType("list{0: non-falsy-string, 1?: ''|'XX', 2?: ''|'YY', 3?: 'ZZ'}", $matches); + assertType("list{0: 'XX'|'YYZZ'|'ZZ', 1?: ''|'XX', 2?: ''|'YY', 3?: 'ZZ'}", $matches); } } function testLtrimDelimiter (string $string): void { if (preg_match(' /(x)/', $string, $matches)) { - assertType("array{non-empty-string, 'x'}", $matches); + assertType("array{'x', 'x'}", $matches); } if (preg_match(' /(x)/', $string, $matches)) { - assertType("array{non-empty-string, 'x'}", $matches); + assertType("array{'x', 'x'}", $matches); } } @@ -786,19 +786,19 @@ function testUnescapeBackslash (string $string): void { if (preg_match(<<<'EOD' ~(\[)~ EOD, $string, $matches)) { - assertType("array{non-empty-string, '['}", $matches); + assertType("array{'[', '['}", $matches); } if (preg_match(<<<'EOD' ~(\d)~ EOD, $string, $matches)) { - assertType("array{non-empty-string, numeric-string}", $matches); + assertType("array{numeric-string, numeric-string}", $matches); } if (preg_match(<<<'EOD' ~(\\d)~ EOD, $string, $matches)) { - assertType("array{non-falsy-string, '\\\d'}", $matches); + assertType("array{'\\\\d', '\\\\d'}", $matches); } if (preg_match(<<<'EOD' @@ -810,7 +810,7 @@ function testUnescapeBackslash (string $string): void { if (preg_match(<<<'EOD' ~(\\\\d)~ EOD, $string, $matches)) { - assertType("array{non-falsy-string, '\\\\\\\d'}", $matches); + assertType("array{'\\\\\\\\d', '\\\\\\\\d'}", $matches); } } @@ -818,128 +818,128 @@ function testEscapedDelimiter (string $string): void { if (preg_match(<<<'EOD' /(\/)/ EOD, $string, $matches)) { - assertType("array{non-empty-string, '/'}", $matches); + assertType("array{'/', '/'}", $matches); } if (preg_match(<<<'EOD' ~(\~)~ EOD, $string, $matches)) { - assertType("array{non-empty-string, '~'}", $matches); + assertType("array{'~', '~'}", $matches); } if (preg_match(<<<'EOD' ~(\[2])~ EOD, $string, $matches)) { - assertType("array{non-falsy-string, '[2]'}", $matches); + assertType("array{'[2]', '[2]'}", $matches); } if (preg_match(<<<'EOD' [(\[2\])] EOD, $string, $matches)) { - assertType("array{non-falsy-string, '[2]'}", $matches); + assertType("array{'[2]', '[2]'}", $matches); } if (preg_match(<<<'EOD' ~(\{2})~ EOD, $string, $matches)) { - assertType("array{non-falsy-string, '{2}'}", $matches); + assertType("array{'{2}', '{2}'}", $matches); } if (preg_match(<<<'EOD' {(\{2\})} EOD, $string, $matches)) { - assertType("array{non-falsy-string, '{2}'}", $matches); + assertType("array{'{2}', '{2}'}", $matches); } if (preg_match(<<<'EOD' ~([a\]])~ EOD, $string, $matches)) { - assertType("array{non-empty-string, ']'|'a'}", $matches); + assertType("array{']'|'a', ']'|'a'}", $matches); } if (preg_match(<<<'EOD' ~([a[])~ EOD, $string, $matches)) { - assertType("array{non-empty-string, '['|'a'}", $matches); + assertType("array{'['|'a', '['|'a'}", $matches); } if (preg_match(<<<'EOD' ~([a\]b])~ EOD, $string, $matches)) { - assertType("array{non-empty-string, ']'|'a'|'b'}", $matches); + assertType("array{']'|'a'|'b', ']'|'a'|'b'}", $matches); } if (preg_match(<<<'EOD' ~([a[b])~ EOD, $string, $matches)) { - assertType("array{non-empty-string, '['|'a'|'b'}", $matches); + assertType("array{'['|'a'|'b', '['|'a'|'b'}", $matches); } if (preg_match(<<<'EOD' ~([a\[b])~ EOD, $string, $matches)) { - assertType("array{non-empty-string, '['|'a'|'b'}", $matches); + assertType("array{'['|'a'|'b', '['|'a'|'b'}", $matches); } if (preg_match(<<<'EOD' [([a\[b])] EOD, $string, $matches)) { - assertType("array{non-empty-string, '['|'a'|'b'}", $matches); + assertType("array{'['|'a'|'b', '['|'a'|'b'}", $matches); } if (preg_match(<<<'EOD' {(x\\\{)|(y\\\\\})} EOD, $string, $matches)) { - assertType("array{non-falsy-string, '', 'y\\\\\\\}'}|array{non-falsy-string, 'x\\\{'}", $matches); + assertType("array{'x\\\\{'|'y\\\\\\\\}', '', 'y\\\\\\\\}'}|array{'x\\\\{'|'y\\\\\\\\}', 'x\\\\{'}", $matches); } } function bugUnescapedDashAfterRange (string $string): void { if (preg_match('/([0-1-y])/', $string, $matches)) { - assertType("array{non-empty-string, non-empty-string}", $matches); + assertType("array{'-'|'0'|'1'|'y', '-'|'0'|'1'|'y'}", $matches); } } function bugEmptySubexpression (string $string): void { if (preg_match('//', $string, $matches)) { - assertType("array{string}", $matches); // could be array{''} + assertType("array{''}", $matches); // could be array{''} } if (preg_match('/()/', $string, $matches)) { - assertType("array{string, ''}", $matches); // could be array{'', ''} + assertType("array{'', ''}", $matches); // could be array{'', ''} } if (preg_match('/|/', $string, $matches)) { - assertType("array{string}", $matches); // could be array{''} + assertType("array{''}", $matches); // could be array{''} } if (preg_match('~|(a)~', $string, $matches)) { - assertType("array{0: string, 1?: 'a'}", $matches); + assertType("array{0: ''|'a', 1?: 'a'}", $matches); } if (preg_match('~(a)|~', $string, $matches)) { - assertType("array{0: string, 1?: 'a'}", $matches); + assertType("array{0: ''|'a', 1?: 'a'}", $matches); } if (preg_match('~(a)||(b)~', $string, $matches)) { - assertType("array{0: string, 1?: 'a'}|array{string, '', 'b'}", $matches); + assertType("array{''|'a'|'b', '', 'b'}|array{0: ''|'a'|'b', 1?: 'a'}", $matches); } if (preg_match('~(|(a))~', $string, $matches)) { - assertType("array{0: string, 1: ''|'a', 2?: 'a'}", $matches); + assertType("array{0: ''|'a', 1: ''|'a', 2?: 'a'}", $matches); } if (preg_match('~((a)|)~', $string, $matches)) { - assertType("array{0: string, 1: ''|'a', 2?: 'a'}", $matches); + assertType("array{0: ''|'a', 1: ''|'a', 2?: 'a'}", $matches); } if (preg_match('~((a)||(b))~', $string, $matches)) { - assertType("list{0: string, 1: ''|'a'|'b', 2?: ''|'a', 3?: 'b'}", $matches); + assertType("list{0: ''|'a'|'b', 1: ''|'a'|'b', 2?: ''|'a', 3?: 'b'}", $matches); } if (preg_match('~((a)|()|(b))~', $string, $matches)) { - assertType("list{0: string, 1: ''|'a'|'b', 2?: ''|'a', 3?: '', 4?: 'b'}", $matches); + assertType("list{0: ''|'a'|'b', 1: ''|'a'|'b', 2?: ''|'a', 3?: '', 4?: 'b'}", $matches); } } @@ -964,7 +964,7 @@ function bug11744(string $string): void function bug12749(string $str): void { if (preg_match('/[A-Z]/', $str, $match)) { - assertType('array{non-empty-string}', $match); // could be non-falsy-string + assertType('array{\'A\'|\'B\'|\'C\'|\'D\'|\'E\'|\'F\'|\'G\'|\'H\'|\'I\'|\'J\'|\'K\'|\'L\'|\'M\'|\'N\'|\'O\'|\'P\'|\'Q\'|\'R\'|\'S\'|\'T\'|\'U\'|\'V\'|\'W\'|\'X\'|\'Y\'|\'Z\'}', $match); // could be non-falsy-string } } @@ -1000,14 +1000,14 @@ function bug12749e(string $str): void { // no ^ $ delims, therefore can be anything which contains a number if (preg_match('/[0-9]/', $str, $match)) { - assertType('array{non-empty-string}', $match); + assertType('array{\'0\'|\'1\'|\'2\'|\'3\'|\'4\'|\'5\'|\'6\'|\'7\'|\'8\'|\'9\'}', $match); } } function bug12749f(string $str): void { if (preg_match('/^[0-9]$/', $str, $match)) { - assertType('array{non-empty-string}', $match); // could be numeric-string + assertType('array{\'0\'|\'1\'|\'2\'|\'3\'|\'4\'|\'5\'|\'6\'|\'7\'|\'8\'|\'9\'}', $match); // could be numeric-string } } @@ -1066,18 +1066,18 @@ function bug12792(string $string): void { } if (preg_match('~a(y|(?:x\Kb|c))d~', $string, $match) === 1) { - assertType('array{string, non-empty-string}', $match); // could be array{'acd'|'ayd'|'bd', 'c'|'xb'|'y'} + assertType('array{string, \'c\'|\'xb\'|\'y\'}', $match); // could be array{'acd'|'ayd'|'bd', 'c'|'xb'|'y'} } if (preg_match('~a((?:c|x\Kb)|y)d~', $string, $match) === 1) { - assertType('array{string, non-empty-string}', $match); // could be array{'acd'|'ayd'|'bd', 'c'|'xb'|'y'} + assertType('array{string, \'c\'|\'xb\'|\'y\'}', $match); // could be array{'acd'|'ayd'|'bd', 'c'|'xb'|'y'} } } function testExtendedSyntaxEscapedHash(string $string): void { // \# in extended mode should be treated as literal hash, not comment if (preg_match('/^ ([\#.]) $/x', $string, $matches)) { - assertType("array{non-falsy-string, '#'|'.'}", $matches); + assertType("array{'#'|'.', '#'|'.'}", $matches); } // Real comment vs escaped hash @@ -1085,6 +1085,6 @@ function testExtendedSyntaxEscapedHash(string $string): void { (\d+) # this is a comment ([\#ab]+) # hash in character class (escaped) /x', $string, $matches)) { - assertType('array{non-falsy-string, numeric-string, non-empty-string}', $matches); + assertType('array{non-falsy-string, numeric-string, non-falsy-string}', $matches); } } diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php index fb8512e9df9..0c0d1bc9d5b 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php @@ -7,15 +7,15 @@ function doOffsetCaptureWithUnmatchedNull(string $s): void { // see https://3v4l.org/07rBO#v8.2.9 if (preg_match('/(foo)(bar)(baz)/', $s, $matches, PREG_OFFSET_CAPTURE|PREG_UNMATCHED_AS_NULL)) { - assertType("array{array{non-falsy-string|null, int<-1, max>}, array{'foo'|null, int<-1, max>}, array{'bar'|null, int<-1, max>}, array{'baz'|null, int<-1, max>}}", $matches); + assertType("array{array{'foobarbaz'|null, int<-1, max>}, array{'foo'|null, int<-1, max>}, array{'bar'|null, int<-1, max>}, array{'baz'|null, int<-1, max>}}", $matches); } - assertType("array{}|array{array{non-falsy-string|null, int<-1, max>}, array{'foo'|null, int<-1, max>}, array{'bar'|null, int<-1, max>}, array{'baz'|null, int<-1, max>}}", $matches); + assertType("array{}|array{array{'foobarbaz'|null, int<-1, max>}, array{'foo'|null, int<-1, max>}, array{'bar'|null, int<-1, max>}, array{'baz'|null, int<-1, max>}}", $matches); } function doNonAutoCapturingModifier(string $s): void { if (preg_match('/(?n)(\d+)/', $s, $matches)) { // (?n) turns capturing off, so only the full match remains - assertType('array{non-empty-string}', $matches); + assertType('array{numeric-string}', $matches); } - assertType('array{}|array{non-empty-string}', $matches); + assertType('array{}|array{numeric-string}', $matches); } diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php index 1b5dc597b7f..6cb56342565 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php @@ -8,14 +8,14 @@ // https://php.watch/versions/8.2/preg-n-no-capture-modifier function doNonAutoCapturingFlag(string $s): void { if (preg_match('/(\d+)/n', $s, $matches)) { - assertType('array{non-empty-string}', $matches); + assertType('array{numeric-string}', $matches); } - assertType('array{}|array{non-empty-string}', $matches); + assertType('array{}|array{numeric-string}', $matches); if (preg_match('/(\d+)(?P\d+)/n', $s, $matches)) { - assertType('array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{0: non-falsy-string&numeric-string, num: numeric-string, 1: numeric-string}', $matches); } - assertType('array{}|array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{}|array{0: non-falsy-string&numeric-string, num: numeric-string, 1: numeric-string}', $matches); if (preg_match('/(\w)-(?P\d+)-(\w)/n', $s, $matches)) { assertType('array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); @@ -26,21 +26,21 @@ function doNonAutoCapturingFlag(string $s): void { // delimiter variants, see https://www.php.net/manual/en/regexp.reference.delimiters.php function (string $s): void { if (preg_match('{(\d+)(?P\d+)}n', $s, $matches)) { - assertType('array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{0: non-falsy-string&numeric-string, num: numeric-string, 1: numeric-string}', $matches); } }; function (string $s): void { if (preg_match('<(\d+)(?P\d+)>n', $s, $matches)) { - assertType('array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{0: non-falsy-string&numeric-string, num: numeric-string, 1: numeric-string}', $matches); } }; function (string $s): void { if (preg_match('((\d+)(?P\d+))n', $s, $matches)) { - assertType('array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{0: non-falsy-string&numeric-string, num: numeric-string, 1: numeric-string}', $matches); } }; function (string $s): void { if (preg_match('[(\d+)(?P\d+)]n', $s, $matches)) { - assertType('array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{0: non-falsy-string&numeric-string, num: numeric-string, 1: numeric-string}', $matches); } }; diff --git a/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php index 9f8c608d471..79cb992cbc0 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php @@ -9,7 +9,7 @@ function (string $s): void { preg_replace_callback( '/(foo)?(bar)?(baz)?/', function ($matches) { - assertType("array{string, 'foo'|null, 'bar'|null, 'baz'|null}", $matches); + assertType("array{''|'bar'|'barbaz'|'baz'|'foo'|'foobar'|'foobarbaz'|'foobaz', 'foo'|null, 'bar'|null, 'baz'|null}", $matches); return ''; }, $s, @@ -23,7 +23,7 @@ function (string $s): void { preg_replace_callback( '/(foo)?(bar)?(baz)?/', function ($matches) { - assertType("list{0: array{string, int<-1, max>}, 1?: array{''|'foo', int<-1, max>}, 2?: array{''|'bar', int<-1, max>}, 3?: array{'baz', int<-1, max>}}", $matches); + assertType("list{0: array{''|'bar'|'barbaz'|'baz'|'foo'|'foobar'|'foobarbaz'|'foobaz', int<-1, max>}, 1?: array{''|'foo', int<-1, max>}, 2?: array{''|'bar', int<-1, max>}, 3?: array{'baz', int<-1, max>}}", $matches); return ''; }, $s, @@ -37,7 +37,7 @@ function (string $s): void { preg_replace_callback( '/(foo)?(bar)?(baz)?/', function ($matches) { - assertType("array{array{string|null, int<-1, max>}, array{'foo'|null, int<-1, max>}, array{'bar'|null, int<-1, max>}, array{'baz'|null, int<-1, max>}}", $matches); + assertType("array{array{''|'bar'|'barbaz'|'baz'|'foo'|'foobar'|'foobarbaz'|'foobaz'|null, int<-1, max>}, array{'foo'|null, int<-1, max>}, array{'bar'|null, int<-1, max>}, array{'baz'|null, int<-1, max>}}", $matches); return ''; }, $s, @@ -62,8 +62,8 @@ function bug14904(string $string): void { preg_replace_callback( '/a|(?b)/', function (array $match): string { - assertType("array{0: non-empty-string, b: 'b'|null, 1: 'b'|null}", $match); - assertNativeType("array{0: non-empty-string, b: 'b'|null, 1: 'b'|null}", $match); + assertType("array{0: 'a'|'b', b: 'b'|null, 1: 'b'|null}", $match); + assertNativeType("array{0: 'a'|'b', b: 'b'|null, 1: 'b'|null}", $match); return ''; }, $string, diff --git a/tests/PHPStan/Type/Regex/RegexShapeReadingTest.php b/tests/PHPStan/Type/Regex/RegexShapeReadingTest.php index 4e0e569bd0a..56a9db464d6 100644 --- a/tests/PHPStan/Type/Regex/RegexShapeReadingTest.php +++ b/tests/PHPStan/Type/Regex/RegexShapeReadingTest.php @@ -28,8 +28,8 @@ public function testMultiByteLiteralsSplitAsUtf8WhateverTheDefaultEncoding(): vo { $defaultEncoding = mb_internal_encoding(); // under a single-byte internal encoding a two-byte literal splits in - // two, doubling the meaningful tokens and typing the subject - // non-falsy when the engine sees one character + // two, doubling the meaningful tokens — the shape reads the pattern + // as UTF-8 whatever the default encoding and keeps one character mb_internal_encoding('ISO-8859-1'); try { @@ -43,7 +43,7 @@ public function testMultiByteLiteralsSplitAsUtf8WhateverTheDefaultEncoding(): vo $this->resetParsedAstCache(); $result = $parser->parseGroups('/é/'); self::assertNotNull($result); - self::assertSame('non-empty-string', $result->getSubjectBaseType()->describe(VerbosityLevel::precise()), 'one character is one token, not two'); + self::assertSame("'é'", $result->getSubjectBaseType()->describe(VerbosityLevel::precise()), 'one character is one token, not two'); } finally { mb_internal_encoding($defaultEncoding); } @@ -62,7 +62,7 @@ private function createParser(): RegexGroupParser private function resetParsedAstCache(): void { - $property = new ReflectionProperty(RegexGroupParser::class, 'parsedAst'); + $property = new ReflectionProperty(RegexGroupParser::class, 'parsedShapes'); $property->setValue(null, []); } @@ -337,7 +337,8 @@ public function testTheAnalysedPhpVersionShapesTheEscape(): void self::assertInstanceOf(ConstantStringType::class, $group74, '7.4 reads the letter x then text'); self::assertSame('ax{ 41 }', $group74->getValue()); - self::assertNotInstanceOf(ConstantStringType::class, $group84, '8.x reads the padded escape'); + self::assertInstanceOf(ConstantStringType::class, $group84, '8.x reads the padded escape'); + self::assertSame('aA', $group84->getValue()); // the reader is a service carrying its own target: no statics, no // environment, one instance per judged PHP, and it hands back the @@ -453,6 +454,52 @@ public function testAPinAboveTheBundledEngineIsIgnored(): void self::assertSame('10.44', $targetOf(new PHPRegexReader(new PhpVersion(80400), PcreVersion::fromString('10.44'))), 'a pin at the bundled engine judges it'); } + public function testDefineGroupsAreCountedOnceAndNeverMatch(): void + { + $parser = $this->createParser(); + + // the engine never fills a group a (?(DEFINE)...) block: it is + // counted once — the branch it sits in is not walked as a branch — + // and carries no type of its own + $this->resetParsedAstCache(); + $result = $parser->parseGroups('/(?(DEFINE)(?x))b/'); + + self::assertNotNull($result); + $groups = array_values($result->getCapturingGroups()); + self::assertCount(1, $groups, 'the defined group is counted once'); + self::assertSame('a', $groups[0]->getName()); + self::assertTrue($groups[0]->isOptional(), 'the engine never fills it'); + } + + public function testTheMatchAndTheSubjectAreTwoChannels(): void + { + $parser = $this->createParser(); + + // $matches[0] is the match the engine wrote, constants included; + // the subject is what the pattern was run against — a different + // quantity: it contains the match, so its accessories carry over by + // containment, but even fully anchored $ admits one final newline + // ("a\n" matches /^(a|b|c)$/), so the subject narrows to accessories + // alone, never to constants + $this->resetParsedAstCache(); + $result = $parser->parseGroups('/^(a|b|c)$/'); + self::assertNotNull($result); + self::assertSame("'a'|'b'|'c'", $result->getSubjectBaseType()->describe(VerbosityLevel::precise()), 'the match channel keeps the constants'); + self::assertSame('non-falsy-string', $result->getSubjectNarrowType()->describe(VerbosityLevel::precise()), 'the subject channel narrows to accessories alone'); + + $this->resetParsedAstCache(); + $result = $parser->parseGroups('/foo/'); + self::assertNotNull($result); + self::assertSame("'foo'", $result->getSubjectBaseType()->describe(VerbosityLevel::precise()), 'the match channel keeps the constant'); + self::assertSame('non-falsy-string', $result->getSubjectNarrowType()->describe(VerbosityLevel::precise()), 'a subject that contains a non-falsy match is non-falsy itself'); + + $this->resetParsedAstCache(); + $result = $parser->parseGroups('/^$/'); + self::assertNotNull($result); + self::assertSame("''", $result->getSubjectBaseType()->describe(VerbosityLevel::precise()), 'the match channel keeps the empty constant'); + self::assertSame('string', $result->getSubjectNarrowType()->describe(VerbosityLevel::precise()), 'the subject may be the empty string or the final newline'); + } + public function testTargetInvalidPatternsGetNoShape(): void { // the pre-check judges for the analysed PHP, not the running one: a @@ -469,7 +516,9 @@ public function testTargetInvalidPatternsGetNoShape(): void $this->resetParsedAstCache(); $result = $php84->parseGroups('/(a\x{ 41 })/'); self::assertNotNull($result, 'the engine PHP 8.4 bundles reads it'); - self::assertNotInstanceOf(ConstantStringType::class, array_values($result->getCapturingGroups())[0]->getType()); + $group84 = array_values($result->getCapturingGroups())[0]->getType(); + self::assertInstanceOf(ConstantStringType::class, $group84, 'the shape knows what it reads'); + self::assertSame('aA', $group84->getValue()); } public function testEngineRefusedPatternsGetNoShapeAndBracketDelimitersWalk(): void From 980ddd436750001dcc4e821e20b90f3700286698 Mon Sep 17 00:00:00 2001 From: Younes ENNAJI Date: Fri, 23 Oct 2026 22:31:05 +0000 Subject: [PATCH 2/2] Answer the review: exclusive conditional branches, a judging engine policy, the packaging residue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conditional branches run one at a time and are read as exclusive as an alternation's; a lookaround condition counts its groups before them, and a group a (?(DEFINE)...) block defines holds the empty string alone — what preg_match writes is pinned in PHPRegexBackendParityTest, compared against the engine itself. The judged engine now follows the pin then the witness: a named engine is a statement honoured in both directions, and the running engine speaks for the analysed PHP when the two share a minor, so a patch release carries the engine it was cut with instead of its minor's first. A release candidate reads as its release, and a broken pin or platform value fails the boot naming the knob to fix. The shapes cache on the judged engine, the ignored-error validator reads its patterns with their modifiers, and the packaging no longer carries what the Hoa retreat left: the autoload fix in bin/phpstan and the scoper stubs and exclusions. --- bin/phpstan | 7 - compiler/build/scoper-namespaces.php | 1 - compiler/build/scoper.inc.php | 5 - src/Command/IgnoredRegexValidator.php | 21 ++- src/Php/PcreVersion.php | 6 +- src/Php/PcreVersionFactory.php | 20 +- src/Type/Regex/PHPRegexReader.php | 45 ++++- src/Type/Regex/RegexAstWalkResult.php | 25 --- src/Type/Regex/RegexGroupParser.php | 59 +++--- src/Type/Regex/RegexGroupWalkResult.php | 135 -------------- .../Type/Regex/PHPRegexBackendParityTest.php | 176 ++++++++++++++++++ .../Type/Regex/RegexShapeReadingTest.php | 103 ++++++++-- 12 files changed, 375 insertions(+), 228 deletions(-) delete mode 100644 src/Type/Regex/RegexGroupWalkResult.php create mode 100644 tests/PHPStan/Type/Regex/PHPRegexBackendParityTest.php diff --git a/bin/phpstan b/bin/phpstan index 48efeee92a8..abb8e716bec 100755 --- a/bin/phpstan +++ b/bin/phpstan @@ -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 = []; diff --git a/compiler/build/scoper-namespaces.php b/compiler/build/scoper-namespaces.php index e147c285245..7ddfe1f8e2e 100644 --- a/compiler/build/scoper-namespaces.php +++ b/compiler/build/scoper-namespaces.php @@ -13,7 +13,6 @@ 'PHPStanTurbo', 'PHPUnit', 'PhpParser', - 'Hoa', 'Symfony\Polyfill\Php80', 'Symfony\Polyfill\Php81', 'Symfony\Polyfill\Php83', diff --git a/compiler/build/scoper.inc.php b/compiler/build/scoper.inc.php index ac20519cee6..028c6b31363 100644 --- a/compiler/build/scoper.inc.php +++ b/compiler/build/scoper.inc.php @@ -5,7 +5,6 @@ $namespaces = require __DIR__ . '/scoper-namespaces.php'; $stubs = [ - '../../vendor/hoa/consistency/Prelude.php', '../../vendor/composer/InstalledVersions.php', '../../vendor/composer/installed.php', ]; @@ -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; } diff --git a/src/Command/IgnoredRegexValidator.php b/src/Command/IgnoredRegexValidator.php index e504917dd29..decbd6a8e72 100644 --- a/src/Command/IgnoredRegexValidator.php +++ b/src/Command/IgnoredRegexValidator.php @@ -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); } @@ -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); diff --git a/src/Php/PcreVersion.php b/src/Php/PcreVersion.php index e89e788d422..07d080483b6 100644 --- a/src/Php/PcreVersion.php +++ b/src/Php/PcreVersion.php @@ -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, diff --git a/src/Php/PcreVersionFactory.php b/src/Php/PcreVersionFactory.php index e3b22dc72d5..4b32a056b54 100644 --- a/src/Php/PcreVersionFactory.php +++ b/src/Php/PcreVersionFactory.php @@ -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 @@ -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 { diff --git a/src/Type/Regex/PHPRegexReader.php b/src/Type/Regex/PHPRegexReader.php index 7eb537b3583..e073e359db4 100644 --- a/src/Type/Regex/PHPRegexReader.php +++ b/src/Type/Regex/PHPRegexReader.php @@ -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; @@ -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 @@ -55,13 +66,32 @@ 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 + * 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 { @@ -69,7 +99,12 @@ private function targetOptions(): array $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(); } diff --git a/src/Type/Regex/RegexAstWalkResult.php b/src/Type/Regex/RegexAstWalkResult.php index bafe505648c..8dadb0a7300 100644 --- a/src/Type/Regex/RegexAstWalkResult.php +++ b/src/Type/Regex/RegexAstWalkResult.php @@ -19,7 +19,6 @@ final class RegexAstWalkResult */ public function __construct( private int $alternationId, - private int $captureGroupId, private array $capturingGroups, private array $markVerbs, private Type $subjectBaseType, @@ -32,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(), @@ -44,19 +41,6 @@ public function nextAlternationId(): self { return new self( $this->alternationId + 1, - $this->captureGroupId, - $this->capturingGroups, - $this->markVerbs, - $this->subjectBaseType, - $this->subjectNarrowType, - ); - } - - public function nextCaptureGroupId(): self - { - return new self( - $this->alternationId, - $this->captureGroupId + 1, $this->capturingGroups, $this->markVerbs, $this->subjectBaseType, @@ -71,7 +55,6 @@ public function addCapturingGroup(RegexCapturingGroup $group): self return new self( $this->alternationId, - $this->captureGroupId, $capturingGroups, $this->markVerbs, $this->subjectBaseType, @@ -86,7 +69,6 @@ public function markVerb(string $markVerb): self return new self( $this->alternationId, - $this->captureGroupId, $this->capturingGroups, $verbs, $this->subjectBaseType, @@ -97,7 +79,6 @@ public function withSubjectBaseType(Type $subjectBaseType): self { return new self( $this->alternationId, - $this->captureGroupId, $this->capturingGroups, $this->markVerbs, $subjectBaseType, @@ -108,7 +89,6 @@ public function withSubjectNarrowType(?Type $subjectNarrowType): self { return new self( $this->alternationId, - $this->captureGroupId, $this->capturingGroups, $this->markVerbs, $this->subjectBaseType, @@ -121,11 +101,6 @@ public function getAlternationId(): int return $this->alternationId; } - public function getCaptureGroupId(): int - { - return $this->captureGroupId; - } - /** * @return array */ diff --git a/src/Type/Regex/RegexGroupParser.php b/src/Type/Regex/RegexGroupParser.php index df17b2063b2..ca02243a9fd 100644 --- a/src/Type/Regex/RegexGroupParser.php +++ b/src/Type/Regex/RegexGroupParser.php @@ -5,6 +5,7 @@ use PHPRegex\Parser\Analysis\CaptureGroupShape; use PHPRegex\Parser\Analysis\CaptureShape; use PHPRegex\Parser\Analysis\CaptureShapeAnalyzer; +use PHPRegex\Parser\Analysis\Participation; use PHPRegex\Parser\Exception\RegexException; use PHPRegex\Parser\Node\AlternationNode; use PHPRegex\Parser\Node\AnchorNode; @@ -62,9 +63,9 @@ public function __construct( public function parseGroups(string $regex): ?RegexAstWalkResult { - // the shape is judged for a PHP version: the same regex read for two - // versions must not share a shape - $cacheKey = $this->phpVersion->getVersionId() . "\n" . $regex; + // the shape is judged for a PHP version and the engine its target + // runs: the same regex read for two of either must not share a shape + $cacheKey = $this->phpVersion->getVersionId() . "\n" . $this->reader->judgedPcreVersion() . "\n" . $regex; if (array_key_exists($cacheKey, self::$parsedShapes)) { $cached = self::$parsedShapes[$cacheKey]; @@ -118,7 +119,7 @@ private static function delimiterOf(string $regex): string */ private function buildWalkResult(CaptureShape $shape, NodeInterface $pattern): RegexAstWalkResult { - $topology = $this->collectTopology($pattern, null, null, 0, []); + $topology = $this->collectTopology($pattern, null, null, 0, ['alternationNodes' => [], 'groups' => [], 'nonCapturing' => []]); $walkResult = RegexAstWalkResult::createEmpty(); foreach ($shape->marks as $mark) { @@ -128,9 +129,9 @@ private function buildWalkResult(CaptureShape $shape, NodeInterface $pattern): R $alternations = []; foreach ($topology['alternationNodes'] as $alternationNode) { $walkResult = $walkResult->nextAlternationId(); - $alternations[spl_object_id($alternationNode)] = new RegexAlternation( + $alternations[spl_object_id($alternationNode['node'])] = new RegexAlternation( $walkResult->getAlternationId(), - count($alternationNode->alternatives), + $alternationNode['branches'], ); } @@ -139,31 +140,33 @@ private function buildWalkResult(CaptureShape $shape, NodeInterface $pattern): R $objects = []; foreach ($topology['nonCapturing'] as $id => $nonCapturing) { $objects[$id] = new RegexNonCapturingGroup( - $nonCapturing['alternation'] !== null ? $alternations[spl_object_id($nonCapturing['alternation'])] ?? null : null, + $nonCapturing['alternation'] !== null ? ($alternations[spl_object_id($nonCapturing['alternation'])] ?? null) : null, $nonCapturing['inOptional'], - $objects[$nonCapturing['parent']] ?? null, + $nonCapturing['parent'] !== null ? ($objects[$nonCapturing['parent']] ?? null) : null, $nonCapturing['resets'], ); } - $groupObjects = []; foreach ($shape->groups as $groupShape) { $entry = $topology['groups'][$groupShape->number] ?? null; if ($entry === null) { continue; } - $walkResult = $walkResult->nextCaptureGroupId(); + // a group the engine never fills holds the empty string: it is + // numbered, it is optional, and nothing more is true of it + $type = $groupShape->participation === Participation::Never() + ? new ConstantStringType('') + : $this->stringTypeOf($groupShape->values, $groupShape->digitsOnly, $groupShape->nonFalsy, $groupShape->minLength); $group = new RegexCapturingGroup( $groupShape->number, $groupShape->name, $entry['alternation'] !== null ? $alternations[spl_object_id($entry['alternation'])] ?? null : null, $entry['inOptional'], - $objects[$entry['parent']] ?? null, - $this->stringTypeOf($groupShape->values, $groupShape->digitsOnly, $groupShape->nonFalsy, $groupShape->minLength), + $entry['parent'] !== null ? ($objects[$entry['parent']] ?? null) : null, + $type, ); - $groupObjects[$groupShape->number] = $group; // a nested group reaches its optionality through its parent, and // a capturing parent carries its own optionality — both must sit // in the same map the children look them up from @@ -205,24 +208,20 @@ private function buildWalkResult(CaptureShape $shape, NodeInterface $pattern): R * the engine does; the topology walks the tree the same way, so a * number finds its node. * - * @param array{alternationNodes: list, groups: array, nonCapturing: array} $topology - * @return array{alternationNodes: list, groups: array, nonCapturing: array} + * @param array{alternationNodes: list, groups: array, nonCapturing: array} $topology + * @return array{alternationNodes: list, groups: array, nonCapturing: array} */ private function collectTopology( NodeInterface $node, ?int $parentId, - ?AlternationNode $alternation, + ?NodeInterface $alternation, int $branch, array $topology, bool $inOptionalQuantification = false, ): array { - $topology['alternationNodes'] ??= []; - $topology['groups'] ??= []; - $topology['nonCapturing'] ??= []; - if ($node instanceof AlternationNode) { - $topology['alternationNodes'][] = $node; + $topology['alternationNodes'][] = ['node' => $node, 'branches' => count($node->alternatives)]; foreach ($node->alternatives as $i => $alternative) { $topology = $this->collectTopology($alternative, $parentId, $node, $i, $topology); } @@ -238,13 +237,14 @@ private function collectTopology( } if ($node instanceof ConditionalNode) { - // the engine numbers the groups of both branches and never the - // condition itself - foreach ([$node->yes, $node->no] as $body) { - $topology = $this->collectTopology($body, $parentId, null, 0, $topology); - } - - return $topology; + // the engine numbers what a lookaround condition captures before + // the branches' groups, and runs one branch at a time — the two + // are as exclusive as an alternation's + $topology['alternationNodes'][] = ['node' => $node, 'branches' => 2]; + $topology = $this->collectTopology($node->condition, $parentId, null, 0, $topology); + $topology = $this->collectTopology($node->yes, $parentId, $node, 0, $topology); + + return $this->collectTopology($node->no, $parentId, $node, 1, $topology); } // an optional quantifier marks the one node it wraps, and only that @@ -292,6 +292,9 @@ private function collectTopology( return $topology; } + /** + * @param list|null $values + */ private function stringTypeOf(?array $values, bool $digitsOnly, bool $nonFalsy, int $minLength): Type { if ($values !== null && $values !== []) { diff --git a/src/Type/Regex/RegexGroupWalkResult.php b/src/Type/Regex/RegexGroupWalkResult.php deleted file mode 100644 index 9169af89ba5..00000000000 --- a/src/Type/Regex/RegexGroupWalkResult.php +++ /dev/null @@ -1,135 +0,0 @@ -|null $onlyLiterals - */ - public function __construct( - private bool $inOptionalQuantification, - private ?array $onlyLiterals, - private TrinaryLogic $isNonEmpty, - private TrinaryLogic $isNonFalsy, - private TrinaryLogic $isNumeric, - ) - { - } - - public static function createEmpty(): self - { - return new self( - false, - [], - TrinaryLogic::createMaybe(), - TrinaryLogic::createMaybe(), - TrinaryLogic::createMaybe(), - ); - } - - public function inOptionalQuantification(bool $inOptionalQuantification): self - { - return new self( - $inOptionalQuantification, - $this->onlyLiterals, - $this->isNonEmpty, - $this->isNonFalsy, - $this->isNumeric, - ); - } - - /** - * @param array|null $onlyLiterals - */ - public function onlyLiterals(?array $onlyLiterals): self - { - return new self( - $this->inOptionalQuantification, - $onlyLiterals, - $this->isNonEmpty, - $this->isNonFalsy, - $this->isNumeric, - ); - } - - public function nonEmpty(TrinaryLogic $nonEmpty): self - { - return new self( - $this->inOptionalQuantification, - $this->onlyLiterals, - $nonEmpty, - $this->isNonFalsy, - $this->isNumeric, - ); - } - - public function nonFalsy(TrinaryLogic $nonFalsy): self - { - return new self( - $this->inOptionalQuantification, - $this->onlyLiterals, - $this->isNonEmpty, - $nonFalsy, - $this->isNumeric, - ); - } - - public function numeric(TrinaryLogic $numeric): self - { - return new self( - $this->inOptionalQuantification, - $this->onlyLiterals, - $this->isNonEmpty, - $this->isNonFalsy, - $numeric, - ); - } - - public function isInOptionalQuantification(): bool - { - return $this->inOptionalQuantification; - } - - /** - * @return array|null - */ - public function getOnlyLiterals(): ?array - { - return $this->onlyLiterals; - } - - public function mightContainEmptyStringLiteral(): bool - { - if ($this->onlyLiterals === null) { - return false; - } - foreach ($this->onlyLiterals as $onlyLiteral) { - if ($onlyLiteral === '') { - return true; - } - } - - return false; - } - - public function isNonEmpty(): TrinaryLogic - { - return $this->isNonEmpty; - } - - public function isNonFalsy(): TrinaryLogic - { - return $this->isNonFalsy; - } - - public function isNumeric(): TrinaryLogic - { - return $this->isNumeric; - } - -} diff --git a/tests/PHPStan/Type/Regex/PHPRegexBackendParityTest.php b/tests/PHPStan/Type/Regex/PHPRegexBackendParityTest.php new file mode 100644 index 00000000000..fe770dca687 --- /dev/null +++ b/tests/PHPStan/Type/Regex/PHPRegexBackendParityTest.php @@ -0,0 +1,176 @@ + + */ + public static function providePatterns(): iterable + { + yield 'plain groups' => ['pattern' => '/(foo)(bar)(baz)+/', 'subject' => 'foobarbazbaz']; + yield 'backreference' => ['pattern' => '/(a|b)\1/', 'subject' => 'aa']; + yield 'mark verb' => ['pattern' => '/(*MARK:first)a/', 'subject' => 'a']; + yield 'quoted run' => ['pattern' => '/(\Qa.c\E)/', 'subject' => 'a.c']; + yield 'extended mode' => ['pattern' => '/(a) # comment/x', 'subject' => 'a']; + yield 'duplicate names' => ['pattern' => '/(?J)(?a)|(?b)/', 'subject' => 'b']; + yield 'version-shaped escape' => ['pattern' => '/(a\x{ 41 })/', 'subject' => 'aA']; + yield 'digit runs' => ['pattern' => '/(?\d{4})-(?\d{2})/', 'subject' => '2026-10']; + yield 'conditional' => ['pattern' => '/(a)(?(1)b|(c))/', 'subject' => 'ab']; + yield 'define' => ['pattern' => '/(?(DEFINE)(?abc))(?x)/', 'subject' => 'x']; + yield 'subroutine call' => ['pattern' => '/(a)(?1)/', 'subject' => 'aa']; + } + + #[Test] + #[DataProvider('providePatterns')] + public function test_the_engine_writes_what_the_shape_reads(string $pattern, string $subject): void + { + // PREG_UNMATCHED_AS_NULL keeps every numbered group in $matches — + // the engine omits the trailing ones it never filled, and the count + // would lie + $matched = preg_match($pattern, $subject, $matches, \PREG_UNMATCHED_AS_NULL); + self::assertSame(1, $matched, $pattern . ' must match ' . $subject); + + $helper = (new \ReflectionClass(RegexExpressionHelper::class))->newInstanceWithoutConstructor(); + $parser = new RegexGroupParser( + new PhpVersion(PHP_VERSION_ID), + $helper, + new PHPRegexReader(new PhpVersion(PHP_VERSION_ID), PcreVersion::fromRuntime()), + ); + $this->resetParsedAstCache(); + $result = $parser->parseGroups($pattern); + self::assertNotNull($result, $pattern . ' must read whole'); + + $engineGroups = 0; + foreach ($matches as $key => $value) { + if (\is_int($key) && $key > $engineGroups) { + $engineGroups = $key; + } + } + + $shapeGroups = $result->getCapturingGroups(); + self::assertCount($engineGroups, $shapeGroups, $pattern . ': the engine numbers as many groups as the walk'); + + foreach ($shapeGroups as $group) { + $number = $group->getId(); + $written = $matches[$number] ?? null; + + // a group the engine left unset is optional in the shape; a + // duplicate name writes the value of whichever branch ran + if ($written === null) { + self::assertTrue($group->isOptional(), $pattern . ': group ' . $number . ' never filled must read optional'); + + continue; + } + + if ($group->getName() !== null && $matches[$group->getName()] !== null) { + self::assertSame($written, $matches[$group->getName()], $pattern . ': the name writes the same value'); + } + + $type = $group->getType()->describe(VerbosityLevel::precise()); + if ($type !== 'string' && str_contains($type, "'")) { + self::assertStringContainsString( + "'" . $written . "'", + $type, + $pattern . ': the engine wrote ' . $written . ', the shape reads ' . $type, + ); + } elseif ($type !== 'string') { + self::assertNotSame('', $written, $pattern . ': an inexact shape never claims constants'); + } + } + } + + #[Test] + public function test_a_conditional_fills_only_one_branch(): void + { + // preg_match('/(a)(?(1)b|(c))/', 'ab', $m) === ['ab', 'a']: the else + // group is numbered, optional, and never set beside a set condition + self::assertSame(1, preg_match('/(a)(?(1)b|(c))/', 'ab', $m)); + self::assertSame(['ab', 'a'], $m); + + $parser = $this->createParser(); + $this->resetParsedAstCache(); + $result = $parser->parseGroups('/(a)(?(1)b|(c))/'); + self::assertNotNull($result); + + $else = $result->getCapturingGroups()[2] ?? null; + self::assertNotNull($else); + self::assertTrue($else->isOptional(), 'the else branch runs alone, or not at all'); + } + + #[Test] + public function test_a_defined_group_stays_empty(): void + { + // preg_match('/(?(DEFINE)(?abc))(?x)/', 'x', $m): d and 1 stay + // at the empty string, whatever the block spells + self::assertSame(1, preg_match('/(?(DEFINE)(?abc))(?x)/', 'x', $m)); + self::assertSame('{"0":"x","d":"","1":"","r":"x","2":"x"}', json_encode($m), 'the engine leaves the defined group empty'); + + $parser = $this->createParser(); + $this->resetParsedAstCache(); + $result = $parser->parseGroups('/(?(DEFINE)(?abc))(?x)/'); + self::assertNotNull($result); + + $defined = $result->getCapturingGroups()[1] ?? null; + self::assertNotNull($defined); + self::assertSame("''", $defined->getType()->describe(VerbosityLevel::precise())); + } + + #[Test] + public function test_digit_groups_read_their_lengths(): void + { + self::assertSame(1, preg_match('/(?\d{4})-(?\d{2})/', '2026-10', $m)); + self::assertSame('2026', $m['year']); + self::assertTrue(ctype_digit($m['year'])); + + $parser = $this->createParser(); + $this->resetParsedAstCache(); + $result = $parser->parseGroups('/(?\d{4})-(?\d{2})/'); + self::assertNotNull($result); + + $year = $result->getCapturingGroups()[1] ?? null; + self::assertNotNull($year); + $type = $year->getType(); + self::assertStringContainsString('numeric-string', $type->describe(VerbosityLevel::precise())); + } + + private function createParser(): RegexGroupParser + { + $helper = (new \ReflectionClass(RegexExpressionHelper::class))->newInstanceWithoutConstructor(); + + return new RegexGroupParser( + new PhpVersion(PHP_VERSION_ID), + $helper, + new PHPRegexReader(new PhpVersion(PHP_VERSION_ID), PcreVersion::fromRuntime()), + ); + } + + private function resetParsedAstCache(): void + { + $property = new \ReflectionProperty(RegexGroupParser::class, 'parsedShapes'); + $property->setAccessible(true); + $property->setValue(null, []); + } + +} diff --git a/tests/PHPStan/Type/Regex/RegexShapeReadingTest.php b/tests/PHPStan/Type/Regex/RegexShapeReadingTest.php index 56a9db464d6..0501ec275ce 100644 --- a/tests/PHPStan/Type/Regex/RegexShapeReadingTest.php +++ b/tests/PHPStan/Type/Regex/RegexShapeReadingTest.php @@ -256,7 +256,7 @@ public function testNoAutoCaptureOptionDropsTheGroups(): void // phantom group 1 survives in the shapes inferred today. $matched = preg_match('/(?n)(\d+)/', 'a5', $matches); self::assertSame(1, $matched); - self::assertCount(1, $matches, 'the engine itself must not capture group 1'); + self::assertSame(['5'], $matches, 'the engine itself must not capture group 1'); $this->resetParsedAstCache(); $result = $parser->parseGroups('/(?n)(\d+)/'); @@ -265,6 +265,9 @@ public function testNoAutoCaptureOptionDropsTheGroups(): void self::assertSame([], $result->getCapturingGroups(), 'the walk must not report a capturing group'); } + /** + * @throws \PHPRegex\Parser\Exception\RegexException + */ public function testTheReaderHandsBackTheParserOwnTree(): void { // no translation layer stands between the walkers and the parser's @@ -272,7 +275,7 @@ public function testTheReaderHandsBackTheParserOwnTree(): void $reader = new PHPRegexReader(new PhpVersion(80400), PcreVersion::fromString('10.44')); $tree = $reader->read('(a)', '', '/'); - self::assertInstanceOf(\PHPRegex\Parser\Node\RegexNode::class, $tree); + self::assertSame('/', $tree->delimiter); $body = $tree->pattern; self::assertInstanceOf(\PHPRegex\Parser\Node\GroupNode::class, $body); self::assertSame(\PHPRegex\Parser\Node\GroupType::Capturing(), $body->type); @@ -286,6 +289,7 @@ public function testTheReaderHandsBackTheParserOwnTree(): void $tree = $reader->read('a{2,4}', '', '/'); self::assertInstanceOf(\PHPRegex\Parser\Node\QuantifierNode::class, $tree->pattern); $bounds = \PHPRegex\Parser\Node\QuantifierBounds::parse($tree->pattern->quantifier); + self::assertNotNull($bounds); self::assertSame(2, $bounds->min); self::assertSame(4, $bounds->max); } @@ -310,6 +314,9 @@ public function testDuplicateGroupNamesKeepTheirShapesInlineAndStayRefusedTraili self::assertNull($parser->parseGroups('/(?a)(?b)/J')); } + /** + * @throws \PHPRegex\Parser\Exception\RegexException + */ public function testTheAnalysedPhpVersionShapesTheEscape(): void { // \x{ 41 } is a padded hex escape: engines from 10.43 read it as the @@ -358,7 +365,7 @@ public function testTheAnalysedPhpVersionShapesTheEscape(): void $name = (new ReflectionClass($n))->getShortName(); $detail = ''; foreach (['value', 'quantifier', 'type', 'isNegated'] as $prop) { - $propValue = $n->$prop ?? null; + $propValue = property_exists($n, $prop) ? $n->$prop : null; if ($propValue !== null && (is_string($propValue) || is_int($propValue) || is_bool($propValue))) { $detail .= '|' . $propValue; } elseif ($propValue !== null) { @@ -429,11 +436,16 @@ public function testPcreVersionMirrorsPhpVersion(): void } } - public function testAPinAboveTheBundledEngineIsIgnored(): void + /** + * @throws \PHPRegex\Parser\Exception\RegexException + */ + public function testTheJudgedEngineFollowsThePinThenTheWitness(): void { - // the analysed PHP bundles its own engine; config or composer.json - // can only narrow below it, never raise the judged engine above - // what that PHP can run + // a named engine is a statement about the analysed project, honoured + // in both directions; the running engine is only a witness, and it + // speaks for the analysed PHP when the two share a minor — a patch + // release carries whatever engine was current, and the baseline map + // only knows the minor's first $targetOf = static function (PHPRegexReader $reader): string { $reader->read('a', '', '/'); @@ -446,12 +458,14 @@ public function testAPinAboveTheBundledEngineIsIgnored(): void return $parser->target()->pcreVersion; }; - // pinned, not read from the runner's engine: the test says the same - // thing on every machine - $runtimeEngine = PcreVersion::fromString('10.49'); - self::assertSame('10.44', $targetOf(new PHPRegexReader(new PhpVersion(80400), $runtimeEngine)), 'a pin above the bundled engine is ignored'); - self::assertSame('10.42', $targetOf(new PHPRegexReader(new PhpVersion(80400), PcreVersion::fromString('10.42'))), 'a pin below the bundled engine narrows'); - self::assertSame('10.44', $targetOf(new PHPRegexReader(new PhpVersion(80400), PcreVersion::fromString('10.44'))), 'a pin at the bundled engine judges it'); + $minor = intdiv(PHP_VERSION_ID, 100) * 100; + $witness = PcreVersion::fromString(PcreVersion::fromRuntime()->getVersionString(), PcreVersion::SOURCE_RUNTIME); + + self::assertSame('10.49', $targetOf(new PHPRegexReader(new PhpVersion(80400), PcreVersion::fromString('10.49', PcreVersion::SOURCE_CONFIG))), 'a pin is a statement, honoured above the baseline'); + self::assertSame('10.42', $targetOf(new PHPRegexReader(new PhpVersion(80400), PcreVersion::fromString('10.42', PcreVersion::SOURCE_CONFIG))), 'a pin below the baseline narrows'); + self::assertSame($witness->getVersionString(), $targetOf(new PHPRegexReader(new PhpVersion($minor), $witness)), 'the running engine speaks for its own minor'); + self::assertNotSame($witness->getVersionString(), $targetOf(new PHPRegexReader(new PhpVersion(70400), $witness)), 'a witness from another minor stays silent'); + self::assertSame('10.49', PcreVersion::fromString('10.49-RC1')->getVersionString(), 'a release candidate reads as its release'); } public function testDefineGroupsAreCountedOnceAndNeverMatch(): void @@ -471,6 +485,55 @@ public function testDefineGroupsAreCountedOnceAndNeverMatch(): void self::assertTrue($groups[0]->isOptional(), 'the engine never fills it'); } + public function testADefinedGroupHoldsOnlyTheEmptyString(): void + { + $parser = $this->createParser(); + + // preg_match('/(?(DEFINE)(?abc))(?x)/', 'x', $m) fills + // bar and leaves foo at '' — the engine never runs what DEFINE holds + $this->resetParsedAstCache(); + $result = $parser->parseGroups('/(?(DEFINE)(?abc))(?x)/'); + + self::assertNotNull($result); + $foo = $result->getCapturingGroups()[1] ?? null; + self::assertNotNull($foo); + self::assertSame("''", $foo->getType()->describe(VerbosityLevel::precise()), 'a defined group stays empty'); + } + + public function testConditionalBranchesAreExclusive(): void + { + $parser = $this->createParser(); + + // preg_match('/(a)(?(1)b|(c))/', 'ab', $m) fills group 1 and never + // group 2 — the branches run one at a time, as an alternation + $this->resetParsedAstCache(); + $result = $parser->parseGroups('/(a)(?(1)b|(c))/'); + + self::assertNotNull($result); + $groups = $result->getCapturingGroups(); + self::assertSame("'a'", $groups[1]->getType()->describe(VerbosityLevel::precise())); + self::assertFalse($groups[1]->isOptional()); + self::assertSame("'c'", $groups[2]->getType()->describe(VerbosityLevel::precise())); + self::assertTrue($groups[2]->isOptional(), 'the else branch runs alone, or not at all'); + } + + public function testALookaroundConditionCountsItsGroups(): void + { + $parser = $this->createParser(); + + // the engine numbers the groups a lookaround condition holds before + // the branches' — preg_match numbers (a) as 1 and (c) as 2, whatever + // runs + $this->resetParsedAstCache(); + $result = $parser->parseGroups('/(?(?=(a))b|(c))/'); + + self::assertNotNull($result); + $groups = $result->getCapturingGroups(); + self::assertCount(2, $groups, 'the condition holds group 1'); + self::assertSame("'a'", $groups[1]->getType()->describe(VerbosityLevel::precise())); + self::assertSame("'c'", $groups[2]->getType()->describe(VerbosityLevel::precise())); + } + public function testTheMatchAndTheSubjectAreTwoChannels(): void { $parser = $this->createParser(); @@ -485,19 +548,25 @@ public function testTheMatchAndTheSubjectAreTwoChannels(): void $result = $parser->parseGroups('/^(a|b|c)$/'); self::assertNotNull($result); self::assertSame("'a'|'b'|'c'", $result->getSubjectBaseType()->describe(VerbosityLevel::precise()), 'the match channel keeps the constants'); - self::assertSame('non-falsy-string', $result->getSubjectNarrowType()->describe(VerbosityLevel::precise()), 'the subject channel narrows to accessories alone'); + $narrowType = $result->getSubjectNarrowType(); + self::assertNotNull($narrowType, 'an anchored pattern narrows its subject'); + self::assertSame('non-falsy-string', $narrowType->describe(VerbosityLevel::precise()), 'the subject channel narrows to accessories alone'); $this->resetParsedAstCache(); $result = $parser->parseGroups('/foo/'); self::assertNotNull($result); self::assertSame("'foo'", $result->getSubjectBaseType()->describe(VerbosityLevel::precise()), 'the match channel keeps the constant'); - self::assertSame('non-falsy-string', $result->getSubjectNarrowType()->describe(VerbosityLevel::precise()), 'a subject that contains a non-falsy match is non-falsy itself'); + $narrowType = $result->getSubjectNarrowType(); + self::assertNotNull($narrowType); + self::assertSame('non-falsy-string', $narrowType->describe(VerbosityLevel::precise()), 'a subject that contains a non-falsy match is non-falsy itself'); $this->resetParsedAstCache(); $result = $parser->parseGroups('/^$/'); self::assertNotNull($result); self::assertSame("''", $result->getSubjectBaseType()->describe(VerbosityLevel::precise()), 'the match channel keeps the empty constant'); - self::assertSame('string', $result->getSubjectNarrowType()->describe(VerbosityLevel::precise()), 'the subject may be the empty string or the final newline'); + $narrowType = $result->getSubjectNarrowType(); + self::assertNotNull($narrowType); + self::assertSame('string', $narrowType->describe(VerbosityLevel::precise()), 'the subject may be the empty string or the final newline'); } public function testTargetInvalidPatternsGetNoShape(): void @@ -554,7 +623,7 @@ public function testXModeCommentsAreReadByTheParserNotPreStripped(): void // both; in front of a mode-aware parser it mangles the pattern. $matched = preg_match('/(a\Q#b\Ec)/x', 'a#bc', $matches); self::assertSame(1, $matched); - self::assertSame('a#bc', $matches[1]); + self::assertSame('["a#bc","a#bc"]', json_encode($matches), 'the engine reads the quoted hash as text'); $this->resetParsedAstCache(); $result = $parser->parseGroups('/(a\Q#b\Ec)/x');