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