diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index c5afa1e22f..aaa08cf178 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -316,6 +316,12 @@ public function supportsPregUnmatchedAsNull(): bool return $this->versionId >= 70400; } + public function supportsPregCaptureOnlyNamedGroups(): bool + { + // https://php.watch/versions/8.2/preg-n-no-capture-modifier + return $this->versionId >= 80200; + } + public function hasDateTimeExceptions(): bool { return $this->versionId >= 80300; diff --git a/src/Rules/Regexp/RegularExpressionQuotingRule.php b/src/Rules/Regexp/RegularExpressionQuotingRule.php index 873209c00f..c46b7ce72c 100644 --- a/src/Rules/Regexp/RegularExpressionQuotingRule.php +++ b/src/Rules/Regexp/RegularExpressionQuotingRule.php @@ -15,7 +15,7 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\ShouldNotHappenException; -use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\Php\RegexExpressionHelper; use function array_filter; use function array_merge; use function array_values; @@ -23,7 +23,6 @@ use function in_array; use function sprintf; use function strlen; -use function substr; /** * @implements Rule @@ -31,7 +30,10 @@ class RegularExpressionQuotingRule implements Rule { - public function __construct(private ReflectionProvider $reflectionProvider) + public function __construct( + private ReflectionProvider $reflectionProvider, + private RegexExpressionHelper $regexExpressionHelper, + ) { } @@ -76,7 +78,7 @@ public function processNode(Node $node, Scope $scope): array return []; } - $patternDelimiters = $this->getDelimitersFromConcat($normalizedArgs[0]->value, $scope); + $patternDelimiters = $this->regexExpressionHelper->getPatternDelimiters($normalizedArgs[0]->value, $scope); return $this->validateQuoteDelimiters($normalizedArgs[0]->value, $scope, $patternDelimiters); } @@ -193,40 +195,6 @@ private function validatePregQuote(FuncCall $pregQuote, Scope $scope, array $pat return null; } - /** - * Get delimiters from non-constant patterns, if possible. - * - * @return string[] - */ - private function getDelimitersFromConcat(Concat $concat, Scope $scope): array - { - if ($concat->left instanceof Concat) { - return $this->getDelimitersFromConcat($concat->left, $scope); - } - - $left = $scope->getType($concat->left); - - $delimiters = []; - foreach ($left->getConstantStrings() as $leftString) { - $delimiter = $this->getDelimiterFromString($leftString); - if ($delimiter === null) { - continue; - } - - $delimiters[] = $delimiter; - } - return $delimiters; - } - - private function getDelimiterFromString(ConstantStringType $string): ?string - { - if ($string->getValue() === '') { - return null; - } - - return substr($string->getValue(), 0, 1); - } - /** * @param string[] $delimiters * diff --git a/src/Type/Php/RegexArrayShapeMatcher.php b/src/Type/Php/RegexArrayShapeMatcher.php index 8494154021..3e65133284 100644 --- a/src/Type/Php/RegexArrayShapeMatcher.php +++ b/src/Type/Php/RegexArrayShapeMatcher.php @@ -33,6 +33,7 @@ use function is_string; use function rtrim; use function sscanf; +use function str_contains; use function str_replace; use function strlen; use function substr; @@ -411,6 +412,12 @@ private function parseGroups(string $regex): ?array return null; } + $captureOnlyNamed = false; + if ($this->phpVersion->supportsPregCaptureOnlyNamedGroups()) { + $modifiers = $this->regexExpressionHelper->getPatternModifiers($regex); + $captureOnlyNamed = str_contains($modifiers ?? '', 'n'); + } + $capturingGroups = []; $groupCombinations = []; $alternationId = -1; @@ -427,6 +434,7 @@ private function parseGroups(string $regex): ?array $capturingGroups, $groupCombinations, $markVerbs, + $captureOnlyNamed, ); return [$capturingGroups, $groupCombinations, $markVerbs]; @@ -448,6 +456,7 @@ private function walkRegexAst( array &$capturingGroups, array &$groupCombinations, array &$markVerbs, + bool $captureOnlyNamed, ): void { $group = null; @@ -509,7 +518,10 @@ private function walkRegexAst( return; } - if ($group instanceof RegexCapturingGroup) { + if ( + $group instanceof RegexCapturingGroup && + (!$captureOnlyNamed || $group->isNamed()) + ) { $capturingGroups[$group->getId()] = $group; if (!array_key_exists($alternationId, $groupCombinations)) { @@ -533,6 +545,7 @@ private function walkRegexAst( $capturingGroups, $groupCombinations, $markVerbs, + $captureOnlyNamed, ); if ($ast->getId() !== '#alternation') { diff --git a/src/Type/Php/RegexExpressionHelper.php b/src/Type/Php/RegexExpressionHelper.php index 5ec2ad4c84..5ba562cbdd 100644 --- a/src/Type/Php/RegexExpressionHelper.php +++ b/src/Type/Php/RegexExpressionHelper.php @@ -3,12 +3,15 @@ namespace PHPStan\Type\Php; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\BinaryOp\Concat; use PhpParser\Node\Name; use PHPStan\Analyser\Scope; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function strrpos; +use function substr; final class RegexExpressionHelper { @@ -26,7 +29,7 @@ public function __construct( * * see https://github.com/phpstan/phpstan-src/pull/3233#discussion_r1676938085 */ - public function resolvePatternConcat(Expr\BinaryOp\Concat $concat, Scope $scope): Type + public function resolvePatternConcat(Concat $concat, Scope $scope): Type { $resolver = new class($scope) { @@ -44,7 +47,7 @@ public function resolve(Expr $expr): Type return new ConstantStringType(''); } - if ($expr instanceof Expr\BinaryOp\Concat) { + if ($expr instanceof Concat) { $left = $this->resolve($expr->left); $right = $this->resolve($expr->right); @@ -66,4 +69,59 @@ public function resolve(Expr $expr): Type return $this->initializerExprTypeResolver->getConcatType($concat->left, $concat->right, static fn (Expr $expr): Type => $resolver->resolve($expr)); } + public function getPatternModifiers(string $pattern): ?string + { + $delimiter = $this->getDelimiterFromString(new ConstantStringType($pattern)); + if ($delimiter === null) { + return null; + } + + if ($delimiter === '{') { + $endDelimiterPos = strrpos($pattern, '}'); + } else { + // same start and end delimiter + $endDelimiterPos = strrpos($pattern, $delimiter); + } + + if ($endDelimiterPos === false) { + return null; + } + + return substr($pattern, $endDelimiterPos + 1); + } + + /** + * Get delimiters from non-constant patterns, if possible. + * + * @return string[] + */ + public function getPatternDelimiters(Concat $concat, Scope $scope): array + { + if ($concat->left instanceof Concat) { + return $this->getPatternDelimiters($concat->left, $scope); + } + + $left = $scope->getType($concat->left); + + $delimiters = []; + foreach ($left->getConstantStrings() as $leftString) { + $delimiter = $this->getDelimiterFromString($leftString); + if ($delimiter === null) { + continue; + } + + $delimiters[] = $delimiter; + } + return $delimiters; + } + + private function getDelimiterFromString(ConstantStringType $string): ?string + { + if ($string->getValue() === '') { + return null; + } + + return substr($string->getValue(), 0, 1); + } + } diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php index af50a43064..9159be672f 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php @@ -8,12 +8,17 @@ // 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{string, numeric-string}', $matches); // should be 'array{string}' + assertType('array{string}', $matches); } - assertType('array{}|array{string, numeric-string}', $matches); + assertType('array{}|array{string}', $matches); if (preg_match('/(\d+)(?P\d+)/n', $s, $matches)) { - assertType('array{0: string, 1: numeric-string, num: numeric-string, 2: numeric-string}', $matches); + assertType('array{0: string, num: numeric-string, 1: numeric-string}', $matches); } - assertType('array{}|array{0: string, 1: numeric-string, num: numeric-string, 2: numeric-string}', $matches); + assertType('array{}|array{0: string, num: numeric-string, 1: numeric-string}', $matches); + + if (preg_match('/(\w)-(?P\d+)-(\w)/n', $s, $matches)) { + assertType('array{0: string, num: numeric-string, 1: numeric-string}', $matches); + } + assertType('array{}|array{0: string, num: numeric-string, 1: numeric-string}', $matches); } diff --git a/tests/PHPStan/Rules/Regexp/RegularExpressionQuotingRuleTest.php b/tests/PHPStan/Rules/Regexp/RegularExpressionQuotingRuleTest.php index fc1db91b7f..1bffbf1a1d 100644 --- a/tests/PHPStan/Rules/Regexp/RegularExpressionQuotingRuleTest.php +++ b/tests/PHPStan/Rules/Regexp/RegularExpressionQuotingRuleTest.php @@ -4,6 +4,7 @@ use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use PHPStan\Type\Php\RegexExpressionHelper; use const PHP_VERSION_ID; /** @@ -14,7 +15,10 @@ class RegularExpressionQuotingRuleTest extends RuleTestCase protected function getRule(): Rule { - return new RegularExpressionQuotingRule($this->createReflectionProvider()); + return new RegularExpressionQuotingRule( + $this->createReflectionProvider(), + self::getContainer()->getByType(RegexExpressionHelper::class), + ); } public function testRule(): void