codemap-java 0.2.2__tar.gz → 0.3.1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codemap-java
3
- Version: 0.2.2
3
+ Version: 0.3.1
4
4
  Summary: Java indexer plugin for CodeMap
5
5
  Project-URL: Homepage, https://github.com/qxbyte/codemap
6
6
  Author: CodeMap Contributors
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Java
11
11
  Classifier: Programming Language :: Python :: 3
12
12
  Classifier: Topic :: Software Development
13
13
  Requires-Python: >=3.11
14
- Requires-Dist: codemap-core<0.3,>=0.2.0
14
+ Requires-Dist: codemap-core<0.4,>=0.3.0
15
15
  Requires-Dist: tree-sitter-java>=0.23
16
16
  Requires-Dist: tree-sitter>=0.25
17
17
  Provides-Extra: dev
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "codemap-java"
7
- version = "0.2.2"
7
+ version = "0.3.1"
8
8
  description = "Java indexer plugin for CodeMap"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -18,7 +18,7 @@ classifiers = [
18
18
  "Topic :: Software Development",
19
19
  ]
20
20
  dependencies = [
21
- "codemap-core>=0.2.0,<0.3",
21
+ "codemap-core>=0.3.0,<0.4",
22
22
  "tree-sitter>=0.25",
23
23
  "tree-sitter-java>=0.23",
24
24
  ]
@@ -29,6 +29,9 @@ dev = ["pytest>=8.0"]
29
29
  [project.entry-points."codemap.indexers"]
30
30
  java = "codemap_java:JavaIndexer"
31
31
 
32
+ [project.entry-points."codemap.bridges"]
33
+ java_calls = "codemap_java.resolver:JavaCallResolverBridge"
34
+
32
35
  [project.urls]
33
36
  Homepage = "https://github.com/qxbyte/codemap"
34
37
 
