diff --git a/src/Type/Php/RegexArrayShapeMatcher.php b/src/Type/Php/RegexArrayShapeMatcher.php index cd91c0aa80..da6a44e735 100644 --- a/src/Type/Php/RegexArrayShapeMatcher.php +++ b/src/Type/Php/RegexArrayShapeMatcher.php @@ -15,14 +15,13 @@ use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntegerType; use PHPStan\Type\NullType; -use PHPStan\Type\Regex\RegexAlternation; use PHPStan\Type\Regex\RegexCapturingGroup; use PHPStan\Type\Regex\RegexExpressionHelper; +use PHPStan\Type\Regex\RegexGroupList; use PHPStan\Type\Regex\RegexGroupParser; use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; -use function array_reverse; use function count; use function in_array; use function is_string; @@ -115,16 +114,10 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched } [$groupList, $markVerbs] = $parseResult; - $trailingOptionals = 0; - foreach (array_reverse($groupList) as $captureGroup) { - if (!$captureGroup->isOptional()) { - break; - } - $trailingOptionals++; - } - - $onlyOptionalTopLevelGroup = $this->getOnlyOptionalTopLevelGroup($groupList); - $onlyTopLevelAlternation = $this->getOnlyTopLevelAlternation($groupList); + $regexGroupList = new RegexGroupList($groupList); + $trailingOptionals = $regexGroupList->countTrailingOptionals(); + $onlyOptionalTopLevelGroup = $regexGroupList->getOnlyOptionalTopLevelGroup(); + $onlyTopLevelAlternation = $regexGroupList->getOnlyTopLevelAlternation(); $flags ??= 0; if ( @@ -134,11 +127,10 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched ) { // if only one top level capturing optional group exists // we build a more precise tagged union of a empty-match and a match with the group - - $onlyOptionalTopLevelGroup->forceNonOptional(); + $regexGroupList = $regexGroupList->forceGroupNonOptional($onlyOptionalTopLevelGroup); $combiType = $this->buildArrayType( - $groupList, + $regexGroupList, $wasMatched, $trailingOptionals, $flags, @@ -154,8 +146,6 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched ); } - $onlyOptionalTopLevelGroup->clearOverrides(); - return $combiType; } elseif ( !$matchesAll @@ -168,24 +158,24 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched $combiTypes = []; $isOptionalAlternation = false; foreach ($onlyTopLevelAlternation->getGroupCombinations() as $groupCombo) { - $comboList = $groupList; + $comboList = new RegexGroupList($groupList); $beforeCurrentCombo = true; - foreach ($comboList as $groupId => $group) { - if (in_array($groupId, $groupCombo, true)) { + foreach ($comboList as $group) { + if (in_array($group->getId(), $groupCombo, true)) { $isOptionalAlternation = $group->inOptionalAlternation(); - $group->forceNonOptional(); + $comboList = $comboList->forceGroupNonOptional($group); $beforeCurrentCombo = false; } elseif ($beforeCurrentCombo && !$group->resetsGroupCounter()) { - $group->forceNonOptional(); - $group->forceType( + $comboList = $comboList->forceGroupTypeAndNonOptional( + $group, $this->containsUnmatchedAsNull($flags, $matchesAll) ? new NullType() : new ConstantStringType(''), ); } elseif ( $group->getAlternationId() === $onlyTopLevelAlternation->getId() && !$this->containsUnmatchedAsNull($flags, $matchesAll) ) { - unset($comboList[$groupId]); + $comboList = $comboList->removeGroup($group); } } @@ -199,11 +189,6 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched ); $combiTypes[] = $combiType; - - foreach ($groupCombo as $groupId) { - $group = $comboList[$groupId]; - $group->clearOverrides(); - } } if ( @@ -223,7 +208,7 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched // the general case, which should work in all cases but does not yield the most // precise result possible in some cases return $this->buildArrayType( - $groupList, + $regexGroupList, $wasMatched, $trailingOptionals, $flags, @@ -233,65 +218,10 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched } /** - * @param array $captureGroups - */ - private function getOnlyOptionalTopLevelGroup(array $captureGroups): ?RegexCapturingGroup - { - $group = null; - foreach ($captureGroups as $captureGroup) { - if (!$captureGroup->isTopLevel()) { - continue; - } - - if (!$captureGroup->isOptional()) { - return null; - } - - if ($group !== null) { - return null; - } - - $group = $captureGroup; - } - - return $group; - } - - /** - * @param array $captureGroups - */ - private function getOnlyTopLevelAlternation(array $captureGroups): ?RegexAlternation - { - $alternation = null; - foreach ($captureGroups as $captureGroup) { - if (!$captureGroup->isTopLevel()) { - continue; - } - - if (!$captureGroup->inAlternation()) { - return null; - } - - if ($captureGroup->inOptionalQuantification()) { - return null; - } - - if ($alternation === null) { - $alternation = $captureGroup->getAlternation(); - } elseif ($alternation->getId() !== $captureGroup->getAlternation()->getId()) { - return null; - } - } - - return $alternation; - } - - /** - * @param array $captureGroups * @param list $markVerbs */ private function buildArrayType( - array $captureGroups, + RegexGroupList $captureGroups, TrinaryLogic $wasMatched, int $trailingOptionals, int $flags, diff --git a/src/Type/Regex/RegexCapturingGroup.php b/src/Type/Regex/RegexCapturingGroup.php index 51a1fc9d85..3cc16fa182 100644 --- a/src/Type/Regex/RegexCapturingGroup.php +++ b/src/Type/Regex/RegexCapturingGroup.php @@ -7,10 +7,6 @@ final class RegexCapturingGroup { - private bool $forceNonOptional = false; - - private ?Type $forceType = null; - public function __construct( private readonly int $id, private readonly ?string $name, @@ -18,6 +14,8 @@ public function __construct( private readonly bool $inOptionalQuantification, private readonly RegexCapturingGroup|RegexNonCapturingGroup|null $parent, private readonly Type $type, + private readonly bool $forceNonOptional = false, + private readonly ?Type $forceType = null, ) { } @@ -27,20 +25,46 @@ public function getId(): int return $this->id; } - public function forceNonOptional(): void + public function forceNonOptional(): self { - $this->forceNonOptional = true; + return new self( + $this->id, + $this->name, + $this->alternation, + $this->inOptionalQuantification, + $this->parent, + $this->type, + true, + $this->forceType, + ); } - public function forceType(Type $type): void + public function forceType(Type $type): self { - $this->forceType = $type; + return new self( + $this->id, + $this->name, + $this->alternation, + $this->inOptionalQuantification, + $this->parent, + $type, + $this->forceNonOptional, + $this->forceType, + ); } - public function clearOverrides(): void + public function withParent(RegexCapturingGroup|RegexNonCapturingGroup $parent): self { - $this->forceNonOptional = false; - $this->forceType = null; + return new self( + $this->id, + $this->name, + $this->alternation, + $this->inOptionalQuantification, + $parent, + $this->type, + $this->forceNonOptional, + $this->forceType, + ); } public function resetsGroupCounter(): bool @@ -128,4 +152,9 @@ public function getType(): Type return $this->type; } + public function getParent(): RegexCapturingGroup|RegexNonCapturingGroup|null + { + return $this->parent; + } + } diff --git a/src/Type/Regex/RegexGroupList.php b/src/Type/Regex/RegexGroupList.php new file mode 100644 index 0000000000..d5f624f5df --- /dev/null +++ b/src/Type/Regex/RegexGroupList.php @@ -0,0 +1,166 @@ + + */ +final class RegexGroupList implements Countable, IteratorAggregate +{ + + /** + * @param array $groups + */ + public function __construct( + private readonly array $groups, + ) + { + } + + public function countTrailingOptionals(): int + { + $trailingOptionals = 0; + foreach (array_reverse($this->groups) as $captureGroup) { + if (!$captureGroup->isOptional()) { + break; + } + $trailingOptionals++; + } + return $trailingOptionals; + } + + public function forceGroupNonOptional(RegexCapturingGroup $group): self + { + return $this->cloneAndReParentList($group); + } + + public function forceGroupTypeAndNonOptional(RegexCapturingGroup $group, Type $type): self + { + return $this->cloneAndReParentList($group, $type); + } + + private function cloneAndReParentList(RegexCapturingGroup $target, ?Type $type = null): self + { + $groups = []; + $forcedGroup = null; + foreach ($this->groups as $i => $group) { + if ($group->getId() === $target->getId()) { + $forcedGroup = $group->forceNonOptional(); + if ($type !== null) { + $forcedGroup = $forcedGroup->forceType($type); + } + $groups[$i] = $forcedGroup; + + continue; + } + + $groups[$i] = $group; + } + + if ($forcedGroup === null) { + throw new ShouldNotHappenException(); + } + + foreach ($groups as $i => $group) { + $parent = $group->getParent(); + + while ($parent !== null) { + if ($parent instanceof RegexNonCapturingGroup) { + $parent = $parent->getParent(); + continue; + } + + if ($parent->getId() === $target->getId()) { + $groups[$i] = $groups[$i]->withParent($forcedGroup); + } + $parent = $parent->getParent(); + } + } + + return new self($groups); + } + + public function removeGroup(RegexCapturingGroup $remove): self + { + $groups = []; + foreach ($this->groups as $i => $group) { + if ($group->getId() === $remove->getId()) { + continue; + } + + $groups[$i] = $group; + } + + return new self($groups); + } + + public function getOnlyOptionalTopLevelGroup(): ?RegexCapturingGroup + { + $group = null; + foreach ($this->groups as $captureGroup) { + if (!$captureGroup->isTopLevel()) { + continue; + } + + if (!$captureGroup->isOptional()) { + return null; + } + + if ($group !== null) { + return null; + } + + $group = $captureGroup; + } + + return $group; + } + + public function getOnlyTopLevelAlternation(): ?RegexAlternation + { + $alternation = null; + foreach ($this->groups as $captureGroup) { + if (!$captureGroup->isTopLevel()) { + continue; + } + + if (!$captureGroup->inAlternation()) { + return null; + } + + if ($captureGroup->inOptionalQuantification()) { + return null; + } + + if ($alternation === null) { + $alternation = $captureGroup->getAlternation(); + } elseif ($alternation->getId() !== $captureGroup->getAlternation()->getId()) { + return null; + } + } + + return $alternation; + } + + public function count(): int + { + return count($this->groups); + } + + /** + * @return ArrayIterator + */ + public function getIterator(): ArrayIterator + { + return new ArrayIterator($this->groups); + } + +}