devlensio 0.6.2 → 1.0.0
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.
- package/README.md +89 -31
- package/dist/extractors/detectLanguage.d.ts +2 -0
- package/dist/extractors/detectLanguage.js +33 -0
- package/dist/extractors/index.d.ts +6 -0
- package/dist/extractors/index.js +116 -0
- package/dist/extractors/runner.d.ts +7 -0
- package/dist/extractors/runner.js +194 -0
- package/dist/extractors/types.d.ts +37 -0
- package/dist/extractors/types.js +8 -0
- package/dist/graph/buildLookup.d.ts +1 -0
- package/dist/graph/buildLookup.js +2 -1
- package/dist/graph/edges/callEdges.js +19 -5
- package/dist/graph/edges/callEdges.test.d.ts +1 -0
- package/dist/graph/edges/callEdges.test.js +200 -0
- package/dist/graph/edges/importEdges.js +12 -0
- package/dist/graph/edges/inheritanceEdges.d.ts +3 -0
- package/dist/graph/edges/inheritanceEdges.js +73 -0
- package/dist/graph/edges/inheritanceEdges.test.d.ts +1 -0
- package/dist/graph/edges/inheritanceEdges.test.js +140 -0
- package/dist/graph/index.js +4 -0
- package/dist/parser/classes.test.d.ts +1 -0
- package/dist/parser/classes.test.js +360 -0
- package/dist/parser/extractors/classes.d.ts +5 -0
- package/dist/parser/extractors/classes.js +241 -0
- package/dist/parser/index.d.ts +2 -0
- package/dist/parser/index.js +7 -1
- package/dist/pipeline/index.d.ts +2 -1
- package/dist/pipeline/index.js +30 -42
- package/dist/scoring/index.js +8 -0
- package/dist/scoring/index.test.js +43 -0
- package/dist/scoring/nodeScorer.js +2 -0
- package/dist/scoring/pruneDisconnected.d.ts +8 -0
- package/dist/scoring/pruneDisconnected.js +66 -0
- package/dist/scoring/pruneDisconnected.test.d.ts +1 -0
- package/dist/scoring/pruneDisconnected.test.js +123 -0
- package/dist/summarizer/prompts.d.ts +1 -1
- package/dist/types.d.ts +5 -5
- package/extractors/go/bin/darwin-amd64/devlens_go_extractor +0 -0
- package/extractors/go/bin/darwin-arm64/devlens_go_extractor +0 -0
- package/extractors/go/bin/linux-amd64/devlens_go_extractor +0 -0
- package/extractors/go/bin/linux-arm64/devlens_go_extractor +0 -0
- package/extractors/go/bin/windows-amd64/devlens_go_extractor.exe +0 -0
- package/extractors/go/build.mjs +43 -0
- package/extractors/go/calls.go +289 -0
- package/extractors/go/contract.go +140 -0
- package/extractors/go/extractor.go +161 -0
- package/extractors/go/fingerprint.go +167 -0
- package/extractors/go/go.mod +3 -0
- package/extractors/go/imports.go +90 -0
- package/extractors/go/inheritance.go +138 -0
- package/extractors/go/lookup.go +106 -0
- package/extractors/go/main.go +57 -0
- package/extractors/go/nodes.go +177 -0
- package/extractors/go/orm_edges.go +277 -0
- package/extractors/go/parser.go +469 -0
- package/extractors/go/routes.go +521 -0
- package/extractors/go/tests.go +45 -0
- package/extractors/go/thirdparty.go +138 -0
- package/extractors/go/typeload.go +178 -0
- package/extractors/go/walker.go +67 -0
- package/extractors/java/build.mjs +90 -0
- package/extractors/java/devlens_java_extractor.jar +0 -0
- package/extractors/java/src/devlens/extractor/Contract.java +171 -0
- package/extractors/java/src/devlens/extractor/Extractor.java +262 -0
- package/extractors/java/src/devlens/extractor/ExtractorResult.java +12 -0
- package/extractors/java/src/devlens/extractor/Fingerprint.java +239 -0
- package/extractors/java/src/devlens/extractor/LookupMaps.java +123 -0
- package/extractors/java/src/devlens/extractor/Main.java +66 -0
- package/extractors/java/src/devlens/extractor/Parser.java +522 -0
- package/extractors/java/src/devlens/extractor/SourceWalker.java +82 -0
- package/extractors/java/src/devlens/extractor/ThirdParty.java +141 -0
- package/extractors/java/src/devlens/extractor/TypeSolverFactory.java +43 -0
- package/extractors/java/src/devlens/extractor/edges/Calls.java +266 -0
- package/extractors/java/src/devlens/extractor/edges/Enrich.java +54 -0
- package/extractors/java/src/devlens/extractor/edges/Imports.java +154 -0
- package/extractors/java/src/devlens/extractor/edges/Inheritance.java +79 -0
- package/extractors/java/src/devlens/extractor/edges/OrmEdges.java +209 -0
- package/extractors/java/src/devlens/extractor/edges/Routes.java +143 -0
- package/extractors/java/src/devlens/extractor/edges/Tests.java +53 -0
- package/extractors/python/devlens_extractors_python/__init__.py +3 -0
- package/extractors/python/devlens_extractors_python/__main__.py +33 -0
- package/extractors/python/devlens_extractors_python/contract.py +94 -0
- package/extractors/python/devlens_extractors_python/edges/__init__.py +28 -0
- package/extractors/python/devlens_extractors_python/edges/calls.py +112 -0
- package/extractors/python/devlens_extractors_python/edges/enrich.py +44 -0
- package/extractors/python/devlens_extractors_python/edges/imports.py +155 -0
- package/extractors/python/devlens_extractors_python/edges/inheritance.py +97 -0
- package/extractors/python/devlens_extractors_python/edges/orm_edges.py +225 -0
- package/extractors/python/devlens_extractors_python/edges/routes/__init__.py +28 -0
- package/extractors/python/devlens_extractors_python/edges/routes/common.py +79 -0
- package/extractors/python/devlens_extractors_python/edges/routes/decorators.py +212 -0
- package/extractors/python/devlens_extractors_python/edges/routes/django_urls.py +213 -0
- package/extractors/python/devlens_extractors_python/edges/routes/drf.py +128 -0
- package/extractors/python/devlens_extractors_python/edges/tests.py +53 -0
- package/extractors/python/devlens_extractors_python/extractor.py +107 -0
- package/extractors/python/devlens_extractors_python/fingerprint.py +201 -0
- package/extractors/python/devlens_extractors_python/lookup.py +72 -0
- package/extractors/python/devlens_extractors_python/parser/__init__.py +72 -0
- package/extractors/python/devlens_extractors_python/parser/classes.py +109 -0
- package/extractors/python/devlens_extractors_python/parser/functions.py +163 -0
- package/extractors/python/devlens_extractors_python/parser/walker.py +30 -0
- package/extractors/python/devlens_extractors_python/third_party.py +103 -0
- package/extractors/python/pyproject.toml +16 -0
- package/extractors/python/setup.mjs +54 -0
- package/extractors/rust/Cargo.toml +29 -0
- package/extractors/rust/bin/darwin-amd64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/darwin-arm64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/linux-amd64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/linux-arm64/devlens_rust_extractor +0 -0
- package/extractors/rust/bin/windows-amd64/devlens_rust_extractor.exe +0 -0
- package/extractors/rust/build.mjs +85 -0
- package/extractors/rust/src/calls.rs +295 -0
- package/extractors/rust/src/contract.rs +236 -0
- package/extractors/rust/src/enrich.rs +107 -0
- package/extractors/rust/src/extractor.rs +199 -0
- package/extractors/rust/src/fingerprint.rs +238 -0
- package/extractors/rust/src/imports.rs +101 -0
- package/extractors/rust/src/inheritance.rs +233 -0
- package/extractors/rust/src/lookup.rs +226 -0
- package/extractors/rust/src/main.rs +53 -0
- package/extractors/rust/src/module_map.rs +131 -0
- package/extractors/rust/src/nodes.rs +174 -0
- package/extractors/rust/src/orm_edges.rs +125 -0
- package/extractors/rust/src/parser.rs +859 -0
- package/extractors/rust/src/routes.rs +1265 -0
- package/extractors/rust/src/tests.rs +109 -0
- package/extractors/rust/src/thirdparty.rs +114 -0
- package/extractors/rust/src/walker.rs +82 -0
- 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
|
+
®_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) = ¤t {
|
|
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
|
+
}
|