Skip to main content

lean_ctx/core/
route_extractor.rs

1use std::path::Path;
2
3use serde::{Deserialize, Serialize};
4
5macro_rules! static_regex {
6    ($pattern:expr_2021) => {{
7        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
8        RE.get_or_init(|| {
9            regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
10        })
11    }};
12}
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct RouteEntry {
16    pub method: String,
17    pub path: String,
18    pub handler: String,
19    pub file: String,
20    pub line: usize,
21}
22
23pub fn extract_routes_from_file(file_path: &str, content: &str) -> Vec<RouteEntry> {
24    let ext = Path::new(file_path)
25        .extension()
26        .and_then(|e| e.to_str())
27        .unwrap_or("");
28
29    let mut routes = Vec::new();
30
31    routes.extend(extract_express(file_path, content, ext));
32    routes.extend(extract_flask(file_path, content, ext));
33    routes.extend(extract_actix(file_path, content, ext));
34    routes.extend(extract_axum(file_path, content, ext));
35    routes.extend(extract_match_routes(file_path, content, ext));
36    routes.extend(extract_spring(file_path, content, ext));
37    routes.extend(extract_rails(file_path, content, ext));
38    routes.extend(extract_fastapi(file_path, content, ext));
39    routes.extend(extract_nextjs(file_path, content, ext));
40
41    routes
42}
43
44pub fn extract_routes_from_project(project_root: &str, file_paths: &[String]) -> Vec<RouteEntry> {
45    let mut all_routes = Vec::new();
46
47    for rel_path in file_paths {
48        if !is_route_candidate(rel_path) {
49            continue;
50        }
51        let abs_path = Path::new(project_root).join(rel_path);
52        let Ok(content) = std::fs::read_to_string(&abs_path) else {
53            continue;
54        };
55        all_routes.extend(extract_routes_from_file(rel_path, &content));
56    }
57
58    all_routes.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
59    all_routes
60}
61
62fn is_route_candidate(path: &str) -> bool {
63    let ext = Path::new(path)
64        .extension()
65        .and_then(|e| e.to_str())
66        .unwrap_or("");
67    matches!(
68        ext,
69        "js" | "ts" | "jsx" | "tsx" | "py" | "rs" | "java" | "rb" | "go" | "kt"
70    )
71}
72
73fn extract_express(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
74    if !matches!(ext, "js" | "ts" | "jsx" | "tsx") {
75        return Vec::new();
76    }
77
78    let re = static_regex!(
79        r#"(?:app|router|server)\s*\.\s*(get|post|put|patch|delete|all|use|options|head)\s*\(\s*['"`]([^'"`]+)['"`]"#
80    );
81
82    content
83        .lines()
84        .enumerate()
85        .filter_map(|(i, line)| {
86            re.captures(line).map(|caps| {
87                let method = caps[1].to_uppercase();
88                let path = caps[2].to_string();
89                let handler = extract_handler_name(line);
90                RouteEntry {
91                    method,
92                    path,
93                    handler,
94                    file: file.to_string(),
95                    line: i + 1,
96                }
97            })
98        })
99        .collect()
100}
101
102fn extract_flask(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
103    if ext != "py" {
104        return Vec::new();
105    }
106
107    let route_re = static_regex!(
108        r#"@(?:app|blueprint|bp)\s*\.\s*route\s*\(\s*['"]([^'"]+)['"](?:.*methods\s*=\s*\[([^\]]+)\])?"#
109    );
110
111    let method_re = static_regex!(
112        r#"@(?:app|blueprint|bp)\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]"#
113    );
114
115    let mut routes = Vec::new();
116
117    for (i, line) in content.lines().enumerate() {
118        if let Some(caps) = route_re.captures(line) {
119            let path = caps[1].to_string();
120            let methods = caps.get(2).map_or_else(
121                || vec!["GET".to_string()],
122                |m| {
123                    m.as_str()
124                        .replace(['\'', '"'], "")
125                        .split(',')
126                        .map(|s| s.trim().to_uppercase())
127                        .collect::<Vec<_>>()
128                },
129            );
130
131            let handler = find_next_def(content, i);
132            for method in methods {
133                routes.push(RouteEntry {
134                    method,
135                    path: path.clone(),
136                    handler: handler.clone(),
137                    file: file.to_string(),
138                    line: i + 1,
139                });
140            }
141        }
142
143        if let Some(caps) = method_re.captures(line) {
144            let method = caps[1].to_uppercase();
145            let path = caps[2].to_string();
146            let handler = find_next_def(content, i);
147            routes.push(RouteEntry {
148                method,
149                path,
150                handler,
151                file: file.to_string(),
152                line: i + 1,
153            });
154        }
155    }
156
157    routes
158}
159
160fn extract_fastapi(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
161    if ext != "py" {
162        return Vec::new();
163    }
164
165    let re = static_regex!(
166        r#"@(?:app|router)\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]"#
167    );
168
169    content
170        .lines()
171        .enumerate()
172        .filter_map(|(i, line)| {
173            re.captures(line).map(|caps| {
174                let method = caps[1].to_uppercase();
175                let path = caps[2].to_string();
176                let handler = find_next_def(content, i);
177                RouteEntry {
178                    method,
179                    path,
180                    handler,
181                    file: file.to_string(),
182                    line: i + 1,
183                }
184            })
185        })
186        .collect()
187}
188
189fn extract_actix(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
190    if ext != "rs" {
191        return Vec::new();
192    }
193
194    let attr_re = static_regex!(r#"#\[(get|post|put|patch|delete)\s*\(\s*"([^"]+)""#);
195
196    let resource_re = static_regex!(
197        r#"web::resource\s*\(\s*"([^"]+)"\s*\)\s*\.route\s*\(.*Method::(GET|POST|PUT|PATCH|DELETE)"#
198    );
199
200    let mut routes = Vec::new();
201
202    for (i, line) in content.lines().enumerate() {
203        if let Some(caps) = attr_re.captures(line) {
204            let method = caps[1].to_uppercase();
205            let path = caps[2].to_string();
206            let handler = find_next_fn_rust(content, i);
207            routes.push(RouteEntry {
208                method,
209                path,
210                handler,
211                file: file.to_string(),
212                line: i + 1,
213            });
214        }
215
216        if let Some(caps) = resource_re.captures(line) {
217            let path = caps[1].to_string();
218            let method = caps[2].to_uppercase();
219            routes.push(RouteEntry {
220                method,
221                path,
222                handler: extract_handler_name(line),
223                file: file.to_string(),
224                line: i + 1,
225            });
226        }
227    }
228
229    routes
230}
231
232/// axum: `.route("/path", get(handler))`, incl. chained methods
233/// (`get(a).post(b)`) and qualified paths (`axum::routing::post(h)`).
234/// GL #454: this very codebase (http_server, lean-ctx-cloud) is axum and the
235/// Routes view showed nothing.
236fn extract_axum(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
237    if ext != "rs" {
238        return Vec::new();
239    }
240
241    let route_re = static_regex!(r#"\.route\s*\(\s*"([^"]+)"\s*,\s*(.+)$"#);
242    // Matches each `get(handler)` / `axum::routing::post(handler)` segment in
243    // the second argument, capturing method + handler name.
244    let method_re = static_regex!(
245        r"(?:axum::routing::|routing::)?\b(get|post|put|patch|delete|head|options|any)\s*\(\s*([A-Za-z0-9_:]+)"
246    );
247
248    let mut routes = Vec::new();
249    for (i, line) in content.lines().enumerate() {
250        let Some(caps) = route_re.captures(line) else {
251            continue;
252        };
253        let path = caps[1].to_string();
254        let rest = &caps[2];
255        for m in method_re.captures_iter(rest) {
256            let handler = m[2].rsplit("::").next().unwrap_or(&m[2]).to_string();
257            routes.push(RouteEntry {
258                method: m[1].to_uppercase(),
259                path: path.clone(),
260                handler,
261                file: file.to_string(),
262                line: i + 1,
263            });
264        }
265    }
266    routes
267}
268
269/// Hand-rolled `match` routers (the lean-ctx dashboard pattern):
270/// `"/api/stats" => { ... }`. Only arms whose string looks like an HTTP path
271/// (`/api/…`, `/v1/…`, `/public/…`) are reported, to avoid false positives on
272/// arbitrary string matches. Method is unknown at the match site, so it is
273/// reported as `*`.
274fn extract_match_routes(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
275    if ext != "rs" {
276        return Vec::new();
277    }
278
279    let re = static_regex!(r#"^\s*"((?:/api|/v1|/public)/[^"]*)"\s*(?:\|\s*"[^"]+"\s*)*=>"#);
280
281    content
282        .lines()
283        .enumerate()
284        .filter_map(|(i, line)| {
285            re.captures(line).map(|caps| RouteEntry {
286                method: "*".to_string(),
287                path: caps[1].to_string(),
288                handler: String::new(),
289                file: file.to_string(),
290                line: i + 1,
291            })
292        })
293        .collect()
294}
295
296fn extract_spring(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
297    if !matches!(ext, "java" | "kt") {
298        return Vec::new();
299    }
300
301    let re = static_regex!(
302        r#"@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping)\s*\(\s*(?:value\s*=\s*)?["']([^"']+)["']"#
303    );
304
305    content
306        .lines()
307        .enumerate()
308        .filter_map(|(i, line)| {
309            re.captures(line).map(|caps| {
310                let annotation = &caps[1];
311                let method = match annotation {
312                    "GetMapping" => "GET",
313                    "PostMapping" => "POST",
314                    "PutMapping" => "PUT",
315                    "PatchMapping" => "PATCH",
316                    "DeleteMapping" => "DELETE",
317                    _ => "*",
318                }
319                .to_string();
320                let path = caps[2].to_string();
321                let handler = find_next_method_java(content, i);
322                RouteEntry {
323                    method,
324                    path,
325                    handler,
326                    file: file.to_string(),
327                    line: i + 1,
328                }
329            })
330        })
331        .collect()
332}
333
334fn extract_rails(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
335    if ext != "rb" {
336        return Vec::new();
337    }
338
339    let re = static_regex!(
340        r#"(get|post|put|patch|delete)\s+['"]([^'"]+)['"](?:\s*,\s*to:\s*['"]([^'"]+)['"])?"#
341    );
342
343    content
344        .lines()
345        .enumerate()
346        .filter_map(|(i, line)| {
347            re.captures(line).map(|caps| {
348                let method = caps[1].to_uppercase();
349                let path = caps[2].to_string();
350                let handler = caps
351                    .get(3)
352                    .map(|m| m.as_str().to_string())
353                    .unwrap_or_default();
354                RouteEntry {
355                    method,
356                    path,
357                    handler,
358                    file: file.to_string(),
359                    line: i + 1,
360                }
361            })
362        })
363        .collect()
364}
365
366fn extract_nextjs(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
367    if !matches!(ext, "ts" | "js") {
368        return Vec::new();
369    }
370
371    if !file.contains("api/") && !file.contains("app/") {
372        return Vec::new();
373    }
374
375    let re = static_regex!(
376        r"export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\("
377    );
378
379    content
380        .lines()
381        .enumerate()
382        .filter_map(|(i, line)| {
383            re.captures(line).map(|caps| {
384                let method = caps[1].to_string();
385                let route_path = file_to_nextjs_route(file);
386                RouteEntry {
387                    method,
388                    path: route_path,
389                    handler: caps[1].to_string(),
390                    file: file.to_string(),
391                    line: i + 1,
392                }
393            })
394        })
395        .collect()
396}
397
398fn file_to_nextjs_route(file: &str) -> String {
399    let parts: Vec<&str> = file.split('/').collect();
400    if let Some(api_pos) = parts.iter().position(|p| *p == "api") {
401        let route_parts = &parts[api_pos..];
402        let mut route = format!("/{}", route_parts.join("/"));
403        if route.ends_with("/route.ts") || route.ends_with("/route.js") {
404            route = route.replace("/route.ts", "").replace("/route.js", "");
405        }
406        route = route.replace('[', ":").replace(']', "");
407        return route;
408    }
409    format!("/{file}")
410}
411
412fn extract_handler_name(line: &str) -> String {
413    let parts: Vec<&str> = line.split([',', ')']).collect();
414    if parts.len() > 1 {
415        let handler = parts
416            .last()
417            .unwrap_or(&"")
418            .trim()
419            .trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
420        if !handler.is_empty() {
421            return handler.to_string();
422        }
423    }
424    String::new()
425}
426
427fn find_next_def(content: &str, after_line: usize) -> String {
428    let def_re = static_regex!(r"def\s+(\w+)");
429    for line in content.lines().skip(after_line + 1).take(5) {
430        if let Some(caps) = def_re.captures(line) {
431            return caps[1].to_string();
432        }
433    }
434    String::new()
435}
436
437fn find_next_fn_rust(content: &str, after_line: usize) -> String {
438    let fn_re = static_regex!(r"(?:pub\s+)?(?:async\s+)?fn\s+(\w+)");
439    for line in content.lines().skip(after_line + 1).take(5) {
440        if let Some(caps) = fn_re.captures(line) {
441            return caps[1].to_string();
442        }
443    }
444    String::new()
445}
446
447fn find_next_method_java(content: &str, after_line: usize) -> String {
448    let method_re = static_regex!(r"(?:public|private|protected)\s+\S+\s+(\w+)\s*\(");
449    for line in content.lines().skip(after_line + 1).take(5) {
450        if let Some(caps) = method_re.captures(line) {
451            return caps[1].to_string();
452        }
453    }
454    String::new()
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460
461    #[test]
462    fn express_get_route() {
463        let code = r"app.get('/api/users', getUsers);";
464        let routes = extract_express("routes.js", code, "js");
465        assert_eq!(routes.len(), 1);
466        assert_eq!(routes[0].method, "GET");
467        assert_eq!(routes[0].path, "/api/users");
468    }
469
470    #[test]
471    fn express_post_route() {
472        let code = r#"router.post("/api/items", createItem);"#;
473        let routes = extract_express("routes.ts", code, "ts");
474        assert_eq!(routes.len(), 1);
475        assert_eq!(routes[0].method, "POST");
476        assert_eq!(routes[0].path, "/api/items");
477    }
478
479    #[test]
480    fn flask_route_decorator() {
481        let code = "@app.route('/hello')\ndef hello():\n    return 'hi'";
482        let routes = extract_flask("app.py", code, "py");
483        assert_eq!(routes.len(), 1);
484        assert_eq!(routes[0].method, "GET");
485        assert_eq!(routes[0].path, "/hello");
486        assert_eq!(routes[0].handler, "hello");
487    }
488
489    #[test]
490    fn flask_route_with_methods() {
491        let code = "@app.route('/data', methods=['GET', 'POST'])\ndef handle_data():\n    pass";
492        let routes = extract_flask("app.py", code, "py");
493        assert_eq!(routes.len(), 2);
494    }
495
496    #[test]
497    fn fastapi_route() {
498        let code = "@app.get('/items')\nasync def list_items():\n    pass";
499        let routes = extract_fastapi("main.py", code, "py");
500        assert_eq!(routes.len(), 1);
501        assert_eq!(routes[0].method, "GET");
502        assert_eq!(routes[0].handler, "list_items");
503    }
504
505    #[test]
506    fn actix_attribute_route() {
507        let code = "#[get(\"/health\")]\nasync fn health_check() -> impl Responder {\n    HttpResponse::Ok()\n}";
508        let routes = extract_actix("main.rs", code, "rs");
509        assert_eq!(routes.len(), 1);
510        assert_eq!(routes[0].method, "GET");
511        assert_eq!(routes[0].path, "/health");
512        assert_eq!(routes[0].handler, "health_check");
513    }
514
515    #[test]
516    fn axum_simple_route() {
517        let code = r#"        .route("/health", get(health))"#;
518        let routes = extract_axum("http_server/mod.rs", code, "rs");
519        assert_eq!(routes.len(), 1);
520        assert_eq!(routes[0].method, "GET");
521        assert_eq!(routes[0].path, "/health");
522        assert_eq!(routes[0].handler, "health");
523    }
524
525    #[test]
526    fn axum_qualified_and_chained_methods() {
527        let code = concat!(
528            "        .route(\"/v1/shutdown\", axum::routing::post(v1_shutdown))\n",
529            "        .route(\"/api/items\", get(list_items).post(create_item))\n",
530        );
531        let routes = extract_axum("server.rs", code, "rs");
532        assert_eq!(routes.len(), 3);
533        assert_eq!(routes[0].method, "POST");
534        assert_eq!(routes[0].handler, "v1_shutdown");
535        assert_eq!(routes[1].method, "GET");
536        assert_eq!(routes[1].handler, "list_items");
537        assert_eq!(routes[2].method, "POST");
538        assert_eq!(routes[2].handler, "create_item");
539    }
540
541    #[test]
542    fn axum_module_path_handler_uses_last_segment() {
543        let code = r#"        .route("/api/billing/supporters", get(supporters::list_supporters))"#;
544        let routes = extract_axum("routes/mod.rs", code, "rs");
545        assert_eq!(routes.len(), 1);
546        assert_eq!(routes[0].handler, "list_supporters");
547    }
548
549    #[test]
550    fn match_router_arms_detected_with_path_prefix_only() {
551        let code = concat!(
552            "            \"/api/stats\" => {\n",
553            "            \"/api/tree\" | \"/api/symbols\" => {\n",
554            "            \"unrelated-string\" => {\n",
555            "            \"/not/an/api\" => {\n",
556        );
557        let routes = extract_match_routes("dashboard/routes/mod.rs", code, "rs");
558        assert_eq!(routes.len(), 2);
559        assert_eq!(routes[0].path, "/api/stats");
560        assert_eq!(routes[0].method, "*");
561        assert_eq!(routes[1].path, "/api/tree");
562    }
563
564    #[test]
565    fn spring_get_mapping() {
566        let code = "@GetMapping(\"/api/users\")\npublic List<User> getUsers() {";
567        let routes = extract_spring("UserController.java", code, "java");
568        assert_eq!(routes.len(), 1);
569        assert_eq!(routes[0].method, "GET");
570        assert_eq!(routes[0].path, "/api/users");
571        assert_eq!(routes[0].handler, "getUsers");
572    }
573
574    #[test]
575    fn rails_route() {
576        let code = "get '/users', to: 'users#index'";
577        let routes = extract_rails("routes.rb", code, "rb");
578        assert_eq!(routes.len(), 1);
579        assert_eq!(routes[0].method, "GET");
580        assert_eq!(routes[0].path, "/users");
581        assert_eq!(routes[0].handler, "users#index");
582    }
583
584    #[test]
585    fn nextjs_route_handler() {
586        let code = "export async function GET(request: Request) {\n  return Response.json({});\n}";
587        let routes = extract_nextjs("src/app/api/users/route.ts", code, "ts");
588        assert_eq!(routes.len(), 1);
589        assert_eq!(routes[0].method, "GET");
590        assert!(routes[0].path.contains("/api/users"));
591    }
592
593    #[test]
594    fn ignores_non_route_files() {
595        assert!(!is_route_candidate("README.md"));
596        assert!(!is_route_candidate("image.png"));
597        assert!(is_route_candidate("server.ts"));
598        assert!(is_route_candidate("routes.rb"));
599    }
600}