Skip to content
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
67 changes: 35 additions & 32 deletions src/Command/IgnoredRegexValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,15 @@

use Nette\Utils\Strings;
use PHPRegex\Parser\Exception\RegexException;
use PHPRegex\Parser\Node\AlternationNode;
use PHPRegex\Parser\Node\AnchorNode;
use PHPRegex\Parser\Node\NodeInterface;
use PHPRegex\Parser\Node\SequenceNode;
use PHPStan\PhpDoc\TypeStringResolver;
use PHPStan\PhpDocParser\Parser\ParserException;
use PHPStan\ShouldNotHappenException;
use PHPStan\Type\ObjectType;
use PHPStan\Type\Regex\PHPRegexTreeNodeBuilder;
use PHPStan\Type\Regex\RegexTreeNode;
use PHPStan\Type\Regex\PHPRegexReader;
use PHPStan\Type\VerbosityLevel;
use function count;
use function strrpos;
Expand All @@ -20,7 +23,7 @@ final class IgnoredRegexValidator

public function __construct(
private TypeStringResolver $typeStringResolver,
private PHPRegexTreeNodeBuilder $treeNodeBuilder,
private PHPRegexReader $reader,
)
{
}
Expand All @@ -31,7 +34,7 @@ public function validate(string $regex): IgnoredRegexValidatorResult
$regex = $this->removeDelimiters($regex);

try {
$ast = $this->treeNodeBuilder->parse($regex, '', $delimiter);
$root = $this->reader->read($regex, '', $delimiter);
} catch (RegexException) {
return new IgnoredRegexValidatorResult(ignoredTypes: [], anchorsInTheMiddle: false, allErrorsIgnored: false);
}
Expand All @@ -44,29 +47,24 @@ public function validate(string $regex): IgnoredRegexValidatorResult
}

return new IgnoredRegexValidatorResult(
ignoredTypes: $this->getIgnoredTypes($ast),
anchorsInTheMiddle: $this->hasAnchorsInTheMiddle($ast),
ignoredTypes: $this->getIgnoredTypes($root->pattern, $root->source ?? $regex),
anchorsInTheMiddle: $this->hasAnchorsInTheMiddle($root->pattern),
allErrorsIgnored: false,
);
}

