devlensio 0.6.2 → 1.0.1

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.
Files changed (129) hide show
  1. package/README.md +89 -31
  2. package/dist/extractors/detectLanguage.d.ts +2 -0
  3. package/dist/extractors/detectLanguage.js +33 -0
  4. package/dist/extractors/index.d.ts +6 -0
  5. package/dist/extractors/index.js +155 -0
  6. package/dist/extractors/runner.d.ts +7 -0
  7. package/dist/extractors/runner.js +194 -0
  8. package/dist/extractors/types.d.ts +37 -0
  9. package/dist/extractors/types.js +8 -0
  10. package/dist/graph/buildLookup.d.ts +1 -0
  11. package/dist/graph/buildLookup.js +2 -1
  12. package/dist/graph/edges/callEdges.js +19 -5
  13. package/dist/graph/edges/callEdges.test.d.ts +1 -0
  14. package/dist/graph/edges/callEdges.test.js +200 -0
  15. package/dist/graph/edges/importEdges.js +12 -0
  16. package/dist/graph/edges/inheritanceEdges.d.ts +3 -0
  17. package/dist/graph/edges/inheritanceEdges.js +73 -0
  18. package/dist/graph/edges/inheritanceEdges.test.d.ts +1 -0
  19. package/dist/graph/edges/inheritanceEdges.test.js +140 -0
  20. package/dist/graph/index.js +4 -0
  21. package/dist/parser/classes.test.d.ts +1 -0
  22. package/dist/parser/classes.test.js +360 -0
  23. package/dist/parser/extractors/classes.d.ts +5 -0
  24. package/dist/parser/extractors/classes.js +241 -0
  25. package/dist/parser/index.d.ts +2 -0
  26. package/dist/parser/index.js +7 -1
  27. package/dist/pipeline/index.d.ts +2 -1
  28. package/dist/pipeline/index.js +30 -42
  29. package/dist/scoring/index.js +8 -0
  30. package/dist/scoring/index.test.js +43 -0
  31. package/dist/scoring/nodeScorer.js +2 -0
  32. package/dist/scoring/pruneDisconnected.d.ts +8 -0
  33. package/dist/scoring/pruneDisconnected.js +66 -0
  34. package/dist/scoring/pruneDisconnected.test.d.ts +1 -0
  35. package/dist/scoring/pruneDisconnected.test.js +123 -0
  36. package/dist/summarizer/prompts.d.ts +1 -1
  37. package/dist/types.d.ts +5 -5
  38. package/extractors/go/bin/darwin-amd64/devlens_go_extractor +0 -0
  39. package/extractors/go/bin/darwin-arm64/devlens_go_extractor +0 -0
  40. package/extractors/go/bin/linux-amd64/devlens_go_extractor +0 -0
  41. package/extractors/go/bin/linux-arm64/devlens_go_extractor +0 -0
  42. package/extractors/go/bin/windows-amd64/devlens_go_extractor.exe +0 -0
  43. package/extractors/go/build.mjs +43 -0
  44. package/extractors/go/calls.go +289 -0
  45. package/extractors/go/contract.go +140 -0
  46. package/extractors/go/extractor.go +161 -0
  47. package/extractors/go/fingerprint.go +167 -0
  48. package/extractors/go/go.mod +3 -0
  49. package/extractors/go/imports.go +90 -0
  50. package/extractors/go/inheritance.go +138 -0
  51. package/extractors/go/lookup.go +106 -0
  52. package/extractors/go/main.go +57 -0
  53. package/extractors/go/nodes.go +177 -0
  54. package/extractors/go/orm_edges.go +277 -0
  55. package/extractors/go/parser.go +469 -0
  56. package/extractors/go/routes.go +521 -0
  57. package/extractors/go/tests.go +45 -0
  58. package/extractors/go/thirdparty.go +138 -0
  59. package/extractors/go/typeload.go +178 -0
  60. package/extractors/go/walker.go +67 -0
  61. package/extractors/java/build.mjs +90 -0
  62. package/extractors/java/devlens_java_extractor.jar +0 -0
  63. package/extractors/java/src/devlens/extractor/Contract.java +171 -0
  64. package/extractors/java/src/devlens/extractor/Extractor.java +262 -0
  65. package/extractors/java/src/devlens/extractor/ExtractorResult.java +12 -0
  66. package/extractors/java/src/devlens/extractor/Fingerprint.java +239 -0
  67. package/extractors/java/src/devlens/extractor/LookupMaps.java +123 -0
  68. package/extractors/java/src/devlens/extractor/Main.java +66 -0
  69. package/extractors/java/src/devlens/extractor/Parser.java +522 -0
  70. package/extractors/java/src/devlens/extractor/SourceWalker.java +82 -0
  71. package/extractors/java/src/devlens/extractor/ThirdParty.java +141 -0
  72. package/extractors/java/src/devlens/extractor/TypeSolverFactory.java +43 -0
  73. package/extractors/java/src/devlens/extractor/edges/Calls.java +266 -0
  74. package/extractors/java/src/devlens/extractor/edges/Enrich.java +54 -0
  75. package/extractors/java/src/devlens/extractor/edges/Imports.java +154 -0
  76. package/extractors/java/src/devlens/extractor/edges/Inheritance.java +79 -0
  77. package/extractors/java/src/devlens/extractor/edges/OrmEdges.java +209 -0
  78. package/extractors/java/src/devlens/extractor/edges/Routes.java +143 -0
  79. package/extractors/java/src/devlens/extractor/edges/Tests.java +53 -0
  80. package/extractors/python/devlens_extractors_python/__init__.py +3 -0
  81. package/extractors/python/devlens_extractors_python/__main__.py +33 -0
  82. package/extractors/python/devlens_extractors_python/contract.py +94 -0
  83. package/extractors/python/devlens_extractors_python/edges/__init__.py +28 -0
  84. package/extractors/python/devlens_extractors_python/edges/calls.py +112 -0
  85. package/extractors/python/devlens_extractors_python/edges/enrich.py +44 -0
  86. package/extractors/python/devlens_extractors_python/edges/imports.py +155 -0
  87. package/extractors/python/devlens_extractors_python/edges/inheritance.py +97 -0
  88. package/extractors/python/devlens_extractors_python/edges/orm_edges.py +225 -0
  89. package/extractors/python/devlens_extractors_python/edges/routes/__init__.py +28 -0
  90. package/extractors/python/devlens_extractors_python/edges/routes/common.py +79 -0
  91. package/extractors/python/devlens_extractors_python/edges/routes/decorators.py +212 -0
  92. package/extractors/python/devlens_extractors_python/edges/routes/django_urls.py +213 -0
  93. package/extractors/python/devlens_extractors_python/edges/routes/drf.py +128 -0
  94. package/extractors/python/devlens_extractors_python/edges/tests.py +53 -0
  95. package/extractors/python/devlens_extractors_python/extractor.py +107 -0
  96. package/extractors/python/devlens_extractors_python/fingerprint.py +201 -0
  97. package/extractors/python/devlens_extractors_python/lookup.py +72 -0
  98. package/extractors/python/devlens_extractors_python/parser/__init__.py +72 -0
  99. package/extractors/python/devlens_extractors_python/parser/classes.py +109 -0
  100. package/extractors/python/devlens_extractors_python/parser/functions.py +163 -0
  101. package/extractors/python/devlens_extractors_python/parser/walker.py +30 -0
  102. package/extractors/python/devlens_extractors_python/third_party.py +103 -0
  103. package/extractors/python/pyproject.toml +16 -0
  104. package/extractors/python/setup.mjs +54 -0
  105. package/extractors/rust/Cargo.toml +29 -0
  106. package/extractors/rust/bin/darwin-amd64/devlens_rust_extractor +0 -0
  107. package/extractors/rust/bin/darwin-arm64/devlens_rust_extractor +0 -0
  108. package/extractors/rust/bin/linux-amd64/devlens_rust_extractor +0 -0
  109. package/extractors/rust/bin/linux-arm64/devlens_rust_extractor +0 -0
  110. package/extractors/rust/bin/windows-amd64/devlens_rust_extractor.exe +0 -0
  111. package/extractors/rust/build.mjs +85 -0
  112. package/extractors/rust/src/calls.rs +295 -0
  113. package/extractors/rust/src/contract.rs +236 -0
  114. package/extractors/rust/src/enrich.rs +107 -0
  115. package/extractors/rust/src/extractor.rs +199 -0
  116. package/extractors/rust/src/fingerprint.rs +238 -0
  117. package/extractors/rust/src/imports.rs +101 -0
  118. package/extractors/rust/src/inheritance.rs +233 -0
  119. package/extractors/rust/src/lookup.rs +226 -0
  120. package/extractors/rust/src/main.rs +53 -0
  121. package/extractors/rust/src/module_map.rs +131 -0
  122. package/extractors/rust/src/nodes.rs +174 -0
  123. package/extractors/rust/src/orm_edges.rs +125 -0
  124. package/extractors/rust/src/parser.rs +859 -0
  125. package/extractors/rust/src/routes.rs +1265 -0
  126. package/extractors/rust/src/tests.rs +109 -0
  127. package/extractors/rust/src/thirdparty.rs +114 -0
  128. package/extractors/rust/src/walker.rs +82 -0
  129. package/package.json +22 -4