@@ -0,0 +1,589 @@
1
+ """Java indexer built on tree-sitter-java.
2
+
3
+ Covers class / interface / enum / record / method / constructor / field
4
+ declarations. Package declarations are honoured as a namespace prefix
5
+ under the file path. Nested types track a class stack to produce the
6
+ correct ``Cls#Inner#m()`` chain.
7
+
8
+ The indexer is single-file by design; cross-file ``extends`` / ``implements``
9
+ and call-graph resolution lives in :class:`codemap.core.bridge.java_calls
10
+ .JavaCallResolverBridge`. To enable that resolver, the indexer attaches three
11
+ metadata keys to ``Symbol.extra`` (ADR-0013):
12
+
13
+ * top-level type symbols carry ``imports`` (list[str], fully qualified)
14
+ * top-level type symbols carry ``supertypes`` (list of
15
+ ``{"name": str, "relation": "extends"|"implements"}``)
16
+ * method / constructor symbols carry ``pending_calls`` — a list of raw
17
+ invocation records ``{"receiver", "name", "arity", "line", "col"}`` for
18
+ the bridge to FQN-resolve.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pathlib import Path, PurePosixPath
24
+ from typing import ClassVar
25
+
26
+ import tree_sitter
27
+ import tree_sitter_java
28
+
29
+ from codemap.core.models import Annotation, Diagnostic, Edge, IndexResult, Range, Symbol
30
+ from codemap.core.symbol import Descriptor, DescriptorKind, SymbolID
31
+ from codemap.indexers.base import IndexContext
32
+
33
+ SCHEME = "scip-java"
34
+ LANG = "java"
35
+
36
+ _JAVA_LANG = tree_sitter.Language(tree_sitter_java.language())
37
+
38
+ _TYPE_DECLS = frozenset(
39
+ {
40
+ "class_declaration",
41
+ "interface_declaration",
42
+ "enum_declaration",
43
+ "record_declaration",
44
+ }
45
+ )
46
+
47
+
48
+ class JavaIndexer:
49
+ name: ClassVar[str] = "java"
50
+ version: ClassVar[str] = "0.1.0"
51
+ file_patterns: ClassVar[list[str]] = ["*.java"]
52
+ languages: ClassVar[list[str]] = [LANG]
53
+
54
+ def supports(self, path: Path) -> bool:
55
+ return path.suffix == ".java"
56
+
57
+ def index_file(
58
+ self,
59
+ path: Path,
60
+ source: bytes,
61
+ ctx: IndexContext,
62
+ ) -> IndexResult:
63
+ try:
64
+ source.decode("utf-8")
65
+ except UnicodeDecodeError as exc:
66
+ return IndexResult(
67
+ diagnostics=[
68
+ Diagnostic(
69
+ severity="error",
70
+ file=ctx.relative_path,
71
+ code="JAVA002",
72
+ message=f"not valid UTF-8: {exc}",
73
+ producer=self.name,
74
+ )
75
+ ]
76
+ )
77
+ parser = tree_sitter.Parser(_JAVA_LANG)
78
+ tree = parser.parse(source)
79
+ visitor = _Visitor(ctx.relative_path)
80
+ visitor.visit(tree.root_node)
81
+ diagnostics = list(visitor.diagnostics)
82
+ if tree.root_node.has_error:
83
+ diagnostics.append(
84
+ Diagnostic(
85
+ severity="warning",
86
+ file=ctx.relative_path,
87
+ range=Range(start_line=1, end_line=1),
88
+ code="JAVA001",
89
+ message="tree-sitter reported parse errors; symbols may be incomplete",
90
+ producer=self.name,
91
+ )
92
+ )
93
+ return IndexResult(
94
+ symbols=visitor.symbols,
95
+ edges=visitor.edges,
96
+ diagnostics=diagnostics,
97
+ )
98
+
99
+
100
+ # ---------------------------------------------------------------------------
101
+ # AST visitor
102
+ # ---------------------------------------------------------------------------
103
+
104
+
105
+ class _Visitor:
106
+ def __init__(self, relative_path: PurePosixPath) -> None:
107
+ self.relative_path = relative_path
108
+ self.symbols: list[Symbol] = []
109
+ self.edges: list[Edge] = []
110
+ self.diagnostics: list[Diagnostic] = []
111
+ self._class_stack: list[str] = []
112
+ self._class_annos_stack: list[list[Annotation]] = []
113
+ self._package: str = ""
114
+ self._file_imports: list[str] = []
115
+
116
+ def visit(self, node: tree_sitter.Node) -> None:
117
+ if node.type == "package_declaration":
118
+ self._package = _node_text(node.children[1]) if node.child_count > 1 else ""
119
+ return
120
+ if node.type == "import_declaration":
121
+ imp = _parse_import(node)
122
+ if imp:
123
+ self._file_imports.append(imp)
124
+ return
125
+ if node.type in _TYPE_DECLS:
126
+ self._visit_type(node)
127
+ return
128
+ if node.type == "method_declaration" and self._class_stack:
129
+ self._visit_method(node, is_constructor=False)
130
+ return
131
+ if node.type == "constructor_declaration" and self._class_stack:
132
+ self._visit_method(node, is_constructor=True)
133
+ return
134
+ if node.type == "field_declaration" and self._class_stack:
135
+ self._visit_field(node)
136
+ return
137
+ for child in node.children:
138
+ self.visit(child)
139
+
140
+ # ------------------------------------------------------------- types
141
+
142
+ def _visit_type(self, node: tree_sitter.Node) -> None:
143
+ name = _name_child(node)
144
+ if name is None:
145
+ return
146
+ java_kind = node.type.removesuffix("_declaration")
147
+ is_top_level = not self._class_stack
148
+ sid = self._make_id(name, kind=DescriptorKind.TYPE)
149
+ annotations = _parse_annotations(node)
150
+ extra: dict[str, object] = {}
151
+ if self._package or java_kind != "class":
152
+ extra["java_kind"] = java_kind
153
+ extra["package"] = self._package
154
+ if is_top_level:
155
+ extra["imports"] = list(self._file_imports)
156
+ extra["supertypes"] = _parse_supertypes(node)
157
+ self.symbols.append(
158
+ Symbol(
159
+ id=sid,
160
+ kind="class", # Symbol schema has no separate interface/enum kind
161
+ language=LANG,
162
+ file=self.relative_path,
163
+ range=_node_range(node),
164
+ annotations=annotations,
165
+ extra=extra,
166
+ )
167
+ )
168
+ body = node.child_by_field_name("body")
169
+ if body is None:
170
+ return
171
+ self._class_stack.append(name)
172
+ self._class_annos_stack.append(annotations)
173
+ try:
174
+ for child in body.children:
175
+ self.visit(child)
176
+ finally:
177
+ self._class_stack.pop()
178
+ self._class_annos_stack.pop()
179
+
180
+ # ----------------------------------------------------------- members
181
+
182
+ def _visit_method(self, node: tree_sitter.Node, *, is_constructor: bool) -> None:
183
+ name = _name_child(node)
184
+ if name is None:
185
+ return
186
+ if is_constructor:
187
+ display = "<init>"
188
+ sid = self._make_id(display, kind=DescriptorKind.METHOD)
189
+ else:
190
+ display = name
191
+ sid = self._make_id(name, kind=DescriptorKind.METHOD)
192
+ signature = _method_signature(node, name, is_constructor=is_constructor)
193
+ body = node.child_by_field_name("body")
194
+ pending_calls = _collect_invocations(body) if body is not None else []
195
+ params = _parse_formal_parameters(node.child_by_field_name("parameters"))
196
+ method_annos = _parse_annotations(node)
197
+ extra: dict[str, object] = {"params": params}
198
+ if not is_constructor:
199
+ ret = node.child_by_field_name("type")
200
+ if ret is not None:
201
+ extra["return_type"] = _strip_generics(_node_text(ret))
202
+ if pending_calls:
203
+ extra["pending_calls"] = pending_calls
204
+ class_annos = self._class_annos_stack[-1] if self._class_annos_stack else []
205
+ route = _http_route_meta(class_annos, method_annos)
206
+ if route is not None:
207
+ extra["http_route"] = route
208
+ self.symbols.append(
209
+ Symbol(
210
+ id=sid,
211
+ kind="method",
212
+ language=LANG,
213
+ file=self.relative_path,
214
+ range=_node_range(node),
215
+ signature=signature,
216
+ annotations=method_annos,
217
+ extra=extra,
218
+ )
219
+ )
220
+
221
+ def _visit_field(self, node: tree_sitter.Node) -> None:
222
+ type_node = node.child_by_field_name("type")
223
+ type_str = _strip_generics(_node_text(type_node)) if type_node is not None else ""
224
+ for child in node.children:
225
+ if child.type != "variable_declarator":
226
+ continue
227
+ name_node = child.child_by_field_name("name")
228
+ if name_node is None:
229
+ continue
230
+ name = _node_text(name_node)
231
+ if not name:
232
+ continue
233
+ sid = self._make_id(name, kind=DescriptorKind.TERM)
234
+ extra: dict[str, object] = {}
235
+ if type_str:
236
+ extra["type"] = type_str
237
+ self.symbols.append(
238
+ Symbol(
239
+ id=sid,
240
+ kind="field",
241
+ language=LANG,
242
+ file=self.relative_path,
243
+ range=_node_range(child),
244
+ extra=extra,
245
+ )
246
+ )
247
+
248
+ # ----------------------------------------------------------- helpers
249
+
250
+ def _make_id(self, name: str, *, kind: DescriptorKind) -> SymbolID:
251
+ descriptors = list(_path_namespaces(self.relative_path))
252
+ descriptors.extend(
253
+ Descriptor(name=cls, kind=DescriptorKind.TYPE) for cls in self._class_stack
254
+ )
255
+ descriptors.append(Descriptor(name=name, kind=kind))
256
+ return SymbolID(scheme=SCHEME, descriptors=tuple(descriptors))
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Pure helpers
261
+ # ---------------------------------------------------------------------------
262
+
263
+
264
+ def _path_namespaces(path: PurePosixPath) -> list[Descriptor]:
265
+ return [Descriptor(name=part, kind=DescriptorKind.NAMESPACE) for part in path.parts]
266
+
267
+
268
+ def _node_range(node: tree_sitter.Node) -> Range:
269
+ sr, sc = node.start_point
270
+ er, ec = node.end_point
271
+ return Range(
272
+ start_line=sr + 1,
273
+ start_col=sc,
274
+ end_line=max(er + 1, sr + 1),
275
+ end_col=ec,
276
+ )
277
+
278
+
279
+ def _node_text(node: tree_sitter.Node) -> str:
280
+ return node.text.decode("utf-8") if node.text is not None else ""
281
+
282
+
283
+ def _name_child(node: tree_sitter.Node) -> str | None:
284
+ name_node = node.child_by_field_name("name")
285
+ if name_node is None or name_node.text is None:
286
+ return None
287
+ text = _node_text(name_node).strip()
288
+ return text or None
289
+
290
+
291
+ def _method_signature(
292
+ node: tree_sitter.Node,
293
+ name: str,
294
+ *,
295
+ is_constructor: bool,
296
+ ) -> str:
297
+ params = node.child_by_field_name("parameters")
298
+ params_text = _node_text(params) if params is not None else "()"
299
+ if is_constructor:
300
+ return f"{name}{params_text}"
301
+ return_type = node.child_by_field_name("type")
302
+ rt_text = _node_text(return_type) + " " if return_type is not None else ""
303
+ return f"{rt_text}{name}{params_text}"
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Metadata extractors for the JavaCallResolverBridge (ADR-0013)
308
+ # ---------------------------------------------------------------------------
309
+
310
+
311
+ def _parse_import(node: tree_sitter.Node) -> str:
312
+ """Return the imported FQN. ``import static x.y.Z.m;`` → ``x.y.Z.m``;
313
+ ``import java.util.*;`` → ``java.util.*``. Empty string on malformed
314
+ input (returned to the caller, who drops empties)."""
315
+ parts: list[str] = []
316
+ saw_asterisk = False
317
+ for child in node.children:
318
+ ttype = child.type
319
+ if ttype in {"import", "static", ";"}:
320
+ continue
321
+ if ttype == "asterisk":
322
+ saw_asterisk = True
323
+ continue
324
+ if ttype in {"identifier", "scoped_identifier"}:
325
+ parts.append(_node_text(child))
326
+ if not parts:
327
+ return ""
328
+ path = ".".join(parts)
329
+ return f"{path}.*" if saw_asterisk else path
330
+
331
+
332
+ def _parse_supertypes(type_node: tree_sitter.Node) -> list[dict[str, str]]:
333
+ """Extract ``extends`` / ``implements`` relations off a type declaration.
334
+
335
+ Handles ``class X extends A``, ``class X implements I, J``,
336
+ ``class X extends A implements I``, and ``interface I extends J, K``.
337
+ Generic type arguments (``Box<String>``) are stripped — bridge resolves
338
+ by raw name, not parameterized type.
339
+ """
340
+ out: list[dict[str, str]] = []
341
+ for child in type_node.children:
342
+ ttype = child.type
343
+ # `class` declarations: superclass / super_interfaces fields.
344
+ if ttype == "superclass":
345
+ out.extend({"name": name, "relation": "extends"} for name in _supertype_names(child))
346
+ elif ttype == "super_interfaces":
347
+ out.extend({"name": name, "relation": "implements"} for name in _supertype_names(child))
348
+ # `interface` declarations: extends_interfaces.
349
+ elif ttype == "extends_interfaces":
350
+ out.extend({"name": name, "relation": "extends"} for name in _supertype_names(child))
351
+ return out
352
+
353
+
354
+ def _supertype_names(container: tree_sitter.Node) -> list[str]:
355
+ out: list[str] = []
356
+ for child in container.children:
357
+ ttype = child.type
358
+ if ttype in {"type_identifier", "scoped_type_identifier"}:
359
+ out.append(_node_text(child))
360
+ elif ttype == "generic_type":
361
+ # take the head type, drop ``<...>``
362
+ head = child.child(0)
363
+ if head is not None and head.type in {
364
+ "type_identifier",
365
+ "scoped_type_identifier",
366
+ }:
367
+ out.append(_node_text(head))
368
+ elif ttype == "type_list":
369
+ out.extend(_supertype_names(child))
370
+ return out
371
+
372
+
373
+ def _collect_invocations(body: tree_sitter.Node) -> list[dict[str, object]]:
374
+ """Walk ``body`` collecting every ``method_invocation`` node as a raw
375
+ record. The bridge does FQN resolution; here we only capture the syntactic
376
+ shape (receiver text, name, arity, location)."""
377
+ records: list[dict[str, object]] = []
378
+
379
+ def walk(node: tree_sitter.Node) -> None:
380
+ if node.type == "method_invocation":
381
+ name_node = node.child_by_field_name("name")
382
+ obj_node = node.child_by_field_name("object")
383
+ args_node = node.child_by_field_name("arguments")
384
+ if name_node is not None:
385
+ receiver = _receiver_text(obj_node)
386
+ name = _node_text(name_node)
387
+ arity = _argument_arity(args_node)
388
+ sr, sc = node.start_point
389
+ records.append(
390
+ {
391
+ "receiver": receiver,
392
+ "name": name,
393
+ "arity": arity,
394
+ "line": sr + 1,
395
+ "col": sc,
396
+ }
397
+ )
398
+ for child in node.children:
399
+ walk(child)
400
+
401
+ walk(body)
402
+ return records
403
+
404
+
405
+ def _receiver_text(obj: tree_sitter.Node | None) -> str:
406
+ """Best-effort textual receiver. Empty for unqualified calls; the inner
407
+ method's name for chained calls (``foo.bar().baz()`` → ``"bar"`` is the
408
+ receiver of ``baz``)."""
409
+ if obj is None:
410
+ return ""
411
+ ttype = obj.type
412
+ if ttype in {"identifier", "scoped_identifier", "this", "super"}:
413
+ return _node_text(obj)
414
+ if ttype == "field_access":
415
+ field = obj.child_by_field_name("field")
416
+ return _node_text(field) if field is not None else ""
417
+ if ttype == "method_invocation":
418
+ inner = obj.child_by_field_name("name")
419
+ return _node_text(inner) if inner is not None else ""
420
+ return ""
421
+
422
+
423
+ def _argument_arity(args: tree_sitter.Node | None) -> int:
424
+ if args is None:
425
+ return 0
426
+ # named_child_count skips punctuation tokens (`(`, `)`, `,`).
427
+ return int(args.named_child_count)
428
+
429
+
430
+ def _parse_formal_parameters(node: tree_sitter.Node | None) -> list[dict[str, str]]:
431
+ """Return ``[{"name": str, "type": str}]`` for each formal parameter.
432
+
433
+ Generic type arguments are stripped (``List<String>`` → ``List``) so the
434
+ FQN resolver can match by raw type name.
435
+ """
436
+ if node is None:
437
+ return []
438
+ out: list[dict[str, str]] = []
439
+ for child in node.children:
440
+ if child.type not in {"formal_parameter", "spread_parameter"}:
441
+ continue
442
+ name_node = child.child_by_field_name("name")
443
+ type_node = child.child_by_field_name("type")
444
+ if name_node is None or type_node is None:
445
+ # Spread parameters wrap the actual variable_declarator differently.
446
+ for sub in child.children:
447
+ if sub.type == "variable_declarator" and name_node is None:
448
+ name_node = sub.child_by_field_name("name")
449
+ if name_node is None or type_node is None:
450
+ continue
451
+ out.append(
452
+ {
453
+ "name": _node_text(name_node),
454
+ "type": _strip_generics(_node_text(type_node)),
455
+ }
456
+ )
457
+ return out
458
+
459
+
460
+ def _strip_generics(t: str) -> str:
461
+ """``Box<String, Integer>`` → ``Box``; arrays / primitives untouched."""
462
+ t = t.strip()
463
+ if "<" in t:
464
+ return t.split("<", 1)[0].rstrip()
465
+ return t
466
+
467
+
468
+ # ---------------------------------------------------------------------------
469
+ # Annotation extraction (Plan 3 Task 1)
470
+ # ---------------------------------------------------------------------------
471
+
472
+
473
+ def _parse_annotations(decl_node: tree_sitter.Node) -> list[Annotation]:
474
+ """Walk a class/method/constructor declaration's ``modifiers`` child and
475
+ return every ``annotation`` / ``marker_annotation`` it carries as an
476
+ :class:`Annotation`."""
477
+ out: list[Annotation] = []
478
+ for child in decl_node.children:
479
+ if child.type != "modifiers":
480
+ continue
481
+ for m in child.children:
482
+ if m.type == "marker_annotation":
483
+ name = _annotation_name(m)
484
+ if name:
485
+ out.append(Annotation(name=name, arguments={}))
486
+ elif m.type == "annotation":
487
+ name = _annotation_name(m)
488
+ args = _annotation_arguments(m)
489
+ if name:
490
+ out.append(Annotation(name=name, arguments=args))
491
+ return out
492
+
493
+
494
+ def _annotation_name(ann_node: tree_sitter.Node) -> str:
495
+ for child in ann_node.children:
496
+ if child.type in {"identifier", "scoped_identifier"}:
497
+ return _node_text(child)
498
+ return ""
499
+
500
+
501
+ def _annotation_arguments(ann_node: tree_sitter.Node) -> dict[str, str]:
502
+ """``@RequestMapping("/x")`` → ``{"value": "/x"}``;
503
+ ``@X(name="a", v=1)`` → ``{"name": "a", "v": "1"}``;
504
+ ``@Override`` → ``{}``."""
505
+ args: dict[str, str] = {}
506
+ arglist = None
507
+ for child in ann_node.children:
508
+ if child.type == "annotation_argument_list":
509
+ arglist = child
510
+ break
511
+ if arglist is None:
512
+ return args
513
+ for child in arglist.children:
514
+ if child.type == "string_literal":
515
+ args["value"] = _strip_string_literal(_node_text(child))
516
+ elif child.type == "element_value_pair":
517
+ key_node = child.child_by_field_name("key")
518
+ val_node = child.child_by_field_name("value")
519
+ if key_node is None or val_node is None:
520
+ # fall back: first two non-`=` children
521
+ kids = [c for c in child.children if c.type not in {"="}]
522
+ if len(kids) < 2:
523
+ continue
524
+ key_node, val_node = kids[0], kids[1]
525
+ key = _node_text(key_node)
526
+ value = _strip_string_literal(_node_text(val_node))
527
+ if key:
528
+ args[key] = value
529
+ elif child.type not in {"(", ")", ","} and "value" not in args:
530
+ # bare non-string single value (e.g. enum member, integer)
531
+ args["value"] = _strip_string_literal(_node_text(child))
532
+ return args
533
+
534
+
535
+ def _strip_string_literal(s: str) -> str:
536
+ if len(s) >= 2 and s[0] == s[-1] and s[0] in {'"', "'"}:
537
+ return s[1:-1]
538
+ return s
539
+
540
+
541
+ # ---------------------------------------------------------------------------
542
+ # Spring http_route metadata (Plan 3 Task 2)
543
+ # ---------------------------------------------------------------------------
544
+
545
+ _VERB_ANNO = {
546
+ "GetMapping": "GET",
547
+ "PostMapping": "POST",
548
+ "PutMapping": "PUT",
549
+ "DeleteMapping": "DELETE",
550
+ "PatchMapping": "PATCH",
551
+ }
552
+
553
+
554
+ def _http_route_meta(
555
+ class_annos: list[Annotation],
556
+ method_annos: list[Annotation],
557
+ ) -> dict[str, str] | None:
558
+ """Combine class-level ``@RequestMapping`` prefix with the method's verb
559
+ mapping annotation. Returns ``{"method", "path"}`` or ``None`` when the
560
+ method has no mapping annotation."""
561
+ prefix = ""
562
+ for a in class_annos:
563
+ if a.name == "RequestMapping":
564
+ prefix = a.arguments.get("value", a.arguments.get("path", ""))
565
+ break
566
+
567
+ verb: str | None = None
568
+ path = ""
569
+ for a in method_annos:
570
+ if a.name in _VERB_ANNO:
571
+ verb = _VERB_ANNO[a.name]
572
+ path = a.arguments.get("value", a.arguments.get("path", ""))
573
+ break
574
+
575
+ if verb is None:
576
+ for a in method_annos:
577
+ if a.name == "RequestMapping":
578
+ verb = "GET"
579
+ path = a.arguments.get("value", a.arguments.get("path", ""))
580
+ break
581
+
582
+ if verb is None:
583
+ return None
584
+ return {"method": verb, "path": _join_route(prefix, path)}
585
+
586
+
587
+ def _join_route(prefix: str, path: str) -> str:
588
+ parts = [seg for seg in (prefix + "/" + path).split("/") if seg]
589
+ return "/" + "/".join(parts) if parts else "/"