attackmap-analyzer-php-laminas 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,623 @@
1
+ """Structural Laminas router and Mezzio route extraction (mlaify/attackmap-analyzer-php-laminas#2).
2
+
3
+ Laminas router config is a tree::
4
+
5
+ 'router' => ['routes' => [
6
+ 'admin' => [
7
+ 'type' => Literal::class,
8
+ 'options' => ['route' => '/admin', 'defaults' => [...]],
9
+ 'may_terminate' => true,
10
+ 'child_routes' => [
11
+ 'user' => ['type' => Segment::class, 'options' => ['route' => '/users[/:id]']],
12
+ 'save' => ['type' => Method::class, 'options' => ['verb' => 'post']],
13
+ ],
14
+ ],
15
+ ]]
16
+
17
+ :func:`router_routes` walks it with :mod:`.phparray` and returns one record per
18
+ reachable path:
19
+
20
+ - **child_routes are joined** onto their parent's path (`/admin/users`). A
21
+ parent is itself reachable only if it has no children or sets
22
+ `'may_terminate' => true`.
23
+ - **Method routes** (`Method::class`, `'verb' => 'post'`, comma lists) put
24
+ their HTTP method(s) on every form of the parent's path instead of adding
25
+ a path.
26
+ - **Segment optional parts** (`[/:action[/:id]]`) are expanded progressively:
27
+ the minimal form, then one more optional group opened at a time in source
28
+ order (`/users`, `/users/:id`). That is at most ``MAX_FORMS`` paths per route,
29
+ so `/:controller[/:action[/:id]]` yields 3 paths, not every combination.
30
+ A child path joins its parent's *maximal* form, because Laminas' parent
31
+ part consumes greedily (`/h[/:action]` + `/x` is `/h/:action/x`), unless
32
+ the child can match nothing (`[/:id]`), which keeps every parent form.
33
+ Method children cover every parent form. `:param{delims}` and
34
+ translated `{literal}` parts are reduced to `:param` and `literal`.
35
+ - `Hostname`/`Scheme` routes contribute no path; `Regex` routes use their
36
+ `spec` (`/install`, with `%param%` as `:param`). Part, Wildcard, Query and
37
+ console routes are skipped.
38
+
39
+ :func:`mezzio_routes` reads Mezzio/Expressive `$app->get('/x', ...)`,
40
+ `->post/put/patch/delete/any` and `$app->route('/x', $mw, ['GET', 'POST'])`.
41
+ FastRoute placeholders keep their name (`{id:\\d+}` becomes `{id}`) and
42
+ optional `[...]` parts expand the same way.
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ import bisect
48
+ import re
49
+ from dataclasses import dataclass, field
50
+
51
+ from .phparray import (
52
+ ARROW,
53
+ LPAREN,
54
+ OP,
55
+ OPEN,
56
+ WORD,
57
+ PhpArray,
58
+ PhpExpr,
59
+ PhpString,
60
+ PhpValue,
61
+ STR,
62
+ Token,
63
+ TokenList,
64
+ is_true,
65
+ keyed_array_spans,
66
+ parse_array_span,
67
+ parse_call_args,
68
+ )
69
+
70
+ MAX_FORMS = 4
71
+ MAX_OPTIONAL_NESTING = 16
72
+ MAX_ROUTE_DEPTH = 32
73
+ MAX_ROUTES_PER_FILE = 5_000
74
+
75
+ _HTTP_METHODS = frozenset({"GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "TRACE", "CONNECT"})
76
+ _PATH_KINDS = frozenset({"literal", "segment", None})
77
+ # `:param{-/}` delimiter spec → `:param`; translated `{literal}` → `literal`.
78
+ _SEGMENT_DELIMS = re.compile(r"(:[A-Za-z0-9_-]++)\{[^{}]*\}")
79
+ _SEGMENT_TRANSLATE = re.compile(r"\{([^{}:]*)\}")
80
+ _REGEX_SPEC_PARAM = re.compile(r"%([A-Za-z0-9_-]++)%")
81
+ _MULTI_SLASH = re.compile(r"//++")
82
+ _MEZZIO_VERBS = {"get": "GET", "post": "POST", "put": "PUT", "patch": "PATCH", "delete": "DELETE", "any": "ANY"}
83
+
84
+
85
+ @dataclass
86
+ class LaminasRoute:
87
+ path: str
88
+ method: str
89
+ offset: int
90
+ name: str
91
+ template: str
92
+ defaults: dict[str, PhpValue] = field(default_factory=dict)
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Optional-part expansion
97
+ # ---------------------------------------------------------------------------
98
+
99
+
100
+ def _parse_optional(template: str) -> list:
101
+ """Parse ``template`` into a list of str and nested lists (optional groups)."""
102
+ root: list = []
103
+ stack: list[list] = [root]
104
+ buf: list[str] = []
105
+ for ch in template:
106
+ if ch == "[" and len(stack) <= MAX_OPTIONAL_NESTING:
107
+ stack[-1].append("".join(buf))
108
+ buf = []
109
+ group: list = []
110
+ stack[-1].append(group)
111
+ stack.append(group)
112
+ elif ch == "]" and len(stack) > 1:
113
+ stack[-1].append("".join(buf))
114
+ buf = []
115
+ stack.pop()
116
+ else:
117
+ buf.append(ch)
118
+ stack[-1].append("".join(buf))
119
+ return root
120
+
121
+
122
+ def _groups_preorder(node: list, out: list[int]) -> None:
123
+ for part in node:
124
+ if isinstance(part, list):
125
+ out.append(id(part))
126
+ _groups_preorder(part, out)
127
+
128
+
129
+ def _render(node: list, opened: set[int]) -> str:
130
+ out: list[str] = []
131
+ for part in node:
132
+ if isinstance(part, list):
133
+ if id(part) in opened:
134
+ out.append(_render(part, opened))
135
+ else:
136
+ out.append(part)
137
+ return "".join(out)
138
+
139
+
140
+ def expand_optional(template: str, max_forms: int = MAX_FORMS) -> list[str]:
141
+ """Minimal form first, then one more optional group opened at a time.
142
+
143
+ ``/:controller[/:action[/:id]]`` → ``/:controller``, ``/:controller/:action``,
144
+ ``/:controller/:action/:id``. With more than ``max_forms - 1`` groups the
145
+ last form is the maximal one. Unbalanced brackets never raise: a stray `]`
146
+ is literal and an unclosed `[` closes at the end.
147
+ """
148
+ if "[" not in template:
149
+ return [template]
150
+ tree = _parse_optional(template)
151
+ order: list[int] = []
152
+ _groups_preorder(tree, order)
153
+ counts = list(range(len(order) + 1))
154
+ if len(counts) > max_forms:
155
+ counts = counts[: max_forms - 1] + [len(order)]
156
+ forms: list[str] = []
157
+ for k in counts:
158
+ form = _render(tree, set(order[:k]))
159
+ if form not in forms:
160
+ forms.append(form)
161
+ return forms
162
+
163
+
164
+ def maximal_form(template: str) -> str:
165
+ """``template`` with every optional group filled in."""
166
+ if "[" not in template:
167
+ return template
168
+ tree = _parse_optional(template)
169
+ order: list[int] = []
170
+ _groups_preorder(tree, order)
171
+ return _render(tree, set(order))
172
+
173
+
174
+ def join_child(parent_template: str, piece: str) -> str:
175
+ """The template a child route's ``piece`` makes under its parent.
176
+
177
+ Laminas' `Part::match` lets the parent consume greedily and never backs
178
+ off, so a child is reached only after the parent's optional parts are
179
+ filled: `/h[/:action]` + `/x` is `/h/:action/x` (`/h/x` sets action=x and
180
+ the child never matches). A child that can itself match nothing
181
+ (`[/:id]`) also leaves every parent form reachable, so it extends the
182
+ parent template as is: `/h[/:action][/:id]`."""
183
+ if minimal_form(piece) == "":
184
+ return parent_template + piece
185
+ return maximal_form(parent_template) + piece
186
+
187
+
188
+ def minimal_form(template: str) -> str:
189
+ """``template`` with every optional group left out."""
190
+ return _render(_parse_optional(template), set()) if "[" in template else template
191
+
192
+
193
+ def normalize_path(path: str) -> str:
194
+ """Collapse `//`, drop a trailing `/` (except the root) and ensure a leading `/`."""
195
+ path = _MULTI_SLASH.sub("/", path)
196
+ if not path.startswith("/"):
197
+ path = "/" + path
198
+ if len(path) > 1 and path.endswith("/"):
199
+ path = path.rstrip("/") or "/"
200
+ return path
201
+
202
+
203
+ def _segment_text(piece: str) -> str:
204
+ piece = _SEGMENT_DELIMS.sub(r"\1", piece)
205
+ return _SEGMENT_TRANSLATE.sub(r"\1", piece)
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Laminas router config
210
+ # ---------------------------------------------------------------------------
211
+
212
+
213
+ def route_type(value: PhpValue | None) -> str | None:
214
+ """`Segment::class`, `'Laminas\\Router\\Http\\Literal'`, `'segment'` → `segment`."""
215
+ if isinstance(value, PhpString):
216
+ text = value.value
217
+ elif isinstance(value, PhpExpr):
218
+ text = value.text
219
+ else:
220
+ return None
221
+ text = text.strip()
222
+ if text.endswith("::class"):
223
+ text = text[: -len("::class")]
224
+ short = text.rsplit("\\", 1)[-1].strip().lower()
225
+ return short or None
226
+
227
+
228
+ @dataclass
229
+ class RouteNode:
230
+ """What a named route passes to its children: its template (optional
231
+ parts included; see :func:`join_child`), the merged defaults and an
232
+ inherited Method verb. ``template`` None means ``prefix``."""
233
+
234
+ prefix: str
235
+ defaults: dict[str, PhpValue] = field(default_factory=dict)
236
+ method: str | None = None
237
+ template: str | None = None
238
+
239
+
240
+ def route_trees(tokens: list[Token]) -> list[PhpArray]:
241
+ """The route maps in a file: every `'router' => ['routes' => [...]]`, or
242
+ else a file that just `return`s a route map.
243
+
244
+ `'router' => $router` and `'routes' => $routes` follow a same-file
245
+ `$routes = [...]` assignment (the last one before the use, else the first
246
+ after it)."""
247
+ trees: list[PhpArray] = []
248
+ variables = _VariableArrays(tokens)
249
+ seen: set[int] = set() # the same `$router` used twice is walked once
250
+ alive: list[PhpArray] = []
251
+ i = 0
252
+ n = len(tokens)
253
+ while i < n - 2:
254
+ tok = tokens[i]
255
+ if tok.kind != STR or tok.text != "router" or tokens[i + 1].kind != ARROW:
256
+ i += 1
257
+ continue
258
+ router, end = variables.value_at(i + 2)
259
+ # Continue after a literal router array: a nested `'router' => [`
260
+ # was parsed with it, and re-parsing would be quadratic.
261
+ i = max(end, i + 1)
262
+ if router is None or id(router) in seen:
263
+ continue
264
+ seen.add(id(router))
265
+ alive.append(router) # keep it alive so its id() isn't reused
266
+ routes = router.get("routes")
267
+ if isinstance(routes, PhpExpr):
268
+ routes = variables.resolve(routes)
269
+ if isinstance(routes, PhpArray) and id(routes) not in seen:
270
+ seen.add(id(routes))
271
+ trees.append(routes)
272
+ return trees or _returned_route_maps(tokens)
273
+
274
+
275
+ class _VariableArrays:
276
+ """`$name = [...]` assignments in a file, parsed on demand."""
277
+
278
+ MAX_PARSES = 64
279
+
280
+ def __init__(self, tokens: list[Token]) -> None:
281
+ self.tokens = tokens
282
+ self.assignments: dict[str, list[int]] = {}
283
+ self.cache: dict[int, PhpArray | None] = {}
284
+ self.parses = 0
285
+ n = len(tokens)
286
+ for i in range(n - 2):
287
+ tok = tokens[i]
288
+ if (
289
+ tok.kind == WORD
290
+ and tok.text.startswith("$")
291
+ and tokens[i + 1].kind == OP
292
+ and tokens[i + 1].text == "="
293
+ and tokens[i + 2].kind == OPEN
294
+ ):
295
+ self.assignments.setdefault(tok.text, []).append(i + 2)
296
+
297
+ def _array(self, index: int) -> PhpArray | None:
298
+ # Each assignment is parsed at most once, and at most MAX_PARSES are,
299
+ # so hostile files can't make variable lookups quadratic.
300
+ if index not in self.cache:
301
+ if self.parses >= self.MAX_PARSES:
302
+ return None
303
+ self.parses += 1
304
+ array, _ = parse_array_span(self.tokens, index)
305
+ self.cache[index] = array
306
+ return self.cache[index]
307
+
308
+ def value_at(self, index: int) -> tuple[PhpArray | None, int]:
309
+ """The array literal at token ``index`` (and the index after it), or
310
+ the array a `$var` there holds (and ``index + 1``)."""
311
+ if index >= len(self.tokens):
312
+ return None, index
313
+ tok = self.tokens[index]
314
+ if tok.kind == OPEN:
315
+ return parse_array_span(self.tokens, index)
316
+ if tok.kind == WORD and tok.text.startswith("$"):
317
+ return self._lookup(tok.text, tok.offset), index + 1
318
+ return None, index + 1
319
+
320
+ def resolve(self, value: PhpExpr) -> PhpArray | None:
321
+ name = value.text.strip()
322
+ if not name.startswith("$") or " " in name:
323
+ return None
324
+ return self._lookup(name, value.offset)
325
+
326
+ def _lookup(self, name: str, offset: int) -> PhpArray | None:
327
+ positions = self.assignments.get(name)
328
+ if not positions:
329
+ return None
330
+ # The last assignment before the use, else the first one: token
331
+ # indices ascend with offsets, so bisect on both.
332
+ use = bisect.bisect_left(self.tokens, offset, key=lambda tok: tok.offset)
333
+ at = bisect.bisect_left(positions, use)
334
+ return self._array(positions[at - 1] if at > 0 else positions[0])
335
+
336
+
337
+ def router_routes(
338
+ trees: list[PhpArray],
339
+ known: dict[str, RouteNode] | None = None,
340
+ index: dict[str, RouteNode] | None = None,
341
+ consumed: set[int] | None = None,
342
+ ) -> list[LaminasRoute]:
343
+ """Routes from a file's route maps (see :func:`route_trees`).
344
+
345
+ Laminas merges every module's config, so a module can add children to a
346
+ route defined elsewhere (`'admin' => ['child_routes' => [...]]` with no
347
+ type or route of its own). Such a stub is placed using ``known`` (full
348
+ route name → :class:`RouteNode`); without an entry its children are
349
+ skipped rather than reported as top-level paths. ``index``, when given,
350
+ collects a :class:`RouteNode` for every named route walked, to build
351
+ ``known`` across files. ``consumed``, when given, collects the source
352
+ offsets of the `'route'`/`'spec'`/`'verb'` strings the walk used, so a
353
+ caller can tell which literals it never reached.
354
+ """
355
+ out: list[LaminasRoute] = []
356
+ ctx = _WalkContext(out, known or {}, index, consumed)
357
+ for routes in trees:
358
+ _walk(routes, "", "", {}, None, 0, ctx)
359
+ return out
360
+
361
+
362
+ @dataclass
363
+ class _WalkContext:
364
+ out: list[LaminasRoute]
365
+ known: dict[str, RouteNode]
366
+ index: dict[str, RouteNode] | None
367
+ consumed: set[int] | None = None
368
+
369
+
370
+ def _returned_route_maps(tokens: list[Token]) -> list[PhpArray]:
371
+ """`return ['home' => ['type' => ..., 'options' => ['route' => ...]]];` in a
372
+ file `require`d as a router's routes."""
373
+ maps: list[PhpArray] = []
374
+ i = 0
375
+ while i < len(tokens) - 1:
376
+ if tokens[i].kind == WORD and tokens[i].text.lower() == "return" and tokens[i + 1].kind == OPEN:
377
+ array, end = parse_array_span(tokens, i + 1)
378
+ if array is not None and any(_looks_like_route_spec(v) for _, v in array.entries()):
379
+ maps.append(array)
380
+ # Skip the parsed array: re-parsing a nested `return [` would be quadratic.
381
+ i = max(end, i + 1)
382
+ continue
383
+ i += 1
384
+ return maps
385
+
386
+
387
+ def _looks_like_route_spec(value: PhpValue) -> bool:
388
+ if not isinstance(value, PhpArray):
389
+ return False
390
+ options = value.get("options")
391
+ return (
392
+ value.get("type") is not None
393
+ and isinstance(options, PhpArray)
394
+ and isinstance(options.get("route"), PhpString)
395
+ )
396
+
397
+
398
+ def _walk(
399
+ routes: PhpArray,
400
+ parent_template: str,
401
+ prefix_name: str,
402
+ defaults: dict[str, PhpValue],
403
+ method: str | None,
404
+ depth: int,
405
+ ctx: _WalkContext,
406
+ ) -> None:
407
+ """``parent_template`` keeps the parent's optional parts: child paths
408
+ join it with :func:`join_child`, Method children expand all its forms."""
409
+ if depth > MAX_ROUTE_DEPTH:
410
+ return
411
+ out = ctx.out
412
+ for name, spec in routes.entries():
413
+ if len(out) >= MAX_ROUTES_PER_FILE:
414
+ return
415
+ if not isinstance(spec, PhpArray):
416
+ continue
417
+ kind = route_type(spec.get("type"))
418
+ options = spec.get("options")
419
+ if not isinstance(options, PhpArray):
420
+ options = PhpArray(offset=spec.offset)
421
+ own_defaults = options.get("defaults")
422
+ full_name = f"{prefix_name}/{name}" if prefix_name else name
423
+ children = spec.get("child_routes")
424
+ terminates = not isinstance(children, PhpArray) or is_true(spec.get("may_terminate"))
425
+
426
+ if kind is None and options.get("route") is None and isinstance(children, PhpArray):
427
+ # A stub extending a route defined in another module's config.
428
+ base = ctx.known.get(full_name)
429
+ if base is None:
430
+ continue
431
+ merged = dict(base.defaults)
432
+ if isinstance(own_defaults, PhpArray):
433
+ merged.update(own_defaults.entries())
434
+ base_template = base.template if base.template is not None else base.prefix
435
+ _walk(children, base_template, full_name, merged, base.method, depth + 1, ctx)
436
+ continue
437
+
438
+ merged = dict(defaults)
439
+ if isinstance(own_defaults, PhpArray):
440
+ merged.update(own_defaults.entries())
441
+
442
+ methods: list[str] | None = [method or "ANY"]
443
+ template = parent_template
444
+ offset = spec.offset
445
+ literal: PhpValue | None = None
446
+ if kind == "method":
447
+ literal = options.get("verb")
448
+ verbs = _verbs(literal)
449
+ if not verbs:
450
+ continue
451
+ # The verb applies to every form of the parent: `/user[/:id]`
452
+ # with a `delete` child is `DELETE /user` and `DELETE /user/:id`.
453
+ methods = verbs
454
+ offset = literal.offset if literal is not None else offset
455
+ elif kind in ("hostname", "scheme"):
456
+ methods = None # matches the host/scheme, not a path
457
+ elif kind == "regex":
458
+ literal = options.get("spec")
459
+ if not isinstance(literal, PhpString) or not literal.value.startswith("/"):
460
+ continue
461
+ template = join_child(parent_template, _REGEX_SPEC_PARAM.sub(r":\1", literal.value))
462
+ offset = literal.offset
463
+ elif kind in _PATH_KINDS:
464
+ literal = options.get("route")
465
+ if not isinstance(literal, PhpString) or not literal.value.startswith(("/", "[")):
466
+ continue
467
+ template = join_child(parent_template, _segment_text(literal.value))
468
+ offset = literal.offset
469
+ else:
470
+ continue # Part, Wildcard, Query, console `simple`, custom types
471
+ if ctx.consumed is not None and literal is not None:
472
+ ctx.consumed.add(literal.offset)
473
+
474
+ if terminates and methods is not None:
475
+ for form in expand_optional(template):
476
+ path = normalize_path(form)
477
+ for verb in methods:
478
+ out.append(LaminasRoute(path, verb, offset, full_name, template, merged))
479
+ child_method = methods[0] if kind == "method" and methods and len(methods) == 1 else method
480
+ if ctx.index is not None:
481
+ ctx.index.setdefault(full_name, RouteNode(maximal_form(template), merged, child_method, template))
482
+ if isinstance(children, PhpArray):
483
+ _walk(children, template, full_name, merged, child_method, depth + 1, ctx)
484
+
485
+
486
+ def fallback_route_literals(
487
+ content: str, tokens: TokenList, consumed: set[int], pattern: re.Pattern[str]
488
+ ) -> list[tuple[str, int]]:
489
+ """Flat `'route' => '/path'` matches (``pattern`` group 1 is the path) that
490
+ the structural walk didn't consume: routes built in ways it can't follow,
491
+ or past the tokenizer's cap (``tokens.truncated``). A literal inside a
492
+ tokenized `child_routes` array is only a fragment of a path and is left
493
+ out; past a truncation the arrays are unknown, so every literal there is
494
+ kept. Returns ``(path, match offset)`` pairs."""
495
+ spans = keyed_array_spans(tokens, "child_routes")
496
+ starts = [start for start, _ in spans]
497
+ found: list[tuple[str, int]] = []
498
+ for match in pattern.finditer(content):
499
+ literal = match.start(1) - 1
500
+ if literal in consumed:
501
+ continue
502
+ at = bisect.bisect_right(starts, literal) - 1
503
+ if at >= 0 and spans[at][0] <= literal < spans[at][1]:
504
+ continue
505
+ found.append((match.group(1), match.start()))
506
+ return found
507
+
508
+
509
+ def _verbs(value: PhpValue | None) -> list[str]:
510
+ if not isinstance(value, PhpString):
511
+ return []
512
+ verbs = [part.strip().upper() for part in value.value.split(",")]
513
+ return [verb for verb in verbs if verb in _HTTP_METHODS]
514
+
515
+
516
+ # ---------------------------------------------------------------------------
517
+ # Mezzio
518
+ # ---------------------------------------------------------------------------
519
+
520
+
521
+ @dataclass
522
+ class MezzioRoute:
523
+ path: str
524
+ method: str
525
+ offset: int
526
+
527
+
528
+ def _strip_fastroute_regex(path: str) -> str:
529
+ """`/a/{id:\\d{1,3}}[/{slug}]` → `/a/{id}[/{slug}]`."""
530
+ out: list[str] = []
531
+ i = 0
532
+ n = len(path)
533
+ while i < n:
534
+ ch = path[i]
535
+ if ch != "{":
536
+ out.append(ch)
537
+ i += 1
538
+ continue
539
+ j = i + 1
540
+ while j < n and path[j] not in ":}":
541
+ j += 1
542
+ out.append("{" + path[i + 1 : j] + "}")
543
+ if j < n and path[j] == ":":
544
+ depth = 1
545
+ j += 1
546
+ while j < n and depth:
547
+ if path[j] == "{":
548
+ depth += 1
549
+ elif path[j] == "}":
550
+ depth -= 1
551
+ j += 1
552
+ i = j
553
+ else:
554
+ i = j + 1
555
+ return "".join(out)
556
+
557
+
558
+ def mezzio_routes(tokens: list[Token]) -> list[MezzioRoute]:
559
+ """`$app->get('/path', ...)` and friends, and `$app->route('/path', $mw, [methods])`."""
560
+ out: list[MezzioRoute] = []
561
+ n = len(tokens)
562
+ i = -1
563
+ while i + 1 < n - 4:
564
+ i += 1
565
+ if len(out) >= MAX_ROUTES_PER_FILE:
566
+ break
567
+ if not (
568
+ tokens[i].kind == WORD
569
+ and tokens[i].text == "$app"
570
+ and tokens[i + 1].kind == OP
571
+ and tokens[i + 1].text == "-"
572
+ and tokens[i + 2].kind == OP
573
+ and tokens[i + 2].text == ">"
574
+ and tokens[i + 3].kind == WORD
575
+ and tokens[i + 4].kind == LPAREN
576
+ ):
577
+ continue
578
+ verb = tokens[i + 3].text.lower()
579
+ if verb != "route" and verb not in _MEZZIO_VERBS:
580
+ continue
581
+ args, end = parse_call_args(tokens, i + 4)
582
+ # Continue after the call: routes aren't registered inside another
583
+ # route's arguments, and re-parsing nested calls would be quadratic.
584
+ i = max(end, i + 5) - 1
585
+ if not args or not isinstance(args[0], PhpString) or not args[0].value.startswith("/"):
586
+ continue
587
+ if verb == "route":
588
+ methods = _route_methods(args[2] if len(args) > 2 else None)
589
+ else:
590
+ methods = [_MEZZIO_VERBS[verb]]
591
+ template = _strip_fastroute_regex(args[0].value)
592
+ for form in expand_optional(template):
593
+ path = normalize_path(form)
594
+ for method in methods:
595
+ out.append(MezzioRoute(path, method, args[0].offset))
596
+ return out
597
+
598
+
599
+ def _route_methods(value: PhpValue | None) -> list[str]:
600
+ if isinstance(value, PhpArray):
601
+ methods = [
602
+ v.value.strip().upper() for _, v in value.items if isinstance(v, PhpString)
603
+ ]
604
+ methods = [m for m in methods if m in _HTTP_METHODS]
605
+ return methods or ["ANY"]
606
+ return ["ANY"] # null, Route::HTTP_METHOD_ANY, a variable
607
+
608
+
609
+ __all__ = [
610
+ "LaminasRoute",
611
+ "fallback_route_literals",
612
+ "join_child",
613
+ "maximal_form",
614
+ "MezzioRoute",
615
+ "RouteNode",
616
+ "route_trees",
617
+ "expand_optional",
618
+ "mezzio_routes",
619
+ "minimal_form",
620
+ "normalize_path",
621
+ "route_type",
622
+ "router_routes",
623
+ ]