Skip to content

Commit

Permalink
Implement RegularExpressionPatternRule
Browse files Browse the repository at this point in the history
  • Loading branch information
enumag committed Dec 16, 2022
1 parent 50543e8 commit 1e32a0f
Show file tree
Hide file tree
Showing 5 changed files with 281 additions and 1 deletion.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
],
"require": {
"php": "^7.2 || ^8.0",
"phpstan/phpstan": "^1.7"
"phpstan/phpstan": "^1.9.4"
},
"conflict": {
"nette/application": "<2.3.0",
Expand Down
6 changes: 6 additions & 0 deletions rules.neon
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,16 @@ parametersSchema:
rules:
- PHPStan\Rule\Nette\DoNotExtendNetteObjectRule

conditionalTags:
PHPStan\Rule\Nette\RegularExpressionPatternRule:
phpstan.rules.rule: %featureToggles.bleedingEdge%

services:
-
class: PHPStan\Rule\Nette\RethrowExceptionRule
arguments:
methods: %methodsThrowingExceptions%
tags:
- phpstan.rules.rule
-
class: PHPStan\Rule\Nette\RegularExpressionPatternRule
109 changes: 109 additions & 0 deletions src/Rule/Nette/RegularExpressionPatternRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rule\Nette;

use Nette\Utils\RegexpException;
use Nette\Utils\Strings;
use PhpParser\Node;
use PhpParser\Node\Expr\StaticCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\Constant\ConstantStringType;
use function in_array;
use function sprintf;
use function strtolower;

/**
* @implements Rule<Node\Expr\StaticCall>
*/
class RegularExpressionPatternRule 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))->build();
}

return $errors;
}

