Skip to main content

features_cli/
import_detector.rs

1//! Module for detecting imports/dependencies in source code files
2//!
3//! This module scans source files for import statements and resolves them
4//! to their actual file paths to detect cross-feature dependencies.
5
6use 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/// Represents language-specific import patterns
21#[derive(Debug, Clone)]
22enum ImportPattern {
23    /// Rust: use statements
24    Rust,
25    /// JavaScript/TypeScript: import/require/export from
26    JavaScript,
27    /// Python: import/from...import
28    Python,
29    /// Go: import statements
30    Go,
31    /// Java/C#/Kotlin: import/using statements
32    JavaLike,
33    /// C/C++: #include statements
34    CStyle,
35    /// Ruby: require/require_relative
36    Ruby,
37    /// PHP: use/require/include
38    Php,
39    /// Shell: source/.
40    Shell,
41    /// CSS/SCSS/Less: @import
42    Css,
43}
44
45/// Get import pattern for a file extension
46fn 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
63/// Extract import path from a Rust use statement
64fn extract_rust_import(line: &str) -> Option<String> {
65    let trimmed = line.trim();
66
67    // Remove "use " prefix and trailing semicolon
68    let import_part = trimmed.strip_prefix("use ")?.trim_end_matches(';').trim();
69
70    // Handle 'use crate::' or 'use super::' or 'use self::'
71    if import_part.starts_with("crate::")
72        || import_part.starts_with("super::")
73        || import_part.starts_with("self::")
74    {
75        // Extract the module path (remove use blocks like {Type1, Type2})
76        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
90/// Extract import path from JavaScript/TypeScript import/require
91fn extract_javascript_import(line: &str) -> Option<String> {
92    let trimmed = line.trim();
93
94    // import ... from "path" or import ... from 'path'
95    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        // import "path" or import 'path'
101        if let Some(quote_pos) = trimmed.find(['"', '\'']) {
102            return extract_quoted_string(&trimmed[quote_pos..]);
103        }
104    }
105
106    // export ... from "path"
107    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    // require("path") or require('path')
116    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
126/// Extract import path from Python import statements
127fn extract_python_import(line: &str) -> Option<String> {
128    let trimmed = line.trim();
129
130    // from ... import ...
131    if trimmed.starts_with("from ")
132        && let Some(import_pos) = trimmed.find(" import ")
133    {
134        let module_path = trimmed[5..import_pos].trim();
135        // Only consider relative imports (starting with .)
136        if module_path.starts_with('.') {
137            return Some(module_path.to_string());
138        }
139    }
140
141    // import ... (relative imports only)
142    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        // Only consider relative imports
151        if module_path.starts_with('.') {
152            return Some(module_path.trim().to_string());
153        }
154    }
155
156    None
157}
158
159/// Extract import path from Go import statements
160fn 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
166/// Extract import path from Java-like languages (Java, C#, Kotlin, Scala)
167fn extract_javalike_import(line: &str) -> Option<String> {
168    let trimmed = line.trim();
169
170    // Java/Kotlin: import package.name
171    if let Some(import_part) = trimmed.strip_prefix("import ") {
172        let import_part = import_part.trim().trim_end_matches(';');
173        // Skip static imports
174        if import_part.starts_with("static ") {
175            return None;
176        }
177        return Some(import_part.to_string());
178    }
179
180    // C#: using namespace
181    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
191/// Extract include path from C/C++ #include statements
192fn extract_c_include(line: &str) -> Option<String> {
193    let trimmed = line.trim();
194    let after_include = trimmed.strip_prefix("#include ")?.trim();
195
196    // #include "file.h" (local include)
197    if let Some(path) = extract_quoted_string(after_include) {
198        return Some(path);
199    }
200
201    // #include <file.h> (system include - we'll skip these)
202    // But if it starts with a relative path marker, keep it
203    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
213/// Extract require path from Ruby
214fn extract_ruby_require(line: &str) -> Option<String> {
215    let trimmed = line.trim();
216
217    // require_relative "path" or require_relative 'path'
218    if let Some(after_require) = trimmed.strip_prefix("require_relative ") {
219        return extract_quoted_string(after_require.trim());
220    }
221
222    // require "path" or require 'path' (only relative paths with ./)
223    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
233/// Extract require/include path from PHP
234fn 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
249/// Extract source path from Shell scripts
250fn extract_shell_source(line: &str) -> Option<String> {
251    let trimmed = line.trim();
252
253    // source path or . path
254    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
269/// Extract @import path from CSS/SCSS/Less
270fn 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
276/// Extract a quoted string (single or double quotes)
277fn extract_quoted_string(s: &str) -> Option<String> {
278    let trimmed = s.trim();
279    let first_char = trimmed.chars().next()?;
280
281    // Check for quote characters
282    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
291/// Extract import statement based on language pattern
292fn 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
307/// Scan a single file for import statements
308pub 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), // Unsupported file type
315    };
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, // 1-based
324                line_content: line.trim().to_string(),
325                imported_path,
326            });
327        }
328    }
329
330    Ok(imports)
331}
332
333/// Build a map of all files in the project for quick lookup
334pub 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
376/// Resolve an import path to an actual file path
377pub 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    // Handle different types of imports
386    if import_path.starts_with('.') {
387        // Relative import (./file or ../file)
388        resolve_relative_import(import_path, source_dir, base_path)
389    } else if import_path.contains("::") {
390        // Rust-style module path (crate::module::submodule)
391        resolve_rust_module_path(import_path, source_file, base_path, file_map)
392    } else if import_path.contains('/') {
393        // Path-like import
394        resolve_path_import(import_path, base_path, file_map)
395    } else {
396        // Package/module name - we'll skip these as they're external
397        None
398    }
399}
400
401/// Resolve relative imports like ./file or ../file
402fn 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    // Try to resolve from source directory
412    let candidate = source_dir.join(import_path);
413
414    // First, check if the path is a directory and try index files
415    // This handles: import foo from './folder' -> './folder/index.ts'
416    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                    // Canonicalize to resolve .. and . in the path
422                    if let Ok(canonical) = index_path.canonicalize() {
423                        return Some(canonical);
424                    }
425                    return Some(index_path);
426                }
427            }
428        }
429    }
430
431    // Try common extensions if no extension provided
432    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            // Append extension to the path
441            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            // Canonicalize to resolve .. and . in the path
448            if let Ok(canonical) = path_with_ext.canonicalize() {
449                return Some(canonical);
450            }
451            return Some(path_with_ext);
452        }
453
454        // Also check if path with extension is a directory with index file
455        // This handles edge cases like './folder.component' -> './folder.component/index.ts'
456        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
471/// Resolve Rust module paths like crate::module::submodule
472fn 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    // Convert crate::module::submodule to module/submodule.rs or module/submodule/mod.rs
479    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        // Handle super:: by going up one directory
483        return resolve_super_path(stripped, source_file, base_path);
484    } else {
485        import_path.strip_prefix("self::")?
486    };
487
488    // Convert :: to /
489    let path_parts: Vec<&str> = path_str.split("::").collect();
490
491    // Find src directory
492    let src_dir = find_src_directory(base_path)?;
493
494    // Try module/file.rs
495    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    // Try module/mod.rs
505    let mod_path = module_path.join("mod.rs");
506    if mod_path.exists() {
507        return Some(mod_path);
508    }
509
510    None
511}
512
513/// Resolve super:: paths in Rust
514fn 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
545/// Find the src directory in a Rust project
546fn 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    // Look for src in subdirectories
553    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
566/// Resolve path-based imports
567fn resolve_path_import(
568    import_path: &str,
569    _base_path: &Path,
570    file_map: &HashMap<String, PathBuf>,
571) -> Option<PathBuf> {
572    // Try direct lookup
573    if let Some(path) = file_map.get(import_path) {
574        return Some(path.clone());
575    }
576
577    // Try with common extensions
578    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    // Try as directory with index
588    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}