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}