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
232fn 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 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
269fn 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}