@@ -0,0 +1,1265 @@
1
+ // routes.rs — route detection: axum method chains + actix-web/rocket
2
+ // attribute macros → ROUTE nodes + HANDLES edges + BackendRouteNodes.
3
+ //
4
+ // Detection passes walk fn bodies (mirrors go/routes.go which keeps the
5
+ // FuncDecl for body walks); handler RESOLUTION goes through the lookup maps
6
+ // (never re-walks for resolution).
7
+ //
8
+ // axum (documented Router API): `Router::new().route("/x", get(h).post(h2))`
9
+ // chains; `.nest(prefix, router)` composes prefixes cross-variable (the
10
+ // Python APIRouter(prefix=) rule — prefix + registration can live in
11
+ // different files); `.merge(router)` unions. MethodRouter combinators
12
+ // get/post/put/delete/patch/options/head/any select the verb; a plain fn
13
+ // passed to `.route()` registers ALL methods (axum docs → "ANY").
14
+ //
15
+ // actix-web / rocket: attribute macros #[get("/x")] / #[route("/x",
16
+ // method="GET")] — macro presence IS the route declaration (the attribute
17
+ // macro contract); the framework comes from the fingerprint.
18
+
19
+ use crate::contract::{edge_with_meta, CodeEdge, CodeNode, RouteNode, NODE_ROUTE};
20
+ use crate::extractor::{Options, ParsedRepo};
21
+ use crate::fingerprint::ParsedManifest;
22
+ use crate::lookup::LookupMaps;
23
+ use crate::module_map::ModuleMap;
24
+ use crate::parser::{ParsedFile, ParsedItem};
25
+ use crate::thirdparty::ThirdPartyRegistry;
26
+ use quote::ToTokens;
27
+ use std::collections::{HashMap, HashSet};
28
+ use syn::spanned::Spanned;
29
+
30
+ pub struct RoutesOutput {
31
+ pub route_nodes: Vec<CodeNode>,
32
+ pub handles_edges: Vec<CodeEdge>,
33
+ pub backend_routes: Vec<RouteNode>,
34
+ }
35
+
36
+ // ── axum facts ──
37
+
38
+ #[derive(Clone)]
39
+ enum HandlerRef {
40
+ Ident(String),
41
+ Path(String), // a::b::c (fn or Type::method)
42
+ MethodValue,
43
+ Closure,
44
+ }
45
+
46
+ #[derive(Clone)]
47
+ struct RouteOp {
48
+ verb: String,
49
+ raw_path: String,
50
+ handler: HandlerRef,
51
+ handler_name: String,
52
+ line: i64,
53
+ file: String,
54
+ /// node id of the fn containing the registration (closure handlers
55
+ /// point here — the Go var-closure rule)
56
+ reg_fn: String,
57
+ }
58
+
59
+ #[derive(Clone)]
60
+ enum RouterOp {
61
+ Route(RouteOp),
62
+ Nest {
63
+ prefix: String,
64
+ target: String,
65
+ },
66
+ Merge {
67
+ target: String,
68
+ },
69
+ /// utoipa-axum `.routes(routes!(h1, h2, ...))` — raw handler paths; the
70
+ /// method+path live in each handler's `#[utoipa::path(...)]` attribute.
71
+ /// Resolved to RouteOps by expand_macro_routes (needs repo/lookup/mf).
72
+ MacroRoutes {
73
+ paths: Vec<String>,
74
+ file: String,
75
+ line: i64,
76
+ reg_fn: String,
77
+ },
78
+ }
79
+
80
+ struct RouterFact {
81
+ /// var name binding the router ("" for anonymous chains)
82
+ key: String,
83
+ ops: Vec<RouterOp>,
84
+ }
85
+
86
+ pub fn detect_routes(
87
+ repo: &ParsedRepo,
88
+ module_map: &ModuleMap,
89
+ lookup: &LookupMaps,
90
+ framework: &str,
91
+ opts: &Options,
92
+ mf: &ParsedManifest,
93
+ tp: &mut ThirdPartyRegistry,
94
+ ) -> RoutesOutput {
95
+ let mut facts: Vec<RouterFact> = vec![];
96
+ let mut func_lit_vars: HashMap<(String, String), String> = HashMap::new();
97
+
98
+ for pf in &repo.files {
99
+ if pf.is_test_file {
100
+ continue;
101
+ }
102
+ for item in &pf.items {
103
+ if item.is_test {
104
+ continue;
105
+ }
106
+ let fn_id = crate::lookup::node_id_for_item(&pf.rel_path, item);
107
+ match item.kind {
108
+ crate::parser::KIND_FUNCTION | crate::parser::KIND_METHOD => {
109
+ collect_axum_facts(pf, item, &fn_id, &mut facts, &mut func_lit_vars);
110
+ }
111
+ _ => {}
112
+ }
113
+ }
114
+ }
115
+
116
+ let mut out = RoutesOutput {
117
+ route_nodes: vec![],
118
+ handles_edges: vec![],
119
+ backend_routes: vec![],
120
+ };
121
+ let mut seen: HashSet<String> = HashSet::new();
122
+
123
+ // utoipa-axum `.routes(routes!(...))` — handler paths → RouteOps (the
124
+ // method+path come from each handler's #[utoipa::path] attribute)
125
+ expand_macro_routes(&mut facts, repo, module_map, lookup, mf, tp);
126
+
127
+ // ── axum: resolve nests/merges → final route list per router ──
128
+ if framework == "axum" || framework == "unknown" {
129
+ let mut router_map: HashMap<String, Vec<RouteOp>> = HashMap::new();
130
+ // routers referenced by nest/merge — their routes surface ONLY via
131
+ // the referencing router (with prefix composition); skip direct emit
132
+ let mut referenced: HashSet<String> = HashSet::new();
133
+ for f in &facts {
134
+ let routes: Vec<RouteOp> = f
135
+ .ops
136
+ .iter()
137
+ .filter_map(|op| match op {
138
+ RouterOp::Route(r) => Some(r.clone()),
139
+ _ => None,
140
+ })
141
+ .collect();
142
+ router_map.entry(f.key.clone()).or_default().extend(routes);
143
+ for op in &f.ops {
144
+ match op {
145
+ RouterOp::Nest { target, .. } | RouterOp::Merge { target, .. } => {
146
+ referenced.insert(target.clone());
147
+ }
148
+ _ => {}
149
+ }
150
+ }
151
+ }
152
+ for f in &facts {
153
+ if referenced.contains(&f.key) {
154
+ continue; // consumed by a nest/merge — composed elsewhere
155
+ }
156
+ let mut final_routes: Vec<RouteOp> = vec![];
157
+ let mut stack: Vec<&RouterOp> = f.ops.iter().collect();
158
+ // compose: nests bring in the target router's routes with prefix
159
+ while let Some(op) = stack.pop() {
160
+ match op {
161
+ RouterOp::Route(r) => final_routes.push(r.clone()),
162
+ RouterOp::MacroRoutes { .. } => {
163
+ // expanded by expand_macro_routes before composition
164
+ }
165
+ RouterOp::Merge { target, .. } => {
166
+ if let Some(inner) = router_map.get(target) {
167
+ final_routes.extend(inner.iter().cloned());
168
+ }
169
+ }
170
+ RouterOp::Nest { prefix, target, .. } => {
171
+ if let Some(inner) = router_map.get(target) {
172
+ for r in inner {
173
+ let mut r2 = r.clone();
174
+ r2.raw_path = compose_paths(prefix, &r2.raw_path);
175
+ final_routes.push(r2);
176
+ }
177
+ }
178
+ }
179
+ }
180
+ }
181
+ for r in final_routes {
182
+ emit_route(
183
+ &r,
184
+ framework,
185
+ &func_lit_vars,
186
+ module_map,
187
+ lookup,
188
+ &mut out,
189
+ &mut seen,
190
+ );
191
+ }
192
+ }
193
+ }
194
+
195
+ // ── actix-web / rocket: attribute macros ──
196
+ if framework == "actix-web" || framework == "rocket" {
197
+ for pf in &repo.files {
198
+ if pf.is_test_file {
199
+ continue;
200
+ }
201
+ for item in &pf.items {
202
+ if item.is_test {
203
+ continue;
204
+ }
205
+ if item.kind == crate::parser::KIND_FUNCTION
206
+ || item.kind == crate::parser::KIND_METHOD
207
+ {
208
+ detect_macro_routes(pf, item, framework, &mut out, &mut seen);
209
+ }
210
+ }
211
+ }
212
+ }
213
+
214
+ let _ = opts;
215
+ out
216
+ }
217
+
218
+ /// Collect router chains inside one fn body (axum).
219
+ fn collect_axum_facts(
220
+ pf: &ParsedFile,
221
+ item: &ParsedItem,
222
+ fn_id: &str,
223
+ facts: &mut Vec<RouterFact>,
224
+ func_lit_vars: &mut HashMap<(String, String), String>,
225
+ ) {
226
+ let fn_node = find_fn_ast(&pf.ast, item);
227
+ let Some(fn_node) = fn_node else { return };
228
+ let body = match fn_node {
229
+ FnAst::Free(f) => &f.block,
230
+ FnAst::Impl(i) => &i.block,
231
+ };
232
+
233
+ // 1) closure/let bindings for handler resolution:
234
+ // `let api_handler = |...| {...};` → api_handler → fn_id
235
+ // 2) router var bindings: `let app = Router::new()...;`
236
+ // 3) outer method-chain exprs whose receiver chain is a router
237
+ let mut router_vars: HashSet<String> = HashSet::new();
238
+ let mut chain_exprs: Vec<syn::Expr> = vec![];
239
+ collect_bindings(
240
+ body,
241
+ fn_id,
242
+ pf,
243
+ func_lit_vars,
244
+ &mut router_vars,
245
+ &mut chain_exprs,
246
+ );
247
+
248
+ // anonymous chains (no let binding): outer MethodCall exprs
249
+ let mut seen_chains: HashSet<String> = HashSet::new();
250
+ for e in &chain_exprs {
251
+ let ops = chain_ops(e, pf, &router_vars, fn_id);
252
+ if ops.is_empty() {
253
+ continue;
254
+ }
255
+ // dedupe identical chains (each link of a chain matches)
256
+ let sig = ops
257
+ .iter()
258
+ .map(|o| match o {
259
+ RouterOp::Route(r) => format!("R{} {}", r.verb, r.raw_path),
260
+ RouterOp::Nest { prefix, target, .. } => format!("N{} {}", prefix, target),
261
+ RouterOp::Merge { target, .. } => format!("M{}", target),
262
+ RouterOp::MacroRoutes { paths, .. } => {
263
+ format!("MR{}", paths.join(","))
264
+ }
265
+ })
266
+ .collect::<Vec<_>>()
267
+ .join("|");
268
+ if !seen_chains.insert(sig) {
269
+ continue;
270
+ }
271
+ // key = the fn NAME: `nest("/api", routes::api_router())` targets a
272
+ // router returned from a fn — the fn name IS the router key
273
+ facts.push(RouterFact {
274
+ key: item.name.clone(),
275
+ ops,
276
+ });
277
+ }
278
+ // router vars bound to chains
279
+ for var in &router_vars {
280
+ if let Some(root) = find_router_var_expr(body, var) {
281
+ let ops = chain_ops(&root, pf, &router_vars, fn_id);
282
+ if !ops.is_empty() {
283
+ facts.push(RouterFact {
284
+ key: var.clone(),
285
+ ops,
286
+ });
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ enum FnAst<'a> {
293
+ Free(&'a syn::ItemFn),
294
+ Impl(&'a syn::ImplItemFn),
295
+ }
296
+
297
+ /// Find the fn item in the AST matching a ParsedItem (name + start line).
298
+ fn find_fn_ast<'a>(file: &'a syn::File, item: &ParsedItem) -> Option<FnAst<'a>> {
299
+ fn walk<'a>(items: &'a [syn::Item], item: &ParsedItem) -> Option<FnAst<'a>> {
300
+ for it in items {
301
+ match it {
302
+ syn::Item::Fn(f) => {
303
+ if f.sig.ident == item.name
304
+ && f.sig.span().start().line as i64 == item.start_line
305
+ {
306
+ return Some(FnAst::Free(f));
307
+ }
308
+ }
309
+ syn::Item::Impl(im) => {
310
+ for ii in &im.items {
311
+ if let syn::ImplItem::Fn(ifn) = ii {
312
+ if ifn.sig.ident == item.name
313
+ && ifn.sig.span().start().line as i64 == item.start_line
314
+ {
315
+ return Some(FnAst::Impl(ifn));
316
+ }
317
+ }
318
+ }
319
+ }
320
+ syn::Item::Mod(m) => {
321
+ if let Some((_, inner)) = &m.content {
322
+ if let Some(hit) = walk(inner, item) {
323
+ return Some(hit);
324
+ }
325
+ }
326
+ }
327
+ _ => {}
328
+ }
329
+ }
330
+ None
331
+ }
332
+ walk(&file.items, item)
333
+ }
334
+
335
+ /// Walk a body: closure/let-var bindings, router var bindings, router chains.
336
+ ///
337
+ /// Iterates STATEMENTS directly (not exprs derived from `Stmt::Local`'s
338
+ /// init). Syn distinguishes `Stmt::Local` (a let STATEMENT:
339
+ /// `let x = ...;`/`let (a, b) = ...;`) from `Expr::Let` (a let-CHAIN, only
340
+ /// valid inside `if`/`while` conditions: `if let Some(x) = ...`). The previous
341
+ /// seed collapsed `Stmt::Local` → `&init.expr`, so the `Expr::Let` arm below
342
+ /// only fired for let-chains (rare) — `let (router, openapi) = ...` and plain
343
+ /// `let app = Router::new()` statements NEVER registered their bindings,
344
+ /// silently killing router_vars. Fix: handle `Stmt::Local` directly for
345
+ /// `Pat::Ident`/`Pat::Tuple`, and descend into `Stmt::Expr` for chains.
346
+ #[allow(clippy::collapsible_if)]
347
+ fn collect_bindings(
348
+ body: &syn::Block,
349
+ fn_id: &str,
350
+ pf: &ParsedFile,
351
+ func_lit_vars: &mut HashMap<(String, String), String>,
352
+ router_vars: &mut HashSet<String>,
353
+ chain_exprs: &mut Vec<syn::Expr>,
354
+ ) {
355
+ // reversed seed → pop() yields FORWARD order, so let-shadowing
356
+ // (`let router = ...; let router = router.route(...)`) and reassignment
357
+ // resolve in source order
358
+ let mut stack: Vec<StmtNode> = body
359
+ .stmts
360
+ .iter()
361
+ .rev()
362
+ .filter_map(|s| match s {
363
+ syn::Stmt::Local(l) => Some(StmtNode::Local(l)),
364
+ syn::Stmt::Expr(e, _) => Some(StmtNode::Expr(e)),
365
+ _ => None,
366
+ })
367
+ .collect();
368
+ let mut seen: HashSet<usize> = HashSet::new();
369
+ while let Some(node) = stack.pop() {
370
+ match node {
371
+ StmtNode::Local(l) => {
372
+ // let STATEMENT: `let pat = init;`
373
+ let Some(init) = &l.init else {
374
+ continue;
375
+ };
376
+ let expr = &*init.expr;
377
+ match &l.pat {
378
+ syn::Pat::Ident(pid) => {
379
+ let name = pid.ident.to_string();
380
+ if is_router_expr(expr, router_vars) {
381
+ router_vars.insert(name.clone());
382
+ chain_exprs.push(expr.clone());
383
+ }
384
+ if let syn::Expr::Closure(_) = expr {
385
+ func_lit_vars.insert((pf.rel_path.clone(), name), fn_id.to_string());
386
+ }
387
+ }
388
+ syn::Pat::Tuple(t) if is_router_expr(expr, router_vars) => {
389
+ // `let (router, openapi) = BaseOpenApi::router()` —
390
+ // utoipa-axum's documented (Router, OpenApi) return;
391
+ // the FIRST binding is the router
392
+ if let Some(syn::Pat::Ident(pid)) = t.elems.first() {
393
+ router_vars.insert(pid.ident.to_string());
394
+ }
395
+ chain_exprs.push(expr.clone());
396
+ }
397
+ _ => {}
398
+ }
399
+ // descend into nested blocks/calls for inner router chains
400
+ push_children_stmt(expr, &mut stack);
401
+ }
402
+ StmtNode::Expr(e) => {
403
+ let ptr = e as *const syn::Expr as usize;
404
+ if !seen.insert(ptr) {
405
+ continue;
406
+ }
407
+ match e {
408
+ syn::Expr::Let(l) => {
409
+ // let-CHAIN (inside if/while conditions): same shape
410
+ // as a let statement but parsed as an expression
411
+ if let syn::Pat::Ident(pid) = l.pat.as_ref() {
412
+ let name = pid.ident.to_string();
413
+ if is_router_expr(&l.expr, router_vars) {
414
+ router_vars.insert(name.clone());
415
+ chain_exprs.push((*l.expr).clone());
416
+ }
417
+ if let syn::Expr::Closure(_) = &*l.expr {
418
+ func_lit_vars
419
+ .insert((pf.rel_path.clone(), name), fn_id.to_string());
420
+ }
421
+ } else if let syn::Pat::Tuple(t) = l.pat.as_ref() {
422
+ if is_router_expr(&l.expr, router_vars) {
423
+ if let Some(syn::Pat::Ident(pid)) = t.elems.first() {
424
+ router_vars.insert(pid.ident.to_string());
425
+ }
426
+ chain_exprs.push((*l.expr).clone());
427
+ }
428
+ }
429
+ }
430
+ syn::Expr::Assign(a) => {
431
+ // `router = router.route(...)` — axum builder
432
+ // reassignment (documented pattern; crates.io
433
+ // router.rs registers the dev git-index route this way)
434
+ if let syn::Expr::Path(p) = &*a.left {
435
+ if p.qself.is_none()
436
+ && p.path.segments.len() == 1
437
+ && router_vars.contains(&p.path.segments[0].ident.to_string())
438
+ && is_router_expr(&a.right, router_vars)
439
+ {
440
+ chain_exprs.push((*a.right).clone());
441
+ }
442
+ }
443
+ }
444
+ syn::Expr::MethodCall(mc) if is_router_expr(&mc.receiver, router_vars) => {
445
+ chain_exprs.push(e.clone());
446
+ }
447
+ _ => {}
448
+ }
449
+ push_children_stmt(e, &mut stack);
450
+ }
451
+ }
452
+ }
453
+ }
454
+
455
+ #[derive(Clone, Copy)]
456
+ enum StmtNode<'a> {
457
+ Local(&'a syn::Local),
458
+ Expr(&'a syn::Expr),
459
+ }
460
+
461
+ /// Push child expressions onto the stack for continued traversal.
462
+ /// (children pushed reversed → forward order on pop)
463
+ fn push_children_stmt<'a>(e: &'a syn::Expr, stack: &mut Vec<StmtNode<'a>>) {
464
+ match e {
465
+ syn::Expr::Block(b) => {
466
+ for s in b.block.stmts.iter().rev() {
467
+ match s {
468
+ syn::Stmt::Local(l) => stack.push(StmtNode::Local(l)),
469
+ syn::Stmt::Expr(ie, _) => stack.push(StmtNode::Expr(ie)),
470
+ _ => {}
471
+ }
472
+ }
473
+ }
474
+ syn::Expr::Let(l) => stack.push(StmtNode::Expr(&l.expr)),
475
+ syn::Expr::Call(c) => {
476
+ stack.push(StmtNode::Expr(&c.func));
477
+ for a in c.args.iter().rev() {
478
+ stack.push(StmtNode::Expr(a));
479
+ }
480
+ }
481
+ syn::Expr::MethodCall(mc) => {
482
+ stack.push(StmtNode::Expr(&mc.receiver));
483
+ for a in mc.args.iter().rev() {
484
+ stack.push(StmtNode::Expr(a));
485
+ }
486
+ }
487
+ _ => {}
488
+ }
489
+ }
490
+
491
+ /// Is `e` a router constructor call? Covers:
492
+ /// - `Router::new()` / `axum::Router::new()` / `Router::<S>::new()`
493
+ /// (path segments ... Router::new)
494
+ /// - `BaseOpenApi::router()` — utoipa-axum's documented (Router, OpenApi)
495
+ /// constructor; also accepts a bare `router()` free-fn call (a local
496
+ /// builder fn returning Router — heuristic, only matters when the result
497
+ /// is then chained with .route()/.nest()/.routes())
498
+ fn is_router_new(e: &syn::Expr) -> bool {
499
+ let syn::Expr::Call(c) = e else {
500
+ return false;
501
+ };
502
+ let syn::Expr::Path(p) = &*c.func else {
503
+ return false;
504
+ };
505
+ if p.qself.is_some() {
506
+ return false;
507
+ }
508
+ let segs = &p.path.segments;
509
+ if segs.is_empty() {
510
+ return false;
511
+ }
512
+ let last = segs.last().map(|s| s.ident.to_string()).unwrap_or_default();
513
+ if last == "router" {
514
+ return true;
515
+ }
516
+ last == "new"
517
+ && segs
518
+ .get(segs.len() - 2)
519
+ .map(|s| s.ident == "Router")
520
+ .unwrap_or(false)
521
+ }
522
+
523
+ fn is_router_expr(e: &syn::Expr, router_vars: &HashSet<String>) -> bool {
524
+ match e {
525
+ syn::Expr::Call(c) => {
526
+ is_router_new(e) || {
527
+ match &*c.func {
528
+ syn::Expr::MethodCall(mc) => is_router_expr(&mc.receiver, router_vars),
529
+ _ => false,
530
+ }
531
+ }
532
+ }
533
+ syn::Expr::MethodCall(mc) => is_router_expr(&mc.receiver, router_vars),
534
+ syn::Expr::Path(p) => {
535
+ p.qself.is_none()
536
+ && p.path.segments.len() == 1
537
+ && router_vars.contains(&p.path.segments[0].ident.to_string())
538
+ }
539
+ _ => false,
540
+ }
541
+ }
542
+
543
+ fn find_router_var_expr(body: &syn::Block, var: &str) -> Option<syn::Expr> {
544
+ for stmt in &body.stmts {
545
+ if let syn::Stmt::Local(l) = stmt {
546
+ if let syn::Pat::Ident(pid) = &l.pat {
547
+ if pid.ident == var {
548
+ if let Some(init) = &l.init {
549
+ return Some((*init.expr).clone());
550
+ }
551
+ }
552
+ }
553
+ }
554
+ }
555
+ None
556
+ }
557
+
558
+ // ── utoipa-axum `.routes(routes!(...))` ──
559
+ //
560
+ // utoipa-axum (the standard OpenAPI router for axum; used by crates.io in
561
+ // production) registers handlers in BATCHES: `router.routes(routes!(a::b::c,
562
+ // d::e::f))`. The method + path for each handler live in its
563
+ // `#[utoipa::path(get, path = "/api/v1/x", ...)]` attribute — the documented
564
+ // utoipa contract. Handlers without the attribute (not OpenAPI'd) are
565
+ // skipped — their route info simply doesn't exist in the source.
566
+
567
+ /// Expand MacroRoutes ops (raw handler paths) into RouteOps by reading each
568
+ /// handler's `#[utoipa::path]` attribute.
569
+ fn expand_macro_routes(
570
+ facts: &mut [RouterFact],
571
+ repo: &ParsedRepo,
572
+ module_map: &ModuleMap,
573
+ lookup: &LookupMaps,
574
+ mf: &ParsedManifest,
575
+ tp: &mut ThirdPartyRegistry,
576
+ ) {
577
+ for f in facts.iter_mut() {
578
+ let mut out: Vec<RouterOp> = vec![];
579
+ for op in std::mem::take(&mut f.ops) {
580
+ match op {
581
+ RouterOp::MacroRoutes {
582
+ paths,
583
+ file,
584
+ line,
585
+ reg_fn,
586
+ } => {
587
+ let base_module = crate::walker::module_path_for_file(&file);
588
+ for p in paths {
589
+ if let Some(op2) = utoipa_route_op(
590
+ &p,
591
+ &file,
592
+ &base_module,
593
+ line,
594
+ &reg_fn,
595
+ repo,
596
+ module_map,
597
+ lookup,
598
+ mf,
599
+ tp,
600
+ ) {
601
+ out.push(op2);
602
+ }
603
+ }
604
+ }
605
+ other => out.push(other),
606
+ }
607
+ }
608
+ f.ops = out;
609
+ }
610
+ }
611
+
612
+ /// Resolve ONE `routes!(...)` handler path to a RouteOp (method+path from the
613
+ /// handler's `#[utoipa::path]` attribute).
614
+ #[allow(clippy::too_many_arguments)]
615
+ fn utoipa_route_op(
616
+ handler_path: &str,
617
+ file: &str,
618
+ base_module: &str,
619
+ line: i64,
620
+ reg_fn: &str,
621
+ repo: &ParsedRepo,
622
+ module_map: &ModuleMap,
623
+ lookup: &LookupMaps,
624
+ mf: &ParsedManifest,
625
+ tp: &mut ThirdPartyRegistry,
626
+ ) -> Option<RouterOp> {
627
+ let node_id = crate::calls::resolve_path_call(
628
+ handler_path,
629
+ file,
630
+ base_module,
631
+ module_map,
632
+ lookup,
633
+ mf,
634
+ tp,
635
+ )?;
636
+ let (hfile, hname) = node_id.rsplit_once("::")?;
637
+ let item = repo
638
+ .files
639
+ .iter()
640
+ .find(|pf| pf.rel_path == hfile)
641
+ .and_then(|pf| {
642
+ pf.items
643
+ .iter()
644
+ .find(|it| it.name == hname && it.kind == crate::parser::KIND_FUNCTION)
645
+ })?;
646
+ let (verb, path) = utoipa_attr(&item.attrs)?;
647
+ Some(RouterOp::Route(RouteOp {
648
+ verb,
649
+ raw_path: path,
650
+ handler: HandlerRef::Ident(hname.to_string()),
651
+ handler_name: hname.to_string(),
652
+ line,
653
+ file: file.to_string(),
654
+ reg_fn: reg_fn.to_string(),
655
+ }))
656
+ }
657
+
658
+ /// Parse `#[utoipa::path(get, path = "/api/v1/crates", ...)]` → (verb, path).
659
+ /// Returns None when the attribute is absent or lacks path/method info.
660
+ fn utoipa_attr(attrs: &[crate::parser::AttrInfo]) -> Option<(String, String)> {
661
+ for a in attrs {
662
+ if a.path != "utoipa::path" {
663
+ continue;
664
+ }
665
+ // first token = the HTTP verb ident (`get`, `post`, `delete`, ...).
666
+ // utoipa's #[utoipa::path(get, path = "/x", ...)] documents the verb
667
+ // as a bare ident; normalize to uppercase for parity with axum's
668
+ // get()/post()/... combinators and the route node contract.
669
+ let verb = a
670
+ .args
671
+ .split(|c: char| c == ',' || c.is_whitespace())
672
+ .next()?
673
+ .trim()
674
+ .to_uppercase();
675
+ if verb.is_empty() {
676
+ return None;
677
+ }
678
+ // `path = "/x"` — tokens render with spaces; normalize `path=` too
679
+ let norm: String = a.args.split_whitespace().collect::<Vec<_>>().join(" ");
680
+ let norm = norm.replace("path=", "path = ");
681
+ let path = norm
682
+ .split("path = ")
683
+ .nth(1)?
684
+ .trim_start_matches('"')
685
+ .split('"')
686
+ .next()?
687
+ .to_string();
688
+ if path.is_empty() {
689
+ return None;
690
+ }
691
+ return Some((verb, path));
692
+ }
693
+ None
694
+ }
695
+
696
+ /// Parse `routes!(a::b::c, d::e::f)` macro tokens → ["a::b::c", "d::e::f"].
697
+ fn macro_path_args(tokens: &proc_macro2::TokenStream) -> Vec<String> {
698
+ let mut out = vec![];
699
+ let mut cur: Vec<String> = vec![];
700
+ for tt in tokens.clone() {
701
+ match tt {
702
+ proc_macro2::TokenTree::Ident(i) => cur.push(i.to_string()),
703
+ proc_macro2::TokenTree::Punct(p) => match p.as_char() {
704
+ ':' => {
705
+ if cur.last().map(|s| s != "::").unwrap_or(false) {
706
+ cur.push("::".to_string());
707
+ }
708
+ // second ':' of a path separator — already recorded
709
+ }
710
+ ',' if !cur.is_empty() => {
711
+ out.push(cur.join(""));
712
+ cur.clear();
713
+ }
714
+ _ => {}
715
+ },
716
+ _ => {}
717
+ }
718
+ }
719
+ if !cur.is_empty() {
720
+ out.push(cur.join(""));
721
+ }
722
+ out
723
+ }
724
+
725
+ /// Extract route/nest/merge ops from a router chain expr (walking INWARD
726
+ /// along receivers — the outermost MethodCall first).
727
+ fn chain_ops(
728
+ outer: &syn::Expr,
729
+ pf: &ParsedFile,
730
+ router_vars: &HashSet<String>,
731
+ fn_id: &str,
732
+ ) -> Vec<RouterOp> {
733
+ let mut ops = vec![];
734
+ let mut current = outer.clone();
735
+ while let syn::Expr::MethodCall(mc) = &current {
736
+ let method = mc.method.to_string();
737
+ let line = mc.span().start().line as i64;
738
+ match method.as_str() {
739
+ "route" => {
740
+ if mc.args.len() >= 2 {
741
+ let path = lit_str(&mc.args[0]);
742
+ let handler_arg = &mc.args[1];
743
+ let ops2 = method_router_ops(handler_arg, &path, pf, line, fn_id);
744
+ ops.extend(ops2);
745
+ }
746
+ }
747
+ "routes" => {
748
+ // utoipa-axum: `.routes(routes!(h1, h2, ...))` — batch
749
+ // registration; method+path come from each handler's
750
+ // #[utoipa::path(...)] attribute (resolved later)
751
+ if let Some(syn::Expr::Macro(m)) = mc.args.first() {
752
+ let is_routes_macro = m
753
+ .mac
754
+ .path
755
+ .segments
756
+ .last()
757
+ .map(|s| s.ident == "routes")
758
+ .unwrap_or(false);
759
+ if is_routes_macro {
760
+ let paths = macro_path_args(&m.mac.tokens);
761
+ if !paths.is_empty() {
762
+ ops.push(RouterOp::MacroRoutes {
763
+ paths,
764
+ file: pf.rel_path.clone(),
765
+ line,
766
+ reg_fn: fn_id.to_string(),
767
+ });
768
+ }
769
+ }
770
+ }
771
+ }
772
+ "nest" => {
773
+ if mc.args.len() >= 2 {
774
+ let prefix = lit_str(&mc.args[0]);
775
+ let target = expr_ident(&mc.args[1]);
776
+ ops.push(RouterOp::Nest { prefix, target });
777
+ }
778
+ }
779
+ "merge" => {
780
+ if let Some(target) = mc.args.first() {
781
+ let target = expr_ident(target);
782
+ ops.push(RouterOp::Merge { target });
783
+ }
784
+ }
785
+ // with_state / layer / route_layer / fallback — not routes
786
+ _ => {}
787
+ }
788
+ match &*mc.receiver {
789
+ syn::Expr::MethodCall(inner) => current = syn::Expr::MethodCall(inner.clone()),
790
+ syn::Expr::Call(inner) => current = syn::Expr::Call(inner.clone()),
791
+ syn::Expr::Path(p) => {
792
+ if p.qself.is_none()
793
+ && p.path.segments.len() == 1
794
+ && router_vars.contains(&p.path.segments[0].ident.to_string())
795
+ {
796
+ break;
797
+ }
798
+ break;
799
+ }
800
+ _ => break,
801
+ }
802
+ }
803
+ ops
804
+ }
805
+
806
+ /// Expand a MethodRouter combinator: `get(h).post(h2)` → [GET h, POST h2];
807
+ /// plain fn/ident → ANY (axum registers all methods).
808
+ fn method_router_ops(
809
+ e: &syn::Expr,
810
+ path: &str,
811
+ pf: &ParsedFile,
812
+ line: i64,
813
+ fn_id: &str,
814
+ ) -> Vec<RouterOp> {
815
+ let mut out = vec![];
816
+ match e {
817
+ syn::Expr::Call(c) => {
818
+ if let syn::Expr::Path(p) = &*c.func {
819
+ if p.qself.is_none() {
820
+ if let Some(verb) = verb_for(&p.path) {
821
+ out.push(route_op(&verb, path, c.args.first(), pf, line, fn_id));
822
+ }
823
+ }
824
+ }
825
+ }
826
+ syn::Expr::MethodCall(mc) => {
827
+ // chain: get(h).post(h2) — root first, then chained methods
828
+ let mut chain: Vec<&syn::ExprMethodCall> = vec![mc];
829
+ let mut recv = &mc.receiver;
830
+ while let syn::Expr::MethodCall(inner) = &**recv {
831
+ chain.push(inner);
832
+ recv = &inner.receiver;
833
+ }
834
+ for m in chain.iter().rev() {
835
+ if let Some(verb) = verb_for_method(&m.method.to_string()) {
836
+ out.push(route_op(&verb, path, m.args.first(), pf, line, fn_id));
837
+ }
838
+ }
839
+ if let syn::Expr::Call(c) = &**recv {
840
+ if let syn::Expr::Path(p) = &*c.func {
841
+ if p.qself.is_none() {
842
+ if let Some(verb) = verb_for(&p.path) {
843
+ out.push(route_op(&verb, path, c.args.first(), pf, line, fn_id));
844
+ }
845
+ }
846
+ }
847
+ }
848
+ }
849
+ _ => {
850
+ // plain handler → ALL methods
851
+ out.push(route_op("ANY", path, Some(e), pf, line, fn_id));
852
+ }
853
+ }
854
+ out
855
+ }
856
+
857
+ fn verb_for(p: &syn::Path) -> Option<String> {
858
+ verb_for_method(&p.segments.last()?.ident.to_string())
859
+ }
860
+
861
+ fn verb_for_method(m: &str) -> Option<String> {
862
+ match m {
863
+ "get" => Some("GET".to_string()),
864
+ "post" => Some("POST".to_string()),
865
+ "put" => Some("PUT".to_string()),
866
+ "delete" => Some("DELETE".to_string()),
867
+ "patch" => Some("PATCH".to_string()),
868
+ "options" => Some("OPTIONS".to_string()),
869
+ "head" => Some("HEAD".to_string()),
870
+ "any" => Some("ANY".to_string()),
871
+ _ => None,
872
+ }
873
+ }
874
+
875
+ fn route_op(
876
+ verb: &str,
877
+ path: &str,
878
+ handler: Option<&syn::Expr>,
879
+ pf: &ParsedFile,
880
+ line: i64,
881
+ fn_id: &str,
882
+ ) -> RouterOp {
883
+ let (hr, name) = match handler {
884
+ Some(syn::Expr::Path(p)) if p.qself.is_none() && p.path.segments.len() == 1 => (
885
+ HandlerRef::Ident(p.path.segments[0].ident.to_string()),
886
+ p.path.segments[0].ident.to_string(),
887
+ ),
888
+ Some(syn::Expr::Path(p)) if p.qself.is_none() => (
889
+ HandlerRef::Path(
890
+ p.path
891
+ .segments
892
+ .iter()
893
+ .map(|s| s.ident.to_string())
894
+ .collect::<Vec<_>>()
895
+ .join("::"),
896
+ ),
897
+ p.path
898
+ .segments
899
+ .last()
900
+ .map(|s| s.ident.to_string())
901
+ .unwrap_or_default(),
902
+ ),
903
+ Some(syn::Expr::Closure(_)) => (HandlerRef::Closure, "<closure>".to_string()),
904
+ Some(syn::Expr::MethodCall(mc)) => (HandlerRef::MethodValue, mc.method.to_string()),
905
+ _ => (HandlerRef::Closure, "<expr>".to_string()),
906
+ };
907
+ RouterOp::Route(RouteOp {
908
+ verb: verb.to_string(),
909
+ raw_path: path.to_string(),
910
+ handler: hr,
911
+ handler_name: name,
912
+ line,
913
+ file: pf.rel_path.clone(),
914
+ reg_fn: fn_id.to_string(),
915
+ })
916
+ }
917
+
918
+ fn lit_str(e: &syn::Expr) -> String {
919
+ if let syn::Expr::Lit(syn::ExprLit {
920
+ lit: syn::Lit::Str(s),
921
+ ..
922
+ }) = e
923
+ {
924
+ s.value()
925
+ } else {
926
+ String::new()
927
+ }
928
+ }
929
+
930
+ fn expr_ident(e: &syn::Expr) -> String {
931
+ match e {
932
+ syn::Expr::Path(p) => {
933
+ if p.qself.is_none() && p.path.segments.len() == 1 {
934
+ return p.path.segments[0].ident.to_string();
935
+ }
936
+ }
937
+ // `nest("/api", routes::api_router())` — the target router is the
938
+ // RETURN VALUE of the called fn; the fn name IS the router key
939
+ // (the var fact carries that key — see collect_axum_facts)
940
+ syn::Expr::Call(c) => {
941
+ if let syn::Expr::Path(p) = &*c.func {
942
+ if p.qself.is_none() {
943
+ if let Some(last) = p.path.segments.last() {
944
+ return last.ident.to_string();
945
+ }
946
+ }
947
+ }
948
+ }
949
+ _ => {}
950
+ }
951
+ collapse(e.to_token_stream().to_string())
952
+ }
953
+
954
+ fn collapse(s: String) -> String {
955
+ s.split_whitespace().collect::<Vec<_>>().join(" ")
956
+ }
957
+
958
+ // ── emission ──
959
+
960
+ fn emit_route(
961
+ r: &RouteOp,
962
+ framework: &str,
963
+ func_lit_vars: &HashMap<(String, String), String>,
964
+ module_map: &ModuleMap,
965
+ lookup: &LookupMaps,
966
+ out: &mut RoutesOutput,
967
+ seen: &mut HashSet<String>,
968
+ ) {
969
+ let (norm, params) = normalize_path(&r.raw_path);
970
+ let is_dynamic = !params.is_empty();
971
+ let id = format!("{}::{} {}", r.file, r.verb, norm);
972
+ if !seen.insert(id.clone()) {
973
+ return;
974
+ }
975
+
976
+ // handler resolution
977
+ let (handler_id, kind) = match &r.handler {
978
+ HandlerRef::Ident(name) => {
979
+ if let Some(id) = lookup.closest_by_path(name, &r.file) {
980
+ (Some(id), "function".to_string())
981
+ } else if let Some(id) = func_lit_vars.get(&(r.file.clone(), name.clone())) {
982
+ (Some(id.clone()), "closure".to_string())
983
+ } else {
984
+ (None, "unresolved".to_string())
985
+ }
986
+ }
987
+ HandlerRef::Path(path) => {
988
+ let base_module = crate::walker::module_path_for_file(&r.file);
989
+ if let Some((file, rest)) = module_map.resolve(path, &base_module) {
990
+ if rest.is_empty() {
991
+ (lookup.closest_by_path(path, &file), "function".to_string())
992
+ } else {
993
+ let parts: Vec<&str> = rest.split("::").collect();
994
+ if parts.len() == 1 {
995
+ // plain fn in the resolved module
996
+ (
997
+ lookup.closest_by_path(parts[0], &file),
998
+ "function".to_string(),
999
+ )
1000
+ } else {
1001
+ // Type::method
1002
+ let mid = method_id_for(parts[0], parts[1], &file, lookup);
1003
+ (mid, "method".to_string())
1004
+ }
1005
+ }
1006
+ } else {
1007
+ (None, "unresolved".to_string())
1008
+ }
1009
+ }
1010
+ HandlerRef::Closure => (Some(r.reg_fn.clone()), "closure".to_string()),
1011
+ HandlerRef::MethodValue => (None, "unresolved".to_string()),
1012
+ };
1013
+
1014
+ let handler_name = r.handler_name.clone();
1015
+
1016
+ let mut meta = serde_json::Map::new();
1017
+ meta.insert("urlPath".to_string(), norm.clone().into());
1018
+ meta.insert("httpMethod".to_string(), r.verb.clone().into());
1019
+ meta.insert("isDynamic".to_string(), is_dynamic.into());
1020
+ meta.insert(
1021
+ "params".to_string(),
1022
+ serde_json::Value::Array(
1023
+ params
1024
+ .iter()
1025
+ .map(|p| serde_json::Value::from(p.clone()))
1026
+ .collect(),
1027
+ ),
1028
+ );
1029
+ meta.insert("framework".to_string(), framework.into());
1030
+ meta.insert("handlerName".to_string(), handler_name.clone().into());
1031
+ meta.insert("routeKind".to_string(), "backend".into());
1032
+ meta.insert("rawPath".to_string(), r.raw_path.clone().into());
1033
+
1034
+ let node = crate::contract::code_node(
1035
+ &id,
1036
+ &r.file,
1037
+ &format!("{} {}", r.verb, norm),
1038
+ NODE_ROUTE,
1039
+ r.line,
1040
+ r.line,
1041
+ "",
1042
+ meta,
1043
+ );
1044
+ out.route_nodes.push(node);
1045
+
1046
+ if let Some(hid) = handler_id {
1047
+ let mut m = serde_json::Map::new();
1048
+ m.insert("urlPath".to_string(), norm.clone().into());
1049
+ m.insert("httpMethod".to_string(), r.verb.clone().into());
1050
+ m.insert("framework".to_string(), framework.into());
1051
+ m.insert("handlerKind".to_string(), kind.into());
1052
+ out.handles_edges
1053
+ .push(edge_with_meta(&id, &hid, "HANDLES", m));
1054
+ }
1055
+
1056
+ out.backend_routes.push(RouteNode {
1057
+ route_type: "BACKEND_ROUTE".to_string(),
1058
+ url_path: norm.clone(),
1059
+ file_path: r.file.clone(),
1060
+ http_method: r.verb.clone(),
1061
+ framework: framework.to_string(),
1062
+ is_dynamic,
1063
+ params,
1064
+ handler_name,
1065
+ node_id: id,
1066
+ });
1067
+ }
1068
+
1069
+ fn method_id_for(owner: &str, method: &str, file: &str, lookup: &LookupMaps) -> Option<String> {
1070
+ let ids = lookup.methods_by_owner.get(owner)?;
1071
+ let matches = |id: &String| {
1072
+ id.ends_with(&format!(".{}", method)) || id.contains(&format!(".{}::", method))
1073
+ };
1074
+ ids.iter()
1075
+ .find(|id| id.starts_with(&format!("{}::", file)) && matches(id))
1076
+ .or_else(|| ids.iter().find(|id| matches(id)))
1077
+ .cloned()
1078
+ }
1079
+
1080
+ /// :param / {param} / <param> / *wild → {param}; collapses double slashes.
1081
+ fn normalize_path(p: &str) -> (String, Vec<String>) {
1082
+ let mut params = vec![];
1083
+ let mut out = String::new();
1084
+ let chars: Vec<char> = p.chars().collect();
1085
+ let mut i = 0;
1086
+ while i < chars.len() {
1087
+ let c = chars[i];
1088
+ if c == ':' || c == '*' {
1089
+ let mut name = String::new();
1090
+ i += 1;
1091
+ while i < chars.len() && chars[i] != '/' {
1092
+ name.push(chars[i]);
1093
+ i += 1;
1094
+ }
1095
+ params.push(name.clone());
1096
+ out.push_str(&format!("{{{}}}", name));
1097
+ } else if c == '{' || c == '<' {
1098
+ let close = if c == '{' { '}' } else { '>' };
1099
+ let mut name = String::new();
1100
+ i += 1;
1101
+ while i < chars.len() && chars[i] != close {
1102
+ name.push(chars[i]);
1103
+ i += 1;
1104
+ }
1105
+ i += 1; // skip close
1106
+ params.push(name.clone());
1107
+ out.push_str(&format!("{{{}}}", name));
1108
+ } else {
1109
+ out.push(c);
1110
+ i += 1;
1111
+ }
1112
+ }
1113
+ while out.contains("//") {
1114
+ out = out.replace("//", "/");
1115
+ }
1116
+ if out.is_empty() {
1117
+ out = "/".to_string();
1118
+ }
1119
+ (out, params)
1120
+ }
1121
+
1122
+ fn compose_paths(prefix: &str, path: &str) -> String {
1123
+ if prefix.is_empty() {
1124
+ return path.to_string();
1125
+ }
1126
+ format!(
1127
+ "{}/{}",
1128
+ prefix.trim_end_matches('/'),
1129
+ path.trim_start_matches('/')
1130
+ )
1131
+ }
1132
+
1133
+ // ── actix-web / rocket attribute macros ──
1134
+
1135
+ const MACRO_VERBS: [(&str, &str); 8] = [
1136
+ ("get", "GET"),
1137
+ ("post", "POST"),
1138
+ ("put", "PUT"),
1139
+ ("delete", "DELETE"),
1140
+ ("patch", "PATCH"),
1141
+ ("head", "HEAD"),
1142
+ ("options", "OPTIONS"),
1143
+ ("any", "ANY"),
1144
+ ];
1145
+
1146
+ fn detect_macro_routes(
1147
+ pf: &ParsedFile,
1148
+ item: &ParsedItem,
1149
+ framework: &str,
1150
+ out: &mut RoutesOutput,
1151
+ seen: &mut HashSet<String>,
1152
+ ) {
1153
+ for attr in &item.attrs {
1154
+ let last = attr.path.rsplit("::").next().unwrap_or("").to_string();
1155
+ let verb = if last == "route" {
1156
+ parse_route_attr(&attr.args)
1157
+ } else {
1158
+ MACRO_VERBS
1159
+ .iter()
1160
+ .find(|(m, _)| *m == last)
1161
+ .map(|(_, v)| v.to_string())
1162
+ };
1163
+ let Some(verb) = verb else { continue };
1164
+ let path = if last == "route" {
1165
+ parse_route_attr_path(&attr.args)
1166
+ } else {
1167
+ parse_lit_from_tokens(&attr.args)
1168
+ };
1169
+ if path.is_empty() {
1170
+ continue;
1171
+ }
1172
+ let (norm, params) = normalize_path(&path);
1173
+ let is_dynamic = !params.is_empty();
1174
+ let id = format!("{}::{} {}", pf.rel_path, verb, norm);
1175
+ if !seen.insert(id.clone()) {
1176
+ continue;
1177
+ }
1178
+ let handler_id = crate::lookup::node_id_for_item(&pf.rel_path, item);
1179
+
1180
+ let mut meta = serde_json::Map::new();
1181
+ meta.insert("urlPath".to_string(), norm.clone().into());
1182
+ meta.insert("httpMethod".to_string(), verb.clone().into());
1183
+ meta.insert("isDynamic".to_string(), is_dynamic.into());
1184
+ meta.insert(
1185
+ "params".to_string(),
1186
+ serde_json::Value::Array(
1187
+ params
1188
+ .iter()
1189
+ .map(|p| serde_json::Value::from(p.clone()))
1190
+ .collect(),
1191
+ ),
1192
+ );
1193
+ meta.insert("framework".to_string(), framework.into());
1194
+ meta.insert("handlerName".to_string(), item.name.clone().into());
1195
+ meta.insert("routeKind".to_string(), "backend".into());
1196
+ meta.insert("rawPath".to_string(), path.clone().into());
1197
+
1198
+ let node = crate::contract::code_node(
1199
+ &id,
1200
+ &pf.rel_path,
1201
+ &format!("{} {}", verb, norm),
1202
+ NODE_ROUTE,
1203
+ item.start_line,
1204
+ item.end_line,
1205
+ "",
1206
+ meta,
1207
+ );
1208
+ out.route_nodes.push(node);
1209
+
1210
+ let mut m = serde_json::Map::new();
1211
+ m.insert("urlPath".to_string(), norm.clone().into());
1212
+ m.insert("httpMethod".to_string(), verb.clone().into());
1213
+ m.insert("framework".to_string(), framework.into());
1214
+ m.insert("handlerKind".to_string(), "function".into());
1215
+ out.handles_edges
1216
+ .push(edge_with_meta(&id, &handler_id, "HANDLES", m));
1217
+
1218
+ out.backend_routes.push(RouteNode {
1219
+ route_type: "BACKEND_ROUTE".to_string(),
1220
+ url_path: norm.clone(),
1221
+ file_path: pf.rel_path.clone(),
1222
+ http_method: verb.clone(),
1223
+ framework: framework.to_string(),
1224
+ is_dynamic,
1225
+ params,
1226
+ handler_name: item.name.clone(),
1227
+ node_id: id,
1228
+ });
1229
+ }
1230
+ }
1231
+
1232
+ fn parse_lit_from_tokens(tokens: &str) -> String {
1233
+ // `"/users/{id}"` — tokens render with quotes; strip them
1234
+ let t = tokens.trim();
1235
+ if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
1236
+ t[1..t.len() - 1].to_string()
1237
+ } else {
1238
+ String::new()
1239
+ }
1240
+ }
1241
+
1242
+ fn parse_route_attr(tokens: &str) -> Option<String> {
1243
+ // `"/path", method = "GET"` → GET
1244
+ let parts: Vec<&str> = tokens.split(',').collect();
1245
+ for part in parts {
1246
+ let part = part.trim();
1247
+ if let Some((_, v)) = part.split_once('=') {
1248
+ let v = v.trim().trim_matches('"').to_uppercase();
1249
+ if v == "GET" || v == "POST" || v == "PUT" || v == "DELETE" || v == "PATCH" {
1250
+ return Some(v);
1251
+ }
1252
+ }
1253
+ }
1254
+ None
1255
+ }
1256
+
1257
+ fn parse_route_attr_path(tokens: &str) -> String {
1258
+ let t = tokens.trim();
1259
+ if t.len() >= 2 && t.starts_with('"') {
1260
+ if let Some(end) = t[1..].find('"') {
1261
+ return t[1..1 + end].to_string();
1262
+ }
1263
+ }
1264
+ String::new()
1265
+ }