Skip to content

Commit 9907ec4

Browse files
authored
feat: resolve spark routes filters for custom placeholders (#10263)
* feat: resolve spark routes filters for custom placeholders * check if overwritten builtin sample still matches its regex
1 parent 12888fb commit 9907ec4

11 files changed

Lines changed: 870 additions & 15 deletions

File tree

app/Config/Routing.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,4 +146,14 @@ class Routing extends BaseRouting
146146
* Default: false
147147
*/
148148
public bool $translateUriToCamelCase = true;
149+
150+
/**
151+
* Sample values for the ``spark routes`` command, keyed by placeholder
152+
* name without the ``(:...)`` wrapper. Each value must match the
153+
* placeholder's regular expression and overrides the built-in or
154+
* auto-generated sample for that placeholder.
155+
*
156+
* @var array<string, string>
157+
*/
158+
public array $placeholderSamples = [];
149159
}
Lines changed: 382 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,382 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* This file is part of CodeIgniter 4 framework.
7+
*
8+
* (c) CodeIgniter Foundation <admin@codeigniter.com>
9+
*
10+
* For the full copyright and license information, please view
11+
* the LICENSE file that was distributed with this source code.
12+
*/
13+
14+
namespace CodeIgniter\Commands\Utilities\Routes;
15+
16+
/**
17+
* Generates a sample string that matches a simple regular expression pattern.
18+
*
19+
* Supports the common fragments seen in custom route placeholders: character
20+
* classes (``[A-Z]``, ``[0-9a-f]``), shorthand escapes (``\d``, ``\w``),
21+
* quantifiers (``{n}``, ``{n,m}``, ``+``, ``*``, ``?``), literals, and
22+
* top-level alternation (``a|b``). Anchors (``^``, ``$``) are tolerated and
23+
* ignored. Anything more exotic (lookarounds, backreferences, nested groups
24+
* with quantifiers, POSIX classes) causes the generator to bail out by
25+
* returning ``null``.
26+
*
27+
* The generated candidate is validated against the original pattern with
28+
* ``preg_match`` before being returned, so callers never receive a non-matching
29+
* sample even when the parser is too permissive for a given edge case.
30+
*
31+
* @see \CodeIgniter\Commands\Utilities\Routes\PlaceholderSampleGeneratorTest
32+
*/
33+
final class PlaceholderSampleGenerator
34+
{
35+
private string $pattern;
36+
private int $position = 0;
37+
private int $length = 0;
38+
39+
/**
40+
* Returns a sample string matching the regular expression ``$pattern``, or
41+
* ``null`` when the pattern uses features this generator cannot reverse.
42+
*/
43+
public function generate(string $pattern): ?string
44+
{
45+
$this->pattern = $pattern;
46+
$this->position = 0;
47+
$this->length = strlen($pattern);
48+
49+
try {
50+
$sample = $this->parseAlternation();
51+
} catch (UnsupportedPatternException) {
52+
return null;
53+
}
54+
55+
if ($this->position !== $this->length) {
56+
return null;
57+
}
58+
59+
if (@preg_match('#^(?:' . $pattern . ')$#', $sample) !== 1) {
60+
return null;
61+
}
62+
63+
return $sample;
64+
}
65+
66+
/**
67+
* Parses a (possibly alternated) sequence and returns the first branch.
68+
*/
69+
private function parseAlternation(): string
70+
{
71+
$branch = $this->parseSequence();
72+
73+
if ($this->position < $this->length && $this->pattern[$this->position] === '|') {
74+
// Skip the remaining branches; the first one is enough.
75+
while ($this->position < $this->length && $this->pattern[$this->position] === '|') {
76+
$this->position++;
77+
$this->parseSequence();
78+
}
79+
}
80+
81+
return $branch;
82+
}
83+
84+
private function parseSequence(): string
85+
{
86+
$output = '';
87+
88+
while ($this->position < $this->length) {
89+
$char = $this->pattern[$this->position];
90+
91+
if ($char === '|' || $char === ')') {
92+
break;
93+
}
94+
95+
$atom = $this->parseAtom();
96+
97+
[$minRepeat] = $this->parseQuantifier();
98+
99+
$output .= str_repeat($atom, $minRepeat);
100+
}
101+
102+
return $output;
103+
}
104+
105+
private function parseAtom(): string
106+
{
107+
$char = $this->pattern[$this->position];
108+
109+
if ($char === '^' || $char === '$') {
110+
$this->position++;
111+
112+
return '';
113+
}
114+
115+
if ($char === '.') {
116+
$this->position++;
117+
118+
return 'a';
119+
}
120+
121+
if ($char === '\\') {
122+
return $this->parseEscape();
123+
}
124+
125+
if ($char === '[') {
126+
return $this->parseCharacterClass();
127+
}
128+
129+
if ($char === '(') {
130+
return $this->parseGroup();
131+
}
132+
133+
// Unsupported metacharacters outside of the ones handled above.
134+
if (in_array($char, ['+', '*', '?', '{', '}', ']'], true)) {
135+
throw new UnsupportedPatternException();
136+
}
137+
138+
$this->position++;
139+
140+
return $char;
141+
}
142+
143+
private function parseEscape(): string
144+
{
145+
$this->position++;
146+
147+
if ($this->position >= $this->length) {
148+
throw new UnsupportedPatternException();
149+
}
150+
151+
$char = $this->pattern[$this->position];
152+
$this->position++;
153+
154+
return match ($char) {
155+
'd' => '0',
156+
'D' => 'a',
157+
'w' => 'a',
158+
'W' => '-',
159+
's' => ' ',
160+
'S' => 'a',
161+
default => $char,
162+
};
163+
}
164+
165+
private function parseCharacterClass(): string
166+
{
167+
$this->position++;
168+
$negated = false;
169+
170+
if ($this->position < $this->length && $this->pattern[$this->position] === '^') {
171+
$negated = true;
172+
$this->position++;
173+
}
174+
175+
$allowed = [];
176+
$first = true;
177+
178+
while ($this->position < $this->length && ($this->pattern[$this->position] !== ']' || $first)) {
179+
$first = false;
180+
$char = $this->pattern[$this->position];
181+
182+
if ($char === '\\') {
183+
$this->position++;
184+
185+
if ($this->position >= $this->length) {
186+
throw new UnsupportedPatternException();
187+
}
188+
189+
$escaped = $this->pattern[$this->position];
190+
$this->position++;
191+
192+
$allowed = [...$allowed, ...$this->expandEscapedClassChar($escaped)];
193+
194+
continue;
195+
}
196+
197+
// Range a-z
198+
if (
199+
$this->position < $this->length - 2
200+
&& $this->pattern[$this->position + 1] === '-'
201+
&& $this->pattern[$this->position + 2] !== ']'
202+
) {
203+
$start = $char;
204+
205+
$end = $this->pattern[$this->position + 2];
206+
$this->position += 3;
207+
208+
if (ord($start) > ord($end)) {
209+
throw new UnsupportedPatternException();
210+
}
211+
212+
for ($code = ord($start); $code <= ord($end); $code++) {
213+
$allowed[] = chr($code);
214+
}
215+
216+
continue;
217+
}
218+
219+
$allowed[] = $char;
220+
$this->position++;
221+
}
222+
223+
if ($this->position >= $this->length || $this->pattern[$this->position] !== ']') {
224+
throw new UnsupportedPatternException();
225+
}
226+
227+
$this->position++;
228+
229+
return $negated ? $this->pickNegated($allowed) : $this->pickPreferred($allowed);
230+
}
231+
232+
private function parseGroup(): string
233+
{
234+
$this->position++;
235+
236+
// Skip non-capturing / named group prefixes (?:..), (?P<name>..), (?<name>..).
237+
if (
238+
$this->position < $this->length - 1
239+
&& $this->pattern[$this->position] === '?'
240+
&& $this->pattern[$this->position + 1] === ':'
241+
) {
242+
$this->position += 2;
243+
} elseif (
244+
$this->position < $this->length
245+
&& $this->pattern[$this->position] === '?'
246+
) {
247+
// Lookarounds, named groups with special syntax, atomic groups, etc.
248+
throw new UnsupportedPatternException();
249+
}
250+
251+
$inner = $this->parseAlternation();
252+
253+
if ($this->position >= $this->length || $this->pattern[$this->position] !== ')') {
254+
throw new UnsupportedPatternException();
255+
}
256+
257+
$this->position++;
258+
259+
return $inner;
260+
}
261+
262+
/**
263+
* Reads the quantifier following the current atom and returns ``[min, max]``.
264+
*
265+
* ``max`` is informational only; the generator always emits the minimum
266+
* number of repetitions (with ``*`` and ``?`` normalized to zero).
267+
*
268+
* @return array{0: int, 1: int|null}
269+
*/
270+
private function parseQuantifier(): array
271+
{
272+
if ($this->position >= $this->length) {
273+
return [1, 1];
274+
}
275+
276+
$char = $this->pattern[$this->position];
277+
278+
$bounds = match ($char) {
279+
'?' => [0, 1],
280+
'*' => [0, null],
281+
'+' => [1, null],
282+
default => null,
283+
};
284+
285+
if ($bounds !== null) {
286+
$this->position++;
287+
$this->consumeGreedyModifier();
288+
289+
return $bounds;
290+
}
291+
292+
if ($char === '{') {
293+
return $this->parseBraceQuantifier();
294+
}
295+
296+
return [1, 1];
297+
}
298+
299+
/**
300+
* @return array{0: int, 1: int|null}
301+
*/
302+
private function parseBraceQuantifier(): array
303+
{
304+
$end = strpos($this->pattern, '}', $this->position);
305+
if ($end === false) {
306+
throw new UnsupportedPatternException();
307+
}
308+
309+
$body = substr($this->pattern, $this->position + 1, $end - $this->position - 1);
310+
311+
$this->position = $end + 1;
312+
$this->consumeGreedyModifier();
313+
314+
if (preg_match('/^(\d+)(?:,(\d*))?$/', $body, $matches) !== 1) {
315+
throw new UnsupportedPatternException();
316+
}
317+
318+
$min = (int) $matches[1];
319+
$max = isset($matches[2]) && $matches[2] !== '' ? (int) $matches[2] : null;
320+
321+
return [$min, $max];
322+
}
323+
324+
private function consumeGreedyModifier(): void
325+
{
326+
if (
327+
$this->position < $this->length
328+
&& ($this->pattern[$this->position] === '?' || $this->pattern[$this->position] === '+')
329+
) {
330+
$this->position++;
331+
}
332+
}
333+
334+
/**
335+
* Prefer letters, then digits, then anything printable. Keeps samples
336+
* readable (``[A-Z0-9]`` → ``A``, not ``0``).
337+
*
338+
* @param list<string> $chars
339+
*/
340+
private function pickPreferred(array $chars): string
341+
{
342+
foreach (['/[A-Za-z]/', '/\d/', '/[^\s\/]/'] as $preference) {
343+
foreach ($chars as $c) {
344+
if (preg_match($preference, $c) === 1) {
345+
return $c;
346+
}
347+
}
348+
}
349+
350+
return $chars[0];
351+
}
352+
353+
/**
354+
* @param list<string> $forbidden
355+
*/
356+
private function pickNegated(array $forbidden): string
357+
{
358+
$lookup = array_flip($forbidden);
359+
360+
foreach (['a', 'A', '0', '_', '-'] as $candidate) {
361+
if (! isset($lookup[$candidate])) {
362+
return $candidate;
363+
}
364+
}
365+
366+
throw new UnsupportedPatternException();
367+
}
368+
369+
/**
370+
* @return list<string>
371+
*/
372+
private function expandEscapedClassChar(string $char): array
373+
{
374+
return match ($char) {
375+
'd' => ['0'],
376+
'w' => ['a'],
377+
's' => [' '],
378+
'D', 'W', 'S' => throw new UnsupportedPatternException(),
379+
default => [$char],
380+
};
381+
}
382+
}

0 commit comments

Comments
 (0)