Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Detect pattern errors via PHPStan extension #30

Merged
merged 1 commit into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions extension.neon
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,4 @@ services:

rules:
- Composer\Pcre\PHPStan\UnsafeStrictGroupsCallRule
- Composer\Pcre\PHPStan\InvalidRegexPatternRule
90 changes: 90 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,93 @@ parameters:
message: "#^Parameter &\\$matches @param\\-out type of method Composer\\\\Pcre\\\\Preg\\:\\:matchWithOffsets\\(\\) expects array\\<int\\|string, array\\{string\\|null, int\\<\\-1, max\\>\\}\\>, array\\<int\\|string, string\\|null\\> given\\.$#"
count: 1
path: src/Preg.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/GrepTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/IsMatchAllTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/IsMatchAllWithOffsetsTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/IsMatchTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/IsMatchWithOffsetsTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/MatchAllTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/MatchTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/ReplaceCallbackArrayTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/ReplaceCallbackTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/ReplaceTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/SplitTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/PregTests/SplitWithOffsetsTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/RegexTests/IsMatchTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/RegexTests/MatchAllTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/RegexTests/MatchTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/RegexTests/ReplaceCallbackArrayTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/RegexTests/ReplaceCallbackTest.php

-
message: "#^Regex pattern is invalid\\: No ending matching delimiter '\\}' found$#"
count: 2
path: tests/RegexTests/ReplaceTest.php
1 change: 1 addition & 0 deletions phpstan.neon.dist
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ parameters:

excludePaths:
- tests/PHPStanTests/nsrt/*
- tests/PHPStanTests/fixtures/*

includes:
- extension.neon
Expand Down
142 changes: 142 additions & 0 deletions src/PHPStan/InvalidRegexPatternRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
<?php declare(strict_types = 1);

namespace Composer\Pcre\PHPStan;

use Composer\Pcre\Preg;
use Composer\Pcre\Regex;
use Composer\Pcre\PcreException;
use Nette\Utils\RegexpException;
use Nette\Utils\Strings;
use PhpParser\Node;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Name\FullyQualified;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use function in_array;
use function sprintf;

/**
* Copy of PHPStan's RegularExpressionPatternRule
*
* @implements Rule<StaticCall>
*/
class InvalidRegexPatternRule implements Rule
{
public function getNodeType(): string
{
return StaticCall::class;
}

public function processNode(Node $node, Scope $scope): array
{
$patterns = $this->extractPatterns($node, $scope);

$errors = [];
foreach ($patterns as $pattern) {
$errorMessage = $this->validatePattern($pattern);
if ($errorMessage === null) {
continue;
}

$errors[] = RuleErrorBuilder::message(sprintf('Regex pattern is invalid: %s', $errorMessage))->identifier('regexp.pattern')->build();
}

return $errors;
}

/**
* @return string[]
*/
private function extractPatterns(StaticCall $node, Scope $scope): array
{
if (!$node->class instanceof FullyQualified) {
return [];
}
$isRegex = $node->class->toString() === Regex::class;
$isPreg = $node->class->toString() === Preg::class;
if (!$isRegex && !$isPreg) {
return [];
}
if (!$node->name instanceof Node\Identifier || !Preg::isMatch('{^(match|isMatch|grep|replace|split)}', $node->name->name)) {
return [];
}

$functionName = $node->name->name;
if (!isset($node->getArgs()[0])) {
return [];
}

$patternNode = $node->getArgs()[0]->value;
$patternType = $scope->getType($patternNode);

$patternStrings = [];

foreach ($patternType->getConstantStrings() as $constantStringType) {
if ($functionName === 'replaceCallbackArray') {
continue;
}

$patternStrings[] = $constantStringType->getValue();
}

foreach ($patternType->getConstantArrays() as $constantArrayType) {
if (
in_array($functionName, [
'replace',
'replaceCallback',
], true)
) {
foreach ($constantArrayType->getValueTypes() as $arrayKeyType) {
foreach ($arrayKeyType->getConstantStrings() as $constantString) {
$patternStrings[] = $constantString->getValue();
}
}
}

if ($functionName !== 'replaceCallbackArray') {
continue;
}

foreach ($constantArrayType->getKeyTypes() as $arrayKeyType) {
foreach ($arrayKeyType->getConstantStrings() as $constantString) {
$patternStrings[] = $constantString->getValue();
}
}
}

return $patternStrings;
}

private function validatePattern(string $pattern): ?string
{
try {
$msg = null;
$prev = set_error_handler(function (int $severity, string $message, string $file) use (&$msg): bool {
$msg = preg_replace("#^preg_match(_all)?\\(.*?\\): #", '', $message);

return true;
});

if ($pattern === '') {
return 'Empty string is not a valid regular expression';
}

Preg::match($pattern, '');
if ($msg !== null) {
return $msg;
}
} catch (PcreException $e) {
if ($e->getCode() === PREG_INTERNAL_ERROR && $msg !== null) {
return $msg;
}

return preg_replace('{.*? failed executing ".*": }', '', $e->getMessage());
} finally {
restore_error_handler();
}

return null;
}

}
71 changes: 71 additions & 0 deletions tests/PHPStanTests/InvalidRegexPatternRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