/**
* @return array<string, string>
*/
private function getIgnoredTypes(RegexTreeNode $ast): array
private function getIgnoredTypes(NodeInterface $ast, string $source): array
{
$alternation = $ast->getChild(0);
if ($alternation === null) {
return [];
}

if ($alternation->getId() !== '#alternation') {
if (!$ast instanceof AlternationNode) {
return [];
}

$types = [];
foreach ($alternation->getChildren() as $child) {
$text = $this->getText($child);
foreach ($ast->alternatives as $alternative) {
$text = $this->getText($alternative, $source);
if ($text === null) {
continue;
}
Expand Down Expand Up @@ -109,16 +107,17 @@ private function removeDelimiters(string $regex): string
return substr($regex, 1, $endDelimiterPosition - 1);
}

private function getText(RegexTreeNode $treeNode): ?string
/**
* The text of a branch as it was written, escapes included: the tree
* decodes them, so each leaf is sliced from the source between its
* positions, whatever the leaf spells.
*/
private function getText(NodeInterface $node, string $source): ?string
{
if ($treeNode->getId() === 'token') {
return $treeNode->getValueValue();
}

if ($treeNode->getId() === '#concatenation') {
if ($node instanceof SequenceNode) {
$fullText = '';
foreach ($treeNode->getChildren() as $child) {
$text = $this->getText($child);
foreach ($node->children as $child) {
$text = $this->getText($child, $source);
if ($text === null) {
continue;
}
Expand All @@ -133,22 +132,26 @@ private function getText(RegexTreeNode $treeNode): ?string
return $fullText;
}

return null;
if ($node->getChildren() !== []) {
return null;
}

return substr($source, $node->getStartPosition(), $node->getEndPosition() - $node->getStartPosition());
}

private function hasAnchorsInTheMiddle(RegexTreeNode $ast): bool
private function hasAnchorsInTheMiddle(NodeInterface $ast): bool
{
if ($ast->getId() === 'token') {
$valueArray = $ast->getValue();

return $valueArray['token'] === 'anchor' && $valueArray['value'] === '$';
if ($ast instanceof AnchorNode) {
return $ast->value === '$';
}
$childrenCount = count($ast->getChildren());
foreach ($ast->getChildren() as $i => $child) {

$children = $ast instanceof SequenceNode ? $ast->children : $ast->getChildren();
$childrenCount = count($children);
foreach ($children as $i => $child) {
$has = $this->hasAnchorsInTheMiddle($child);
if (
$has
&& ($ast->getId() !== '#concatenation' || $i !== $childrenCount - 1)
&& (!$ast instanceof SequenceNode || $i !== $childrenCount - 1)
) {
return true;
}
Expand Down
4 changes: 2 additions & 2 deletions src/DependencyInjection/ValidateIgnoredErrorsExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
use PHPStan\Analyser\NameScope;
use PHPStan\Command\IgnoredRegexValidator;
use PHPStan\File\FileExcluder;
use PHPStan\Type\Regex\PHPRegexTreeNodeBuilder;
use PHPStan\Type\Regex\PHPRegexReader;
use PHPStan\Php\ComposerPhpVersionFactory;
use PHPStan\Php\ConfiguredPhpVersionRangeHelper;
use PHPStan\Php\PcreVersion;
Expand Down Expand Up @@ -129,7 +129,7 @@ public function resolveTypeAlias(string $aliasName, NameScope $nameScope): ?Type
// ignoreErrors patterns are matched by the running PHPStan
// itself, so their read is judged for the running PHP and
// its engine
new PHPRegexTreeNodeBuilder(
new PHPRegexReader(
new PhpVersion(PHP_VERSION_ID),
PcreVersion::fromRuntime(),
),
Expand Down
64 changes: 64 additions & 0 deletions src/Type/Regex/PHPRegexReader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php declare(strict_types = 1);

namespace PHPStan\Type\Regex;

use PHPRegex\Parser\Exception\RegexException;
use PHPRegex\Parser\Node\RegexNode;
use PHPRegex\Parser\PcreTarget;
use PHPRegex\Parser\RegexParser;
use PHPStan\DependencyInjection\AutowiredService;
use PHPStan\Php\PcreVersion;
use PHPStan\Php\PhpVersion;

/**
* Reads a pattern body into the PHPRegex AST, judged for the analysed
* project: the analysed PHP and its bundled engine, narrowed below it when
* the config or composer.json names an older one — a pin never raises the
* judged engine above what that PHP can run. A padded \x{ 41 } escape is
* the letter A on the engines PHP 8.x ships with and the letter x then
* text on PHP 7.4's, and the trees follow that verdict.
*/
#[AutowiredService]
final class PHPRegexReader
{

private ?RegexParser $parser = null;

public function __construct(
private PhpVersion $phpVersion,
private PcreVersion $pcreVersion,
)
{
}

/**
* @param string $rawRegex the pattern body, delimiters and modifiers stripped
* @param string $modifiers the pattern modifiers as RegexGroupParser read them
* @param string $delimiter the opening delimiter of the pattern as written
*
* @throws RegexException when the pattern body cannot be read
*/
public function read(string $rawRegex, string $modifiers, string $delimiter): RegexNode
{
$this->parser ??= RegexParser::create($this->targetOptions());

return $this->parser->parsePattern($rawRegex, $modifiers, $delimiter);
}

/**
* @return array<string, int|string>
*/
private function targetOptions(): array
{
$phpVersionId = $this->phpVersion->getVersionId();
$bundled = PcreTarget::bundledWith($phpVersionId);

$options = ['php_version' => $phpVersionId];
if (!$this->pcreVersion->atLeast($bundled->pcreVersion)) {
$options['pcre_version'] = $this->pcreVersion->getVersionString();
}

return $options;
}

}
Loading