Skip to main content

lean_ctx/core/
route_extractor.rs

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