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_spring(file_path, content, ext));
35    routes.extend(extract_rails(file_path, content, ext));
36    routes.extend(extract_fastapi(file_path, content, ext));
37    routes.extend(extract_nextjs(file_path, content, ext));
38
39    routes
40}
41
42pub fn extract_routes_from_project(
43    project_root: &str,
44    files: &std::collections::HashMap<String, super::graph_index::FileEntry>,
45) -> Vec<RouteEntry> {
46    let mut all_routes = Vec::new();
47
48    for rel_path in files.keys() {
49        if !is_route_candidate(rel_path) {
50            continue;
51        }
52        let abs_path = Path::new(project_root).join(rel_path);
53        let Ok(content) = std::fs::read_to_string(&abs_path) else {
54            continue;
55        };
56        all_routes.extend(extract_routes_from_file(rel_path, &content));
57    }
58
59    all_routes.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.method.cmp(&b.method)));
60    all_routes
61}
62
63fn is_route_candidate(path: &str) -> bool {
64    let ext = Path::new(path)
65        .extension()
66        .and_then(|e| e.to_str())
67        .unwrap_or("");
68    matches!(
69        ext,
70        "js" | "ts" | "jsx" | "tsx" | "py" | "rs" | "java" | "rb" | "go" | "kt"
71    )
72}
73
74fn extract_express(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
75    if !matches!(ext, "js" | "ts" | "jsx" | "tsx") {
76        return Vec::new();
77    }
78
79    let re = static_regex!(
80        r#"(?:app|router|server)\s*\.\s*(get|post|put|patch|delete|all|use|options|head)\s*\(\s*['"`]([^'"`]+)['"`]"#
81    );
82
83    content
84        .lines()
85        .enumerate()
86        .filter_map(|(i, line)| {
87            re.captures(line).map(|caps| {
88                let method = caps[1].to_uppercase();
89                let path = caps[2].to_string();
90                let handler = extract_handler_name(line);
91                RouteEntry {
92                    method,
93                    path,
94                    handler,
95                    file: file.to_string(),
96                    line: i + 1,
97                }
98            })
99        })
100        .collect()
101}
102
103fn extract_flask(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
104    if ext != "py" {
105        return Vec::new();
106    }
107
108    let route_re = static_regex!(
109        r#"@(?:app|blueprint|bp)\s*\.\s*route\s*\(\s*['"]([^'"]+)['"](?:.*methods\s*=\s*\[([^\]]+)\])?"#
110    );
111
112    let method_re = static_regex!(
113        r#"@(?:app|blueprint|bp)\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]"#
114    );
115
116    let mut routes = Vec::new();
117
118    for (i, line) in content.lines().enumerate() {
119        if let Some(caps) = route_re.captures(line) {
120            let path = caps[1].to_string();
121            let methods = caps.get(2).map_or_else(
122                || vec!["GET".to_string()],
123                |m| {
124                    m.as_str()
125                        .replace(['\'', '"'], "")
126                        .split(',')
127                        .map(|s| s.trim().to_uppercase())
128                        .collect::<Vec<_>>()
129                },
130            );
131
132            let handler = find_next_def(content, i);
133            for method in methods {
134                routes.push(RouteEntry {
135                    method,
136                    path: path.clone(),
137                    handler: handler.clone(),
138                    file: file.to_string(),
139                    line: i + 1,
140                });
141            }
142        }
143
144        if let Some(caps) = method_re.captures(line) {
145            let method = caps[1].to_uppercase();
146            let path = caps[2].to_string();
147            let handler = find_next_def(content, i);
148            routes.push(RouteEntry {
149                method,
150                path,
151                handler,
152                file: file.to_string(),
153                line: i + 1,
154            });
155        }
156    }
157
158    routes
159}
160
161fn extract_fastapi(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
162    if ext != "py" {
163        return Vec::new();
164    }
165
166    let re = static_regex!(
167        r#"@(?:app|router)\s*\.\s*(get|post|put|patch|delete)\s*\(\s*['"]([^'"]+)['"]"#
168    );
169
170    content
171        .lines()
172        .enumerate()
173        .filter_map(|(i, line)| {
174            re.captures(line).map(|caps| {
175                let method = caps[1].to_uppercase();
176                let path = caps[2].to_string();
177                let handler = find_next_def(content, i);
178                RouteEntry {
179                    method,
180                    path,
181                    handler,
182                    file: file.to_string(),
183                    line: i + 1,
184                }
185            })
186        })
187        .collect()
188}
189
190fn extract_actix(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
191    if ext != "rs" {
192        return Vec::new();
193    }
194
195    let attr_re = static_regex!(r#"#\[(get|post|put|patch|delete)\s*\(\s*"([^"]+)""#);
196
197    let resource_re = static_regex!(
198        r#"web::resource\s*\(\s*"([^"]+)"\s*\)\s*\.route\s*\(.*Method::(GET|POST|PUT|PATCH|DELETE)"#
199    );
200
201    let mut routes = Vec::new();
202
203    for (i, line) in content.lines().enumerate() {
204        if let Some(caps) = attr_re.captures(line) {
205            let method = caps[1].to_uppercase();
206            let path = caps[2].to_string();
207            let handler = find_next_fn_rust(content, i);
208            routes.push(RouteEntry {
209                method,
210                path,
211                handler,
212                file: file.to_string(),
213                line: i + 1,
214            });
215        }
216
217        if let Some(caps) = resource_re.captures(line) {
218            let path = caps[1].to_string();
219            let method = caps[2].to_uppercase();
220            routes.push(RouteEntry {
221                method,
222                path,
223                handler: extract_handler_name(line),
224                file: file.to_string(),
225                line: i + 1,
226            });
227        }
228    }
229
230    routes
231}
232
233fn extract_spring(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
234    if !matches!(ext, "java" | "kt") {
235        return Vec::new();
236    }
237
238    let re = static_regex!(
239        r#"@(GetMapping|PostMapping|PutMapping|PatchMapping|DeleteMapping|RequestMapping)\s*\(\s*(?:value\s*=\s*)?["']([^"']+)["']"#
240    );
241
242    content
243        .lines()
244        .enumerate()
245        .filter_map(|(i, line)| {
246            re.captures(line).map(|caps| {
247                let annotation = &caps[1];
248                let method = match annotation {
249                    "GetMapping" => "GET",
250                    "PostMapping" => "POST",
251                    "PutMapping" => "PUT",
252                    "PatchMapping" => "PATCH",
253                    "DeleteMapping" => "DELETE",
254                    _ => "*",
255                }
256                .to_string();
257                let path = caps[2].to_string();
258                let handler = find_next_method_java(content, i);
259                RouteEntry {
260                    method,
261                    path,
262                    handler,
263                    file: file.to_string(),
264                    line: i + 1,
265                }
266            })
267        })
268        .collect()
269}
270
271fn extract_rails(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
272    if ext != "rb" {
273        return Vec::new();
274    }
275
276    let re = static_regex!(
277        r#"(get|post|put|patch|delete)\s+['"]([^'"]+)['"](?:\s*,\s*to:\s*['"]([^'"]+)['"])?"#
278    );
279
280    content
281        .lines()
282        .enumerate()
283        .filter_map(|(i, line)| {
284            re.captures(line).map(|caps| {
285                let method = caps[1].to_uppercase();
286                let path = caps[2].to_string();
287                let handler = caps
288                    .get(3)
289                    .map(|m| m.as_str().to_string())
290                    .unwrap_or_default();
291                RouteEntry {
292                    method,
293                    path,
294                    handler,
295                    file: file.to_string(),
296                    line: i + 1,
297                }
298            })
299        })
300        .collect()
301}
302
303fn extract_nextjs(file: &str, content: &str, ext: &str) -> Vec<RouteEntry> {
304    if !matches!(ext, "ts" | "js") {
305        return Vec::new();
306    }
307
308    if !file.contains("api/") && !file.contains("app/") {
309        return Vec::new();
310    }
311
312    let re = static_regex!(
313        r"export\s+(?:async\s+)?function\s+(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s*\("
314    );
315
316    content
317        .lines()
318        .enumerate()
319        .filter_map(|(i, line)| {
320            re.captures(line).map(|caps| {
321                let method = caps[1].to_string();
322                let route_path = file_to_nextjs_route(file);
323                RouteEntry {
324                    method,
325                    path: route_path,
326                    handler: caps[1].to_string(),
327                    file: file.to_string(),
328                    line: i + 1,
329                }
330            })
331        })
332        .collect()
333}
334
335fn file_to_nextjs_route(file: &str) -> String {
336    let parts: Vec<&str> = file.split('/').collect();
337    if let Some(api_pos) = parts.iter().position(|p| *p == "api") {
338        let route_parts = &parts[api_pos..];
339        let mut route = format!("/{}", route_parts.join("/"));
340        if route.ends_with("/route.ts") || route.ends_with("/route.js") {
341            route = route.replace("/route.ts", "").replace("/route.js", "");
342        }
343        route = route.replace('[', ":").replace(']', "");
344        return route;
345    }
346    format!("/{file}")
347}
348
349fn extract_handler_name(line: &str) -> String {
350    let parts: Vec<&str> = line.split([',', ')']).collect();
351    if parts.len() > 1 {
352        let handler = parts
353            .last()
354            .unwrap_or(&"")
355            .trim()
356            .trim_matches(|c: char| !c.is_alphanumeric() && c != '_');
357        if !handler.is_empty() {
358            return handler.to_string();
359        }
360    }
361    String::new()
362}
363
364fn find_next_def(content: &str, after_line: usize) -> String {
365    let def_re = static_regex!(r"def\s+(\w+)");
366    for line in content.lines().skip(after_line + 1).take(5) {
367        if let Some(caps) = def_re.captures(line) {
368            return caps[1].to_string();
369        }
370    }
371    String::new()
372}
373
374fn find_next_fn_rust(content: &str, after_line: usize) -> String {
375    let fn_re = static_regex!(r"(?:pub\s+)?(?:async\s+)?fn\s+(\w+)");
376    for line in content.lines().skip(after_line + 1).take(5) {
377        if let Some(caps) = fn_re.captures(line) {
378            return caps[1].to_string();
379        }
380    }
381    String::new()
382}
383
384fn find_next_method_java(content: &str, after_line: usize) -> String {
385    let method_re = static_regex!(r"(?:public|private|protected)\s+\S+\s+(\w+)\s*\(");
386    for line in content.lines().skip(after_line + 1).take(5) {
387        if let Some(caps) = method_re.captures(line) {
388            return caps[1].to_string();
389        }
390    }
391    String::new()
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397
398    #[test]
399    fn express_get_route() {
400        let code = r"app.get('/api/users', getUsers);";
401        let routes = extract_express("routes.js", code, "js");
402        assert_eq!(routes.len(), 1);
403        assert_eq!(routes[0].method, "GET");
404        assert_eq!(routes[0].path, "/api/users");
405    }
406
407    #[test]
408    fn express_post_route() {
409        let code = r#"router.post("/api/items", createItem);"#;
410        let routes = extract_express("routes.ts", code, "ts");
411        assert_eq!(routes.len(), 1);
412        assert_eq!(routes[0].method, "POST");
413        assert_eq!(routes[0].path, "/api/items");
414    }
415
416    #[test]
417    fn flask_route_decorator() {
418        let code = "@app.route('/hello')\ndef hello():\n    return 'hi'";
419        let routes = extract_flask("app.py", code, "py");
420        assert_eq!(routes.len(), 1);
421        assert_eq!(routes[0].method, "GET");
422        assert_eq!(routes[0].path, "/hello");
423        assert_eq!(routes[0].handler, "hello");
424    }
425
426    #[test]
427    fn flask_route_with_methods() {
428        let code = "@app.route('/data', methods=['GET', 'POST'])\ndef handle_data():\n    pass";
429        let routes = extract_flask("app.py", code, "py");
430        assert_eq!(routes.len(), 2);
431    }
432
433    #[test]
434    fn fastapi_route() {
435        let code = "@app.get('/items')\nasync def list_items():\n    pass";
436        let routes = extract_fastapi("main.py", code, "py");
437        assert_eq!(routes.len(), 1);
438        assert_eq!(routes[0].method, "GET");
439        assert_eq!(routes[0].handler, "list_items");
440    }
441
442    #[test]
443    fn actix_attribute_route() {
444        let code = "#[get(\"/health\")]\nasync fn health_check() -> impl Responder {\n    HttpResponse::Ok()\n}";
445        let routes = extract_actix("main.rs", code, "rs");
446        assert_eq!(routes.len(), 1);
447        assert_eq!(routes[0].method, "GET");
448        assert_eq!(routes[0].path, "/health");
449        assert_eq!(routes[0].handler, "health_check");
450    }
451
452    #[test]
453    fn spring_get_mapping() {
454        let code = "@GetMapping(\"/api/users\")\npublic List<User> getUsers() {";
455        let routes = extract_spring("UserController.java", code, "java");
456        assert_eq!(routes.len(), 1);
457        assert_eq!(routes[0].method, "GET");
458        assert_eq!(routes[0].path, "/api/users");
459        assert_eq!(routes[0].handler, "getUsers");
460    }
461
462    #[test]
463    fn rails_route() {
464        let code = "get '/users', to: 'users#index'";
465        let routes = extract_rails("routes.rb", code, "rb");
466        assert_eq!(routes.len(), 1);
467        assert_eq!(routes[0].method, "GET");
468        assert_eq!(routes[0].path, "/users");
469        assert_eq!(routes[0].handler, "users#index");
470    }
471
472    #[test]
473    fn nextjs_route_handler() {
474        let code = "export async function GET(request: Request) {\n  return Response.json({});\n}";
475        let routes = extract_nextjs("src/app/api/users/route.ts", code, "ts");
476        assert_eq!(routes.len(), 1);
477        assert_eq!(routes[0].method, "GET");
478        assert!(routes[0].path.contains("/api/users"));
479    }
480
481    #[test]
482    fn ignores_non_route_files() {
483        assert!(!is_route_candidate("README.md"));
484        assert!(!is_route_candidate("image.png"));
485        assert!(is_route_candidate("server.ts"));
486        assert!(is_route_candidate("routes.rb"));
487    }
488}