/*
* This file is part of composer/pcre.
*
* (c) Composer <https://github.com/composer>
*
* For the full copyright and license information, please view
* the LICENSE file that was distributed with this source code.
*/

namespace Composer\Pcre\PHPStanTests;

use PHPStan\Testing\RuleTestCase;
use Composer\Pcre\PHPStan\InvalidRegexPatternRule;
use PHPStan\Type\Php\RegexArrayShapeMatcher;

/**
* Run with "vendor/bin/phpunit --testsuite phpstan"
*
* This is excluded by default to avoid side effects with the library tests
*
* @extends RuleTestCase<InvalidRegexPatternRule>
*/
class InvalidRegexPatternRuleTest extends RuleTestCase
{
protected function getRule(): \PHPStan\Rules\Rule
{
return new InvalidRegexPatternRule();
}

public function testRule(): void
{
$missing = PHP_VERSION_ID < 70300 ? ')' : 'closing parenthesis';

$this->analyse([__DIR__ . '/fixtures/invalid-patterns.php'], [
[
'Regex pattern is invalid: Compilation failed: missing '.$missing.' at offset 1',
11,
],
[
'Regex pattern is invalid: Compilation failed: missing '.$missing.' at offset 1',
13,
],
[
'Regex pattern is invalid: Compilation failed: missing '.$missing.' at offset 1',
15,
],
[
'Regex pattern is invalid: Compilation failed: missing '.$missing.' at offset 1',
17,
],
[
'Regex pattern is invalid: Compilation failed: missing '.$missing.' at offset 1',
19,
],
[
'Regex pattern is invalid: Compilation failed: missing '.$missing.' at offset 1',
21,
],
]);
}

public static function getAdditionalConfigFiles(): array
{
return [
'phar://' . __DIR__ . '/../../vendor/phpstan/phpstan/phpstan.phar/conf/bleedingEdge.neon',
__DIR__ . '/../../extension.neon',
];
}
}
2 changes: 1 addition & 1 deletion tests/PHPStanTests/UnsafeStrictGroupsCallRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
*
* @extends RuleTestCase<UnsafeStrictGroupsCallRule>
*/
class UnsafeStrictGruopsCallRuleTest extends RuleTestCase
class UnsafeStrictGroupsCallRuleTest extends RuleTestCase
{
protected function getRule(): \PHPStan\Rules\Rule
{
Expand Down
22 changes: 22 additions & 0 deletions tests/PHPStanTests/fixtures/invalid-patterns.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

namespace PregMatchShapes;

use Composer\Pcre\Preg;
use Composer\Pcre\Regex;
use function PHPStan\Testing\assertType;

function doMatch(string $s): void
{
Preg::match('/(/i', $s, $matches);

Regex::isMatch('/(/i', $s);

Preg::grep('/(/i', [$s]);

Preg::replaceCallback('/(/i', function ($match) { return ''; }, $s);

Preg::replaceCallback(['/(/i', '{}'], function ($match) { return ''; }, $s);

Preg::replaceCallbackArray(['/(/i' => function ($match) { return ''; }], $s);
}
Loading
Loading