/**
* @return string[]
*/
private function extractPatterns(StaticCall $staticCall, Scope $scope): array
{
if (!$staticCall->class instanceof Node\Name || !$staticCall->name instanceof Node\Identifier) {
return [];
}
$className = $scope->resolveName($staticCall->class);
if ($className !== Strings::class) {
return [];
}
$methodName = strtolower((string) $staticCall->name);
if (
!in_array($methodName, [
'split',
'match',
'matchall',
'replace',
], true)
) {
return [];
}

if (!isset($staticCall->getArgs()[1])) {
return [];
}
$patternNode = $staticCall->getArgs()[1]->value;
$patternType = $scope->getType($patternNode);

$patternStrings = [];

foreach ($patternType->getConstantStrings() as $constantStringType) {
$patternStrings[] = $constantStringType->getValue();
}

foreach ($patternType->getConstantArrays() as $constantArrayType) {
if ($methodName !== 'replace') {
continue;
}

foreach ($constantArrayType->getKeyTypes() as $arrayKeyType) {
if (!$arrayKeyType instanceof ConstantStringType) {
continue;
}

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

return $patternStrings;
}

private function validatePattern(string $pattern): ?string
{
try {
Strings::match('', $pattern);
} catch (RegexpException $e) {
return $e->getMessage();
}

return null;
}

}
132 changes: 132 additions & 0 deletions tests/Rule/Nette/RegularExpressionPatternRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rule\Nette;

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use function sprintf;
use const PHP_VERSION_ID;

/**
* @extends RuleTestCase<RegularExpressionPatternRule>
*/
class RegularExpressionPatternRuleTest extends RuleTestCase
{

protected function getRule(): Rule
{
return new RegularExpressionPatternRule();
}

public function testValidRegexPatternBefore73(): void
{
if (PHP_VERSION_ID >= 70300) {
self::markTestSkipped('This test requires PHP < 7.3.0');
}

$this->analyse(
[__DIR__ . '/data/valid-regex-pattern.php'],
[
[
'Regex pattern is invalid: Delimiter must not be alphanumeric or backslash in pattern: nok',
6,
],
[
'Regex pattern is invalid: Compilation failed: missing ) at offset 1 in pattern: ~(~',
7,
],
[
'Regex pattern is invalid: Delimiter must not be alphanumeric or backslash in pattern: nok',
11,
],
[
'Regex pattern is invalid: Compilation failed: missing ) at offset 1 in pattern: ~(~',
12,
],
[
'Regex pattern is invalid: Delimiter must not be alphanumeric or backslash in pattern: nok',
16,
],
[
'Regex pattern is invalid: Compilation failed: missing ) at offset 1 in pattern: ~(~',
17,
],
[
'Regex pattern is invalid: Delimiter must not be alphanumeric or backslash in pattern: nok',
21,
],
[
'Regex pattern is invalid: Compilation failed: missing ) at offset 1 in pattern: ~(~',
22,
],
[
'Regex pattern is invalid: Delimiter must not be alphanumeric or backslash in pattern: nok',
26,
],
[
'Regex pattern is invalid: Compilation failed: missing ) at offset 1 in pattern: ~(~',
26,
],
]
);
}

public function testValidRegexPatternAfter73(): void
{
if (PHP_VERSION_ID < 70300) {
self::markTestSkipped('This test requires PHP >= 7.3.0');
}

$messagePart = 'alphanumeric or backslash';
if (PHP_VERSION_ID >= 80200) {
$messagePart = 'alphanumeric, backslash, or NUL';
}

$this->analyse(
[__DIR__ . '/data/valid-regex-pattern.php'],
[
[
sprintf('Regex pattern is invalid: Delimiter must not be %s in pattern: nok', $messagePart),
6,
],
[
'Regex pattern is invalid: Compilation failed: missing closing parenthesis at offset 1 in pattern: ~(~',
7,
],
[
sprintf('Regex pattern is invalid: Delimiter must not be %s in pattern: nok', $messagePart),
11,
],
[
'Regex pattern is invalid: Compilation failed: missing closing parenthesis at offset 1 in pattern: ~(~',
12,
],
[
sprintf('Regex pattern is invalid: Delimiter must not be %s in pattern: nok', $messagePart),
16,
],
[
'Regex pattern is invalid: Compilation failed: missing closing parenthesis at offset 1 in pattern: ~(~',
17,
],
[
sprintf('Regex pattern is invalid: Delimiter must not be %s in pattern: nok', $messagePart),
21,
],
[
'Regex pattern is invalid: Compilation failed: missing closing parenthesis at offset 1 in pattern: ~(~',
22,
],
[
sprintf('Regex pattern is invalid: Delimiter must not be %s in pattern: nok', $messagePart),
26,
],
[
'Regex pattern is invalid: Compilation failed: missing closing parenthesis at offset 1 in pattern: ~(~',
26,
],
]
);
}

}
33 changes: 33 additions & 0 deletions tests/Rule/Nette/data/valid-regex-pattern.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

$string = (function (): string {})();

\Nette\Utils\Strings::match('', '~ok~');
\Nette\Utils\Strings::match('', 'nok');
\Nette\Utils\Strings::match('', '~(~');
\Nette\Utils\Strings::match('', $string);

\Nette\Utils\Strings::matchAll('', '~ok~');
\Nette\Utils\Strings::matchAll('', 'nok');
\Nette\Utils\Strings::matchAll('', '~(~');
\Nette\Utils\Strings::matchAll('', $string);

\Nette\Utils\Strings::split('', '~ok~');
\Nette\Utils\Strings::split('', 'nok');
\Nette\Utils\Strings::split('', '~(~');
\Nette\Utils\Strings::split('', $string);

\Nette\Utils\Strings::replace('', '~ok~', '');
\Nette\Utils\Strings::replace('', 'nok', '');
\Nette\Utils\Strings::replace('', '~(~', '');
\Nette\Utils\Strings::replace('', $string, '');
\Nette\Utils\Strings::replace('', ['~ok~', 'nok', '~(~', $string], '');

\Nette\Utils\Strings::replace(
'',
[
'~ok~' => function () {},
'nok' => function () {},
'~(~' => function () {},
]
);

0 comments on commit 1e32a0f

Please sign in to comment.