Repository navigation
Expand file tree
/
Copy pathPatternVerdicts.php
More file actions
224 lines (197 loc) · 8.14 KB
/
Copy pathPatternVerdicts.php
File metadata and controls
224 lines (197 loc) · 8.14 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
<?php
declare(strict_types=1);
/*
* This file is part of the PHPRegex package.
*
* (c) Younes ENNAJI <younes.ennaji.pro@gmail.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace PHPRegex\Psalm;
use PHPRegex\Parser\Analysis\CaptureShape;
use PHPRegex\Parser\Analysis\CaptureShapeAnalyzer;
use PHPRegex\Parser\Engine\PcreEngine;
use PHPRegex\Parser\Exception\ExceptionInterface;
use PHPRegex\Parser\Node\AlternationNode;
use PHPRegex\Parser\Node\AnchorNode;
use PHPRegex\Parser\Node\AssertionNode;
use PHPRegex\Parser\Node\CharTypeNode;
use PHPRegex\Parser\Node\GroupNode;
use PHPRegex\Parser\Node\GroupType;
use PHPRegex\Parser\Node\KeepNode;
use PHPRegex\Parser\Node\NodeInterface;
use PHPRegex\Parser\Node\PosixClassNode;
use PHPRegex\Parser\Node\RegexNode;
use PHPRegex\Parser\Node\SequenceNode;
use PHPRegex\Parser\RegexParser;
use PHPRegex\Parser\Validation\ValidationErrorCategory;
use PHPRegex\Parser\Validation\ValidationResult;
/**
* What the library says of a pattern for a target, read once per process:
* the error the target reports, or the shape of what it captures and the
* facts the types read from its tree.
*
* @internal
*
* @phpstan-type PatternVerdict array{error: ?string, shape: ?CaptureShape, noAutoCapture: bool, keepsOut: bool, fewStrings: bool}
*/
final class PatternVerdicts
{
/**
* What the library says of a pattern, read once per process.
*
* @var array<string, PatternVerdict> by target and pattern
*/
private static array $verdicts = [];
/**
* The error a version judged reports for a pattern, null when it
* accepts it, read once per process for a pattern no verdict was read
* for.
*
* @var array<string, array{?string}> by target and pattern
*/
private static array $errors = [];
/**
* @return PatternVerdict
*/
public static function verdict(RegexParser $parser, string $pattern): array
{
return self::$verdicts[CallTarget::key($parser)."\0".$pattern] ??= self::judge($parser, $pattern);
}
/**
* The error the first of the versions judged that refuses the pattern
* reports, in their order, the floor first; null when each accepts it.
* A later version is validated only: its verdict is the error alone.
*
* @param list<RegexParser> $parsers
*/
public static function firstError(array $parsers, string $pattern): ?string
{
foreach ($parsers as $parser) {
$key = CallTarget::key($parser)."\0".$pattern;
$error = isset(self::$verdicts[$key]) ? self::$verdicts[$key]['error'] : (self::$errors[$key] ??= [self::refusal($parser, $parser->validate($pattern))])[0];
if (null !== $error) {
return $error;
}
}
return null;
}
/**
* @return PatternVerdict
*/
private static function judge(RegexParser $parser, string $pattern): array
{
try {
$regex = $parser->parse($pattern);
} catch (ExceptionInterface) {
$regex = null;
}
$validation = $parser->validate($pattern);
if (ValidationErrorCategory::Limit === $validation->category || !$validation->isValid) {
return ['error' => self::refusal($parser, $validation), 'shape' => null, 'noAutoCapture' => false, 'keepsOut' => false, 'fewStrings' => false];
}
// A pattern the running engine does not compile leaves $matches as it was: nothing is asserted on it.
if (null === $regex || null !== (new PcreEngine())->compile($pattern)) {
return ['error' => null, 'shape' => null, 'noAutoCapture' => false, 'keepsOut' => false, 'fewStrings' => false]; // Only on a running PCRE2 older than the target's, as the 10.40 and 10.42 CI images.
}
// a version condition reads as the target's release reads it
$shape = (new CaptureShapeAnalyzer($parser->target()))->analyze($regex);
return [
'error' => null,
'shape' => $shape,
'noAutoCapture' => str_contains($regex->flags, 'n'),
'keepsOut' => self::keepsOut($regex),
// Only a match of bounded length, from the start of the subject, may leave a few strings to read.
'fewStrings' => null !== $shape->whole->maxLength && self::startsAtTheStart($regex->pattern) && (str_contains($regex->flags, 'u') || !self::readsTheLocale($regex)),
];
}
/**
* Whether every match starts at the start of the subject, as far as the
* tree shows it: ^ or \A first in each branch.
*/
private static function startsAtTheStart(NodeInterface $node): bool
{
if ($node instanceof AlternationNode) {
foreach ($node->alternatives as $alternative) {
if (!self::startsAtTheStart($alternative)) {
return false;
}
}
return [] !== $node->alternatives;
}
return match (true) {
$node instanceof AnchorNode => '^' === $node->value,
$node instanceof AssertionNode => 'A' === $node->value,
$node instanceof SequenceNode => [] !== $node->children && self::startsAtTheStart($node->children[0]),
$node instanceof GroupNode => \in_array($node->type, [GroupType::Capturing, GroupType::Named, GroupType::NonCapturing, GroupType::Atomic], true) && self::startsAtTheStart($node->child),
default => false,
};
}
/**
* Whether, without /u, the pattern reads PCRE's character tables, which
* PHP builds from the locale setlocale() sets: a caseless part, a
* shorthand class, a POSIX class or a word boundary. After
* setlocale(LC_CTYPE, 'fr_FR.ISO8859-1'), preg_match('/^\w$/', "\xE9")
* returns 1, where it returns 0 under the C locale the solver reads.
*/
private static function readsTheLocale(NodeInterface $node): bool
{
if ($node instanceof RegexNode && str_contains($node->flags, 'i')) {
return true;
}
if ($node instanceof CharTypeNode || $node instanceof PosixClassNode
|| ($node instanceof AssertionNode && \in_array($node->value, ['b', 'B'], true))
|| ($node instanceof GroupNode && str_contains((string) $node->flags, 'i'))) {
return true;
}
foreach ($node->getChildren() as $child) {
if (self::readsTheLocale($child)) {
return true;
}
}
return false;
}
/**
* Whether a \K stands anywhere in the tree.
*/
private static function keepsOut(NodeInterface $node): bool
{
if ($node instanceof KeepNode) {
return true;
}
foreach ($node->getChildren() as $child) {
if (self::keepsOut($child)) {
return true;
}
}
return false;
}
/**
* The error a target reports for a pattern it refuses, null when it
* accepts it. The library's own limits, not PCRE's, report nothing: it
* cannot judge the pattern, which PCRE may well accept.
*/
private static function refusal(RegexParser $parser, ValidationResult $validation): ?string
{
return ValidationErrorCategory::Limit === $validation->category || $validation->isValid ? null : self::message($parser, $validation);
}
/**
* "Regex pattern is invalid for PHP 8.4 with PCRE2 10.44: <the
* library's error> (offset 3).", as the PHPStan extension words it.
*/
private static function message(RegexParser $parser, ValidationResult $validation): string
{
$target = $parser->target();
$reason = rtrim(explode("\n", $validation->error ?? 'Invalid regex.')[0], '.');
return \sprintf(
'Regex pattern is invalid for PHP %d.%d with PCRE2 %s%s: %s%s.',
intdiv($target->phpVersionId, 10000),
intdiv($target->phpVersionId, 100) % 100,
$target->pcreVersion,
'C' === $target->locale ? '' : ' under the locale '.$target->locale,
$reason,
null === $validation->offset ? '' : ' (offset '.$validation->offset.')',
);
}
}