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    // Try common extensions if no extension provided
415    let extensions = [
416        "", ".ts", ".tsx", ".js", ".jsx", ".rs", ".py", ".go", ".java", ".rb", ".php",
417    ];
418
419    for ext in extensions {
420        let path_with_ext = if ext.is_empty() {
421            candidate.clone()
422        } else {
423            // Append extension to the path
424            let mut path_str = candidate.to_string_lossy().to_string();
425            path_str.push_str(ext);
426            PathBuf::from(path_str)
427        };
428
429        if path_with_ext.exists() && path_with_ext.starts_with(base_path) {
430            // Canonicalize to resolve .. and . in the path
431            if let Ok(canonical) = path_with_ext.canonicalize() {
432                return Some(canonical);
433            }
434            return Some(path_with_ext);
435        }
436
437        // Try as directory with index file
438        let candidate_as_dir = candidate.clone();
439        if candidate_as_dir.is_dir() {
440            for index_name in ["index", "mod", "__init__"] {
441                for idx_ext in ["ts", "tsx", "js", "jsx", "rs", "py"] {
442                    let index_path = candidate_as_dir.join(format!("{}.{}", index_name, idx_ext));
443                    if index_path.exists() {
444                        return Some(index_path);
445                    }
446                }
447            }
448        }
449    }
450
451    None
452}
453
454/// Resolve Rust module paths like crate::module::submodule
455fn resolve_rust_module_path(
456    import_path: &str,
457    source_file: &Path,
458    base_path: &Path,
459    _file_map: &HashMap<String, PathBuf>,
460) -> Option<PathBuf> {
461    // Convert crate::module::submodule to module/submodule.rs or module/submodule/mod.rs
462    let path_str = if let Some(stripped) = import_path.strip_prefix("crate::") {
463        stripped
464    } else if let Some(stripped) = import_path.strip_prefix("super::") {
465        // Handle super:: by going up one directory
466        return resolve_super_path(stripped, source_file, base_path);
467    } else {
468        import_path.strip_prefix("self::")?
469    };
470
471    // Convert :: to /
472    let path_parts: Vec<&str> = path_str.split("::").collect();
473
474    // Find src directory
475    let src_dir = find_src_directory(base_path)?;
476
477    // Try module/file.rs
478    let mut module_path = src_dir.clone();
479    for part in &path_parts {
480        module_path = module_path.join(part);
481    }
482
483    if module_path.with_extension("rs").exists() {
484        return Some(module_path.with_extension("rs"));
485    }
486
487    // Try module/mod.rs
488    let mod_path = module_path.join("mod.rs");
489    if mod_path.exists() {
490        return Some(mod_path);
491    }
492
493    None
494}
495
496/// Resolve super:: paths in Rust
497fn resolve_super_path(
498    remaining_path: &str,
499    source_file: &Path,
500    base_path: &Path,
501) -> Option<PathBuf> {
502    let source_dir = source_file.parent()?;
503    let parent_dir = source_dir.parent()?;
504
505    if !parent_dir.starts_with(base_path) {
506        return None;
507    }
508
509    let path_parts: Vec<&str> = remaining_path.split("::").collect();
510    let mut module_path = parent_dir.to_path_buf();
511
512    for part in &path_parts {
513        module_path = module_path.join(part);
514    }
515
516    if module_path.with_extension("rs").exists() {
517        return Some(module_path.with_extension("rs"));
518    }
519
520    let mod_path = module_path.join("mod.rs");
521    if mod_path.exists() {
522        return Some(mod_path);
523    }
524
525    None
526}
527
528/// Find the src directory in a Rust project
529fn find_src_directory(base_path: &Path) -> Option<PathBuf> {
530    let src_dir = base_path.join("src");
531    if src_dir.is_dir() {
532        return Some(src_dir);
533    }
534
535    // Look for src in subdirectories
536    for entry in (fs::read_dir(base_path).ok()?).flatten() {
537        let path = entry.path();
538        if path.is_dir() {
539            let nested_src = path.join("src");
540            if nested_src.is_dir() {
541                return Some(nested_src);
542            }
543        }
544    }
545
546    None
547}
548
549/// Resolve path-based imports
550fn resolve_path_import(
551    import_path: &str,
552    _base_path: &Path,
553    file_map: &HashMap<String, PathBuf>,
554) -> Option<PathBuf> {
555    // Try direct lookup
556    if let Some(path) = file_map.get(import_path) {
557        return Some(path.clone());
558    }
559
560    // Try with common extensions
561    for ext in [
562        "ts", "tsx", "js", "jsx", "rs", "py", "go", "java", "rb", "php",
563    ] {
564        let with_ext = format!("{}.{}", import_path, ext);
565        if let Some(path) = file_map.get(&with_ext) {
566            return Some(path.clone());
567        }
568    }
569
570    // Try as directory with index
571    for index_name in ["index", "mod", "__init__"] {
572        for ext in ["ts", "tsx", "js", "jsx", "rs", "py"] {
573            let index_path = format!("{}/{}.{}", import_path, index_name, ext);
574            if let Some(path) = file_map.get(&index_path) {
575                return Some(path.clone());
576            }
577        }
578    }
579
580    None
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586
587    #[test]
588    fn test_extract_rust_import() {
589        assert_eq!(
590            extract_rust_import("use crate::models::Feature;"),
591            Some("crate::models::Feature".to_string())
592        );
593        assert_eq!(
594            extract_rust_import("use super::helper;"),
595            Some("super::helper".to_string())
596        );
597        assert_eq!(
598            extract_rust_import("use self::utils;"),
599            Some("self::utils".to_string())
600        );
601    }
602
603    #[test]
604    fn test_extract_javascript_import() {
605        assert_eq!(
606            extract_javascript_import("import { Feature } from './models';"),
607            Some("./models".to_string())
608        );
609        assert_eq!(
610            extract_javascript_import("const x = require('../utils');"),
611            Some("../utils".to_string())
612        );
613        assert_eq!(
614            extract_javascript_import("export { Feature } from './models';"),
615            Some("./models".to_string())
616        );
617    }
618
619    #[test]
620    fn test_extract_python_import() {
621        assert_eq!(
622            extract_python_import("from .models import Feature"),
623            Some(".models".to_string())
624        );
625        assert_eq!(
626            extract_python_import("from ..utils import helper"),
627            Some("..utils".to_string())
628        );
629    }
630
631    #[test]
632    fn test_extract_quoted_string() {
633        assert_eq!(
634            extract_quoted_string("\"./path/to/file\""),
635            Some("./path/to/file".to_string())
636        );
637        assert_eq!(
638            extract_quoted_string("'./path/to/file'"),
639            Some("./path/to/file".to_string())
640        );
641    }
642}