1use anyhow::Result;
7use std::collections::HashMap;
8use std::fs;
9use std::path::{Path, PathBuf};
10use walkdir::WalkDir;
11
12#[derive(Debug, Clone)]
13pub struct ImportStatement {
14 pub file_path: String,
15 pub line_number: usize,
16 pub line_content: String,
17 pub imported_path: String,
18}
19
20#[derive(Debug, Clone)]
22enum ImportPattern {
23 Rust,
25 JavaScript,
27 Python,
29 Go,
31 JavaLike,
33 CStyle,
35 Ruby,
37 Php,
39 Shell,
41 Css,
43}
44
45fn get_import_pattern(extension: &str) -> Option<ImportPattern> {
47 match extension {
48 "rs" => Some(ImportPattern::Rust),
49 "js" | "jsx" | "ts" | "tsx" | "mjs" | "cjs" => Some(ImportPattern::JavaScript),
50 "py" => Some(ImportPattern::Python),
51 "go" => Some(ImportPattern::Go),
52 "java" | "kt" | "scala" => Some(ImportPattern::JavaLike),
53 "cs" => Some(ImportPattern::JavaLike),
54 "c" | "cpp" | "cc" | "cxx" | "h" | "hpp" => Some(ImportPattern::CStyle),
55 "rb" => Some(ImportPattern::Ruby),
56 "php" => Some(ImportPattern::Php),
57 "sh" | "bash" => Some(ImportPattern::Shell),
58 "css" | "scss" | "less" => Some(ImportPattern::Css),
59 _ => None,
60 }
61}
62
63fn extract_rust_import(line: &str) -> Option<String> {
65 let trimmed = line.trim();
66
67 let import_part = trimmed.strip_prefix("use ")?.trim_end_matches(';').trim();
69
70 if import_part.starts_with("crate::")
72 || import_part.starts_with("super::")
73 || import_part.starts_with("self::")
74 {
75 let path = if let Some(brace_pos) = import_part.find('{') {
77 import_part[..brace_pos].trim()
78 } else if let Some(as_pos) = import_part.find(" as ") {
79 import_part[..as_pos].trim()
80 } else {
81 import_part
82 };
83
84 return Some(path.to_string());
85 }
86
87 None
88}
89
90fn extract_javascript_import(line: &str) -> Option<String> {
92 let trimmed = line.trim();
93
94 if trimmed.starts_with("import ") {
96 if let Some(from_pos) = trimmed.find(" from ") {
97 let after_from = &trimmed[from_pos + 6..].trim();
98 return extract_quoted_string(after_from);
99 }
100 if let Some(quote_pos) = trimmed.find(['"', '\'']) {
102 return extract_quoted_string(&trimmed[quote_pos..]);
103 }
104 }
105
106 if trimmed.starts_with("export ")
108 && trimmed.contains(" from ")
109 && let Some(from_pos) = trimmed.find(" from ")
110 {
111 let after_from = &trimmed[from_pos + 6..].trim();
112 return extract_quoted_string(after_from);
113 }
114
115 if trimmed.contains("require(")
117 && let Some(paren_pos) = trimmed.find("require(")
118 {
119 let after_paren = &trimmed[paren_pos + 8..];
120 return extract_quoted_string(after_paren);
121 }
122
123 None
124}
125
126fn extract_python_import(line: &str) -> Option<String> {
128 let trimmed = line.trim();
129
130 if trimmed.starts_with("from ")
132 && let Some(import_pos) = trimmed.find(" import ")
133 {
134 let module_path = trimmed[5..import_pos].trim();
135 if module_path.starts_with('.') {
137 return Some(module_path.to_string());
138 }
139 }
140
141 if let Some(import_part) = trimmed.strip_prefix("import ") {
143 let import_part = import_part.trim();
144 let module_path = if let Some(as_pos) = import_part.find(" as ") {
145 &import_part[..as_pos]
146 } else {
147 import_part
148 };
149
150 if module_path.starts_with('.') {
152 return Some(module_path.trim().to_string());
153 }
154 }
155
156 None
157}
158
159fn extract_go_import(line: &str) -> Option<String> {
161 let trimmed = line.trim();
162 let after_import = trimmed.strip_prefix("import ")?.trim();
163 extract_quoted_string(after_import)
164}
165
166fn extract_javalike_import(line: &str) -> Option<String> {
168 let trimmed = line.trim();
169
170 if let Some(import_part) = trimmed.strip_prefix("import ") {
172 let import_part = import_part.trim().trim_end_matches(';');
173 if import_part.starts_with("static ") {
175 return None;
176 }
177 return Some(import_part.to_string());
178 }
179
180 if !trimmed.contains('=')
182 && let Some(import_part) = trimmed.strip_prefix("using ")
183 {
184 let import_part = import_part.trim().trim_end_matches(';');
185 return Some(import_part.to_string());
186 }
187
188 None
189}
190
191fn extract_c_include(line: &str) -> Option<String> {
193 let trimmed = line.trim();
194 let after_include = trimmed.strip_prefix("#include ")?.trim();
195
196 if let Some(path) = extract_quoted_string(after_include) {
198 return Some(path);
199 }
200
201 if after_include.starts_with('<')
204 && after_include.contains('/')
205 && let Some(end) = after_include.find('>')
206 {
207 return Some(after_include[1..end].to_string());
208 }
209
210 None
211}
212
213fn extract_ruby_require(line: &str) -> Option<String> {
215 let trimmed = line.trim();
216
217 if let Some(after_require) = trimmed.strip_prefix("require_relative ") {
219 return extract_quoted_string(after_require.trim());
220 }
221
222 if let Some(after_require) = trimmed.strip_prefix("require ")
224 && let Some(path) = extract_quoted_string(after_require.trim())
225 && path.starts_with('.')
226 {
227 return Some(path);
228 }
229
230 None
231}
232
233fn extract_php_include(line: &str) -> Option<String> {
235 let trimmed = line.trim();
236
237 for keyword in ["require", "require_once", "include", "include_once"] {
238 if let Some(after_keyword) = trimmed.strip_prefix(keyword) {
239 let after_keyword = after_keyword.trim();
240 if let Some(path) = extract_quoted_string(after_keyword) {
241 return Some(path);
242 }
243 }
244 }
245
246 None
247}
248
249fn extract_shell_source(line: &str) -> Option<String> {
251 let trimmed = line.trim();
252
253 if let Some(path) = trimmed.strip_prefix("source ") {
255 let path = path.trim();
256 return extract_quoted_string(path).or_else(|| Some(path.to_string()));
257 }
258
259 if let Some(path) = trimmed.strip_prefix(". ")
260 && !trimmed.starts_with("..")
261 {
262 let path = path.trim();
263 return extract_quoted_string(path).or_else(|| Some(path.to_string()));
264 }
265
266 None
267}
268
269fn extract_css_import(line: &str) -> Option<String> {
271 let trimmed = line.trim();
272 let after_import = trimmed.strip_prefix("@import ")?.trim();
273 extract_quoted_string(after_import)
274}
275
276fn extract_quoted_string(s: &str) -> Option<String> {
278 let trimmed = s.trim();
279 let first_char = trimmed.chars().next()?;
280
281 if ['"', '\'', '`'].contains(&first_char)
283 && let Some(end) = trimmed[1..].find(first_char)
284 {
285 return Some(trimmed[1..end + 1].to_string());
286 }
287
288 None
289}
290
291fn extract_import(line: &str, pattern: &ImportPattern) -> Option<String> {
293 match pattern {
294 ImportPattern::Rust => extract_rust_import(line),
295 ImportPattern::JavaScript => extract_javascript_import(line),
296 ImportPattern::Python => extract_python_import(line),
297 ImportPattern::Go => extract_go_import(line),
298 ImportPattern::JavaLike => extract_javalike_import(line),
299 ImportPattern::CStyle => extract_c_include(line),
300 ImportPattern::Ruby => extract_ruby_require(line),
301 ImportPattern::Php => extract_php_include(line),
302 ImportPattern::Shell => extract_shell_source(line),
303 ImportPattern::Css => extract_css_import(line),
304 }
305}
306
307pub fn scan_file_for_imports(file_path: &Path) -> Result<Vec<ImportStatement>> {
309 let mut imports = Vec::new();
310
311 let extension = file_path.extension().and_then(|e| e.to_str()).unwrap_or("");
312 let pattern = match get_import_pattern(extension) {
313 Some(p) => p,
314 None => return Ok(imports), };
316
317 let content = fs::read_to_string(file_path)?;
318
319 for (line_number, line) in content.lines().enumerate() {
320 if let Some(imported_path) = extract_import(line, &pattern) {
321 imports.push(ImportStatement {
322 file_path: file_path.to_string_lossy().to_string(),
323 line_number: line_number + 1, line_content: line.trim().to_string(),
325 imported_path,
326 });
327 }
328 }
329
330 Ok(imports)
331}
332
333pub fn build_file_map(base_path: &Path) -> HashMap<String, PathBuf> {
335 let mut file_map = HashMap::new();
336
337 let skip_dirs = [
338 "node_modules",
339 "target",
340 "dist",
341 "build",
342 ".git",
343 ".svn",
344 ".hg",
345 "vendor",
346 "__pycache__",
347 ".next",
348 ".nuxt",
349 "coverage",
350 ];
351
352 for entry in WalkDir::new(base_path)
353 .into_iter()
354 .filter_entry(|e| {
355 if e.file_type().is_dir() {
356 let dir_name = e.file_name().to_string_lossy();
357 !skip_dirs.contains(&dir_name.as_ref())
358 } else {
359 true
360 }
361 })
362 .filter_map(|e| e.ok())
363 {
364 if entry.file_type().is_file() {
365 let path = entry.path();
366 if let Ok(relative_path) = path.strip_prefix(base_path) {
367 let key = relative_path.to_string_lossy().to_string();
368 file_map.insert(key, path.to_path_buf());
369 }
370 }
371 }
372
373 file_map
374}
375
376pub fn resolve_import_path(
378 import_path: &str,
379 source_file: &Path,
380 base_path: &Path,
381 file_map: &HashMap<String, PathBuf>,
382) -> Option<PathBuf> {
383 let source_dir = source_file.parent()?;
384
385 if import_path.starts_with('.') {
387 resolve_relative_import(import_path, source_dir, base_path)
389 } else if import_path.contains("::") {
390 resolve_rust_module_path(import_path, source_file, base_path, file_map)
392 } else if import_path.contains('/') {
393 resolve_path_import(import_path, base_path, file_map)
395 } else {
396 None
398 }
399}
400
401fn resolve_relative_import(
403 import_path: &str,
404 source_dir: &Path,
405 base_path: &Path,
406) -> Option<PathBuf> {
407 let _import_path_clean = import_path
408 .trim_start_matches("./")
409 .trim_start_matches("../");
410
411 let candidate = source_dir.join(import_path);
413
414 if candidate.is_dir() {
417 for index_name in ["index", "mod", "__init__"] {
418 for idx_ext in ["ts", "tsx", "js", "jsx", "rs", "py"] {
419 let index_path = candidate.join(format!("{}.{}", index_name, idx_ext));
420 if index_path.exists() && index_path.starts_with(base_path) {
421 if let Ok(canonical) = index_path.canonicalize() {
423 return Some(canonical);
424 }
425 return Some(index_path);
426 }
427 }
428 }
429 }
430
431 let extensions = [
433 "", ".ts", ".tsx", ".js", ".jsx", ".rs", ".py", ".go", ".java", ".rb", ".php",
434 ];
435
436 for ext in extensions {
437 let path_with_ext = if ext.is_empty() {
438 candidate.clone()
439 } else {
440 let mut path_str = candidate.to_string_lossy().to_string();
442 path_str.push_str(ext);
443 PathBuf::from(path_str)
444 };
445
446 if path_with_ext.exists() && path_with_ext.starts_with(base_path) {
447 if let Ok(canonical) = path_with_ext.canonicalize() {
449 return Some(canonical);
450 }
451 return Some(path_with_ext);
452 }
453
454 if path_with_ext.is_dir() {
457 for index_name in ["index", "mod", "__init__"] {
458 for idx_ext in ["ts", "tsx", "js", "jsx", "rs", "py"] {
459 let index_path = path_with_ext.join(format!("{}.{}", index_name, idx_ext));
460 if index_path.exists() && index_path.starts_with(base_path) {
461 return Some(index_path);
462 }
463 }
464 }
465 }
466 }
467
468 None
469}
470
471fn resolve_rust_module_path(
473 import_path: &str,
474 source_file: &Path,
475 base_path: &Path,
476 _file_map: &HashMap<String, PathBuf>,
477) -> Option<PathBuf> {
478 let path_str = if let Some(stripped) = import_path.strip_prefix("crate::") {
480 stripped
481 } else if let Some(stripped) = import_path.strip_prefix("super::") {
482 return resolve_super_path(stripped, source_file, base_path);
484 } else {
485 import_path.strip_prefix("self::")?
486 };
487
488 let path_parts: Vec<&str> = path_str.split("::").collect();
490
491 let src_dir = find_src_directory(base_path)?;
493
494 let mut module_path = src_dir.clone();
496 for part in &path_parts {
497 module_path = module_path.join(part);
498 }
499
500 if module_path.with_extension("rs").exists() {
501 return Some(module_path.with_extension("rs"));
502 }
503
504 let mod_path = module_path.join("mod.rs");
506 if mod_path.exists() {
507 return Some(mod_path);
508 }
509
510 None
511}
512
513fn resolve_super_path(
515 remaining_path: &str,
516 source_file: &Path,
517 base_path: &Path,
518) -> Option<PathBuf> {
519 let source_dir = source_file.parent()?;
520 let parent_dir = source_dir.parent()?;
521
522 if !parent_dir.starts_with(base_path) {
523 return None;
524 }
525
526 let path_parts: Vec<&str> = remaining_path.split("::").collect();
527 let mut module_path = parent_dir.to_path_buf();
528
529 for part in &path_parts {
530 module_path = module_path.join(part);
531 }
532
533 if module_path.with_extension("rs").exists() {
534 return Some(module_path.with_extension("rs"));
535 }
536
537 let mod_path = module_path.join("mod.rs");
538 if mod_path.exists() {
539 return Some(mod_path);
540 }
541
542 None
543}
544
545fn find_src_directory(base_path: &Path) -> Option<PathBuf> {
547 let src_dir = base_path.join("src");
548 if src_dir.is_dir() {
549 return Some(src_dir);
550 }
551
552 for entry in (fs::read_dir(base_path).ok()?).flatten() {
554 let path = entry.path();
555 if path.is_dir() {
556 let nested_src = path.join("src");
557 if nested_src.is_dir() {
558 return Some(nested_src);
559 }
560 }
561 }
562
563 None
564}
565
566fn resolve_path_import(
568 import_path: &str,
569 _base_path: &Path,
570 file_map: &HashMap<String, PathBuf>,
571) -> Option<PathBuf> {
572 if let Some(path) = file_map.get(import_path) {
574 return Some(path.clone());
575 }
576
577 for ext in [
579 "ts", "tsx", "js", "jsx", "rs", "py", "go", "java", "rb", "php",
580 ] {
581 let with_ext = format!("{}.{}", import_path, ext);
582 if let Some(path) = file_map.get(&with_ext) {
583 return Some(path.clone());
584 }
585 }
586
587 for index_name in ["index", "mod", "__init__"] {
589 for ext in ["ts", "tsx", "js", "jsx", "rs", "py"] {
590 let index_path = format!("{}/{}.{}", import_path, index_name, ext);
591 if let Some(path) = file_map.get(&index_path) {
592 return Some(path.clone());
593 }
594 }
595 }
596
597 None
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 #[test]
605 fn test_extract_rust_import() {
606 assert_eq!(
607 extract_rust_import("use crate::models::Feature;"),
608 Some("crate::models::Feature".to_string())
609 );
610 assert_eq!(
611 extract_rust_import("use super::helper;"),
612 Some("super::helper".to_string())
613 );
614 assert_eq!(
615 extract_rust_import("use self::utils;"),
616 Some("self::utils".to_string())
617 );
618 }
619
620 #[test]
621 fn test_extract_javascript_import() {
622 assert_eq!(
623 extract_javascript_import("import { Feature } from './models';"),
624 Some("./models".to_string())
625 );
626 assert_eq!(
627 extract_javascript_import("const x = require('../utils');"),
628 Some("../utils".to_string())
629 );
630 assert_eq!(
631 extract_javascript_import("export { Feature } from './models';"),
632 Some("./models".to_string())
633 );
634 }
635
636 #[test]
637 fn test_extract_python_import() {
638 assert_eq!(
639 extract_python_import("from .models import Feature"),
640 Some(".models".to_string())
641 );
642 assert_eq!(
643 extract_python_import("from ..utils import helper"),
644 Some("..utils".to_string())
645 );
646 }
647
648 #[test]
649 fn test_extract_quoted_string() {
650 assert_eq!(
651 extract_quoted_string("\"./path/to/file\""),
652 Some("./path/to/file".to_string())
653 );
654 assert_eq!(
655 extract_quoted_string("'./path/to/file'"),
656 Some("./path/to/file".to_string())
657 );
658 }
659}