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
235fn 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 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
272fn 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}