Skip to main content

portalis_transpiler/
import_analyzer.rs

1//! Import Analyzer - Detects Python imports and maps to Rust dependencies
2//!
3//! Analyzes Python source code to:
4//! 1. Detect all import statements using AST-based parsing
5//! 2. Map Python modules to Rust crates
6//! 3. Track WASM compatibility
7//! 4. Generate Cargo.toml dependencies
8//!
9//! ## Features
10//! - Full AST-based import detection (using rustpython-parser)
11//! - Support for all Python import patterns (simple, aliased, from, star, relative)
12//! - Module path resolution (relative imports, submodules)
13//! - Symbol tracking with aliases
14//! - Location tracking (line numbers)
15//! - Comprehensive error handling
16
17use crate::stdlib_mapper::{StdlibMapper, WasmCompatibility};
18use crate::external_packages::ExternalPackageRegistry;
19use rustpython_parser::{ast, Parse};
20use serde::{Deserialize, Serialize};
21use std::collections::HashMap;
22
23/// Represents a detected Python import with full AST information
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
25pub struct PythonImport {
26    /// Module name (e.g., "json", "pathlib")
27    pub module: String,
28
29    /// Specific items imported (e.g., ["Path", "exists"] from pathlib)
30    /// For simple imports, this is empty
31    pub items: Vec<ImportedSymbol>,
32
33    /// Import type
34    pub import_type: ImportType,
35
36    /// Module-level alias (e.g., "import numpy as np" -> Some("np"))
37    pub alias: Option<String>,
38
39    /// Relative import level (0 = absolute, 1 = ".", 2 = "..", etc.)
40    pub level: usize,
41
42    /// Line number where import appears
43    pub line: usize,
44
45    /// Column offset
46    pub col_offset: usize,
47}
48
49/// Represents an imported symbol (name from a module)
50#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
51pub struct ImportedSymbol {
52    /// Original name in the module
53    pub name: String,
54
55    /// Alias if renamed (e.g., "from os import path as p" -> Some("p"))
56    pub alias: Option<String>,
57}
58
59/// Type of import statement
60#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
61pub enum ImportType {
62    /// import module
63    Module,
64
65    /// from module import item
66    FromImport,
67
68    /// from module import *
69    StarImport,
70}
71
72/// Rust crate dependency with WASM info
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct RustDependency {
75    /// Crate name
76    pub crate_name: String,
77
78    /// Version requirement
79    pub version: String,
80
81    /// Features needed
82    pub features: Vec<String>,
83
84    /// WASM compatibility level
85    pub wasm_compat: WasmCompatibility,
86
87    /// Optional target-specific (e.g., only for wasm32)
88    pub target: Option<String>,
89
90    /// Additional notes
91    pub notes: Option<String>,
92}
93
94/// Analysis result with dependencies and compatibility info
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct ImportAnalysis {
97    /// Detected Python imports
98    pub python_imports: Vec<PythonImport>,
99
100    /// Required Rust dependencies
101    pub rust_dependencies: Vec<RustDependency>,
102
103    /// Rust use statements to add
104    pub rust_use_statements: Vec<String>,
105
106    /// WASM compatibility summary
107    pub wasm_compatibility: WasmCompatibilitySummary,
108
109    /// Unmapped modules (need manual mapping)
110    pub unmapped_modules: Vec<String>,
111}
112
113/// WASM compatibility summary
114#[derive(Debug, Clone, Serialize, Deserialize)]
115pub struct WasmCompatibilitySummary {
116    /// Can compile to WASM without any issues
117    pub fully_compatible: bool,
118
119    /// Requires WASI support
120    pub needs_wasi: bool,
121
122    /// Requires JavaScript interop
123    pub needs_js_interop: bool,
124
125    /// Has incompatible modules
126    pub has_incompatible: bool,
127
128    /// Detailed breakdown
129    pub modules_by_compat: HashMap<String, WasmCompatibility>,
130}
131
132/// Import Analyzer - analyzes Python imports using AST-based parsing
133///
134/// This analyzer uses rustpython-parser to accurately detect all Python import
135/// patterns, including:
136/// - Simple imports: `import os`
137/// - Aliased imports: `import numpy as np`
138/// - From imports: `from os import path`
139/// - From-as imports: `from os import path as p`
140/// - Star imports: `from typing import *`
141/// - Relative imports: `from . import utils`
142/// - Multi-level relative: `from ...parent import x`
143/// - Multiple imports: `import os, sys, json`
144pub struct ImportAnalyzer {
145    stdlib_mapper: StdlibMapper,
146    external_registry: ExternalPackageRegistry,
147    /// Current module path (for resolving relative imports)
148    current_module_path: Option<String>,
149}
150
151impl ImportAnalyzer {
152    /// Create new import analyzer
153    pub fn new() -> Self {
154        Self {
155            stdlib_mapper: StdlibMapper::new(),
156            external_registry: ExternalPackageRegistry::new(),
157            current_module_path: None,
158        }
159    }
160
161    /// Create analyzer with a known module path (for resolving relative imports)
162    pub fn with_module_path(module_path: String) -> Self {
163        Self {
164            stdlib_mapper: StdlibMapper::new(),
165            external_registry: ExternalPackageRegistry::new(),
166            current_module_path: Some(module_path),
167        }
168    }
169
170    /// Set the current module path (for resolving relative imports)
171    pub fn set_module_path(&mut self, module_path: String) {
172        self.current_module_path = Some(module_path);
173    }
174
175    /// Analyze Python source code and extract import information
176    pub fn analyze(&self, python_code: &str) -> ImportAnalysis {
177        let python_imports = self.extract_imports(python_code);
178        let mut rust_dependencies = Vec::new();
179        let mut rust_use_statements = Vec::new();
180        let mut unmapped_modules = Vec::new();
181        let mut modules_by_compat = HashMap::new();
182
183        for import in &python_imports {
184            // Try stdlib first
185            if let Some(module_mapping) = self.stdlib_mapper.get_module(&import.module) {
186                // Generate Rust dependency
187                let rust_dep = RustDependency {
188                    crate_name: module_mapping.rust_crate.clone()
189                        .unwrap_or_else(|| "std".to_string()),
190                    version: module_mapping.version.clone(),
191                    features: vec![],
192                    wasm_compat: module_mapping.wasm_compatible.clone(),
193                    target: None,
194                    notes: module_mapping.notes.clone(),
195                };
196
197                // Track compatibility
198                modules_by_compat.insert(
199                    import.module.clone(),
200                    module_mapping.wasm_compatible.clone()
201                );
202
203                // Generate use statement
204                if !module_mapping.rust_use.is_empty() {
205                    let use_stmt = if import.items.is_empty() {
206                        format!("use {};", module_mapping.rust_use)
207                    } else {
208                        // Map specific items with their Rust names (using alias if present)
209                        let items_str = import.items
210                            .iter()
211                            .map(|sym| {
212                                if let Some(ref alias) = sym.alias {
213                                    format!("{} as {}", sym.name, alias)
214                                } else {
215                                    sym.name.clone()
216                                }
217                            })
218                            .collect::<Vec<_>>()
219                            .join(", ");
220                        format!("use {}::{{{}}};", module_mapping.rust_use, items_str)
221                    };
222                    rust_use_statements.push(use_stmt);
223                }
224
225                // Add dependency if not stdlib
226                if module_mapping.rust_crate.is_some() {
227                    rust_dependencies.push(rust_dep);
228                }
229            }
230            // Try external packages
231            else if let Some(pkg_mapping) = self.external_registry.get_package(&import.module) {
232                let rust_dep = RustDependency {
233                    crate_name: pkg_mapping.rust_crate.clone(),
234                    version: pkg_mapping.version.clone(),
235                    features: pkg_mapping.features.clone(),
236                    wasm_compat: pkg_mapping.wasm_compatible.clone(),
237                    target: None,
238                    notes: pkg_mapping.notes.clone(),
239                };
240
241                modules_by_compat.insert(
242                    import.module.clone(),
243                    pkg_mapping.wasm_compatible.clone()
244                );
245
246                // Generate use statement
247                rust_use_statements.push(format!("use {};", pkg_mapping.rust_crate));
248
249                rust_dependencies.push(rust_dep);
250            } else {
251                // Module not yet mapped
252                unmapped_modules.push(import.module.clone());
253            }
254        }
255
256        // Deduplicate dependencies
257        rust_dependencies.sort_by(|a, b| a.crate_name.cmp(&b.crate_name));
258        rust_dependencies.dedup_by(|a, b| a.crate_name == b.crate_name);
259
260        rust_use_statements.sort();
261        rust_use_statements.dedup();
262
263        unmapped_modules.sort();
264        unmapped_modules.dedup();
265
266        // Build WASM compatibility summary
267        let wasm_compatibility = self.build_wasm_summary(&modules_by_compat);
268
269        ImportAnalysis {
270            python_imports,
271            rust_dependencies,
272            rust_use_statements,
273            wasm_compatibility,
274            unmapped_modules,
275        }
276    }
277
278    /// Extract import statements from Python code using AST-based parsing
279    ///
280    /// This method uses rustpython-parser to accurately detect all import patterns,
281    /// including complex multi-line imports, relative imports, and star imports.
282    fn extract_imports(&self, python_code: &str) -> Vec<PythonImport> {
283        let mut imports = Vec::new();
284
285        // Parse the Python code into an AST
286        let parsed = match ast::Suite::parse(python_code, "<input>") {
287            Ok(suite) => suite,
288            Err(e) => {
289                // If parsing fails, fall back to empty import list
290                eprintln!("Failed to parse Python code: {}", e);
291                return imports;
292            }
293        };
294
295        // Walk the AST and extract all import statements
296        for stmt in parsed.iter() {
297            match stmt {
298                // Handle "import module [as alias]" statements
299                ast::Stmt::Import(import_stmt) => {
300                    self.extract_import_stmt(import_stmt, &mut imports);
301                }
302                // Handle "from module import ..." statements
303                ast::Stmt::ImportFrom(import_from_stmt) => {
304                    self.extract_import_from_stmt(import_from_stmt, &mut imports);
305                }
306                _ => {}
307            }
308        }
309
310        imports
311    }
312
313    /// Extract imports from "import module [as alias]" statements
314    ///
315    /// Handles:
316    /// - `import os`
317    /// - `import numpy as np`
318    /// - `import os, sys, json` (multiple imports in one statement)
319    fn extract_import_stmt(&self, import_stmt: &ast::StmtImport, imports: &mut Vec<PythonImport>) {
320        for alias in &import_stmt.names {
321            let module = alias.name.to_string();
322            let alias_name = alias.asname.as_ref().map(|a| a.to_string());
323
324            imports.push(PythonImport {
325                module,
326                items: vec![],
327                import_type: ImportType::Module,
328                alias: alias_name,
329                level: 0, // Absolute import
330                line: 0, // TODO: Extract line info when TextRange API is available
331                col_offset: 0,
332            });
333        }
334    }
335
336    /// Extract imports from "from module import ..." statements
337    ///
338    /// Handles:
339    /// - `from os import path`
340    /// - `from os import path as p`
341    /// - `from os import path, getcwd`
342    /// - `from typing import *`
343    /// - `from . import utils` (relative import)
344    /// - `from ..parent import module` (multi-level relative)
345    fn extract_import_from_stmt(&self, import_from: &ast::StmtImportFrom, imports: &mut Vec<PythonImport>) {
346        // Get the module name
347        let module = if let Some(module_identifier) = &import_from.module {
348            module_identifier.to_string()
349        } else {
350            // Relative import without module name (e.g., "from . import utils")
351            String::new()
352        };
353
354        // Get the relative import level (0 = absolute, 1 = ".", 2 = "..", etc.)
355        // The level field is an Option<Int> (BigInt) in rustpython-parser
356        // For relative imports, level is typically small (1-3)
357        let level: usize = if let Some(level_int) = &import_from.level {
358            // Int is a BigInt - debug format is "Int(123)"
359            // Extract the number from the debug string
360            let level_str = format!("{:?}", level_int);
361            // Parse "Int(123)" -> "123"
362            if let Some(start) = level_str.find('(') {
363                if let Some(end) = level_str.find(')') {
364                    let number_str = &level_str[start + 1..end];
365                    number_str.parse().unwrap_or(0)
366                } else {
367                    0
368                }
369            } else {
370                // Try parsing directly in case format changes
371                level_str.parse().unwrap_or(0)
372            }
373        } else {
374            0
375        };
376
377        // Resolve the full module path for relative imports
378        let resolved_module = self.resolve_relative_import(&module, level);
379
380        // Check if this is a star import
381        let is_star_import = import_from.names.iter().any(|alias| alias.name.as_str() == "*");
382
383        if is_star_import {
384            // Star import: from module import *
385            imports.push(PythonImport {
386                module: resolved_module,
387                items: vec![],
388                import_type: ImportType::StarImport,
389                alias: None,
390                level,
391                line: 0, // TODO: Extract line info when TextRange API is available
392                col_offset: 0,
393            });
394        } else {
395            // Regular from import with specific items
396            let items: Vec<ImportedSymbol> = import_from
397                .names
398                .iter()
399                .map(|alias| ImportedSymbol {
400                    name: alias.name.to_string(),
401                    alias: alias.asname.as_ref().map(|a| a.to_string()),
402                })
403                .collect();
404
405            imports.push(PythonImport {
406                module: resolved_module,
407                items,
408                import_type: ImportType::FromImport,
409                alias: None,
410                level,
411                line: 0, // TODO: Extract line info when TextRange API is available
412                col_offset: 0,
413            });
414        }
415    }
416
417    /// Resolve relative imports to absolute module paths
418    ///
419    /// Given a relative import level and module name, resolves to an absolute path.
420    /// For example:
421    /// - level=0, module="os" -> "os" (absolute import)
422    /// - level=1, module="utils" -> "current.package.utils" (from . import utils)
423    /// - level=2, module="config" -> "parent.package.config" (from .. import config)
424    ///
425    /// Note: Requires `current_module_path` to be set for proper resolution.
426    fn resolve_relative_import(&self, module: &str, level: usize) -> String {
427        if level == 0 {
428            // Absolute import
429            return module.to_string();
430        }
431
432        // Relative import - need to resolve based on current module path
433        if let Some(ref current_path) = self.current_module_path {
434            let path_parts: Vec<&str> = current_path.split('.').collect();
435
436            // Go up 'level' directories
437            let parent_depth = path_parts.len().saturating_sub(level);
438            let parent_path = path_parts[..parent_depth].join(".");
439
440            if module.is_empty() {
441                // "from . import utils" case
442                parent_path
443            } else {
444                // "from .module import x" case
445                if parent_path.is_empty() {
446                    module.to_string()
447                } else {
448                    format!("{}.{}", parent_path, module)
449                }
450            }
451        } else {
452            // No current module path set, return as-is with level indicator
453            if level == 1 {
454                format!(".{}", module)
455            } else {
456                format!("{}{}", ".".repeat(level), module)
457            }
458        }
459    }
460
461
462    /// Build WASM compatibility summary
463    fn build_wasm_summary(
464        &self,
465        modules_by_compat: &HashMap<String, WasmCompatibility>
466    ) -> WasmCompatibilitySummary {
467        let mut needs_wasi = false;
468        let mut needs_js_interop = false;
469        let mut has_incompatible = false;
470
471        for compat in modules_by_compat.values() {
472            match compat {
473                WasmCompatibility::RequiresWasi => needs_wasi = true,
474                WasmCompatibility::RequiresJsInterop => needs_js_interop = true,
475                WasmCompatibility::Incompatible => has_incompatible = true,
476                WasmCompatibility::Partial => {
477                    // Some functions work, some don't
478                    needs_wasi = true;
479                }
480                WasmCompatibility::Full => {}
481            }
482        }
483
484        let fully_compatible = !needs_wasi && !needs_js_interop && !has_incompatible;
485
486        WasmCompatibilitySummary {
487            fully_compatible,
488            needs_wasi,
489            needs_js_interop,
490            has_incompatible,
491            modules_by_compat: modules_by_compat.clone(),
492        }
493    }
494
495    /// Generate Cargo.toml dependencies section
496    pub fn generate_cargo_toml_deps(&self, analysis: &ImportAnalysis) -> String {
497        let mut output = String::new();
498        output.push_str("[dependencies]\n");
499
500        for dep in &analysis.rust_dependencies {
501            if dep.target.is_none() {
502                if dep.features.is_empty() {
503                    output.push_str(&format!("{} = \"{}\"\n", dep.crate_name, dep.version));
504                } else {
505                    output.push_str(&format!(
506                        "{} = {{ version = \"{}\", features = [{}] }}\n",
507                        dep.crate_name,
508                        dep.version,
509                        dep.features.iter()
510                            .map(|f| format!("\"{}\"", f))
511                            .collect::<Vec<_>>()
512                            .join(", ")
513                    ));
514                }
515            }
516        }
517
518        // Add target-specific dependencies
519        output.push_str("\n[target.'cfg(target_arch = \"wasm32\")'.dependencies]\n");
520
521        // Add wasm-bindgen if JS interop needed
522        if analysis.wasm_compatibility.needs_js_interop {
523            output.push_str("wasm-bindgen = \"0.2\"\n");
524            output.push_str("wasm-bindgen-futures = \"0.4\"\n");
525            output.push_str("js-sys = \"0.3\"\n");
526        }
527
528        // Add WASI if needed
529        if analysis.wasm_compatibility.needs_wasi {
530            output.push_str("\n[target.'cfg(target_arch = \"wasm32\")'.dependencies.wasi]\n");
531            output.push_str("version = \"0.11\"\n");
532            output.push_str("optional = true\n");
533        }
534
535        output
536    }
537
538    /// Generate compatibility report
539    pub fn generate_compatibility_report(&self, analysis: &ImportAnalysis) -> String {
540        let mut report = String::new();
541
542        report.push_str("# WASM Compatibility Report\n\n");
543
544        if analysis.wasm_compatibility.fully_compatible {
545            report.push_str("✅ **Fully WASM Compatible** - No special requirements\n\n");
546        } else {
547            report.push_str("## Compatibility Status\n\n");
548
549            if analysis.wasm_compatibility.needs_wasi {
550                report.push_str("⚠️  **Requires WASI** - Needs filesystem/OS support\n");
551            }
552
553            if analysis.wasm_compatibility.needs_js_interop {
554                report.push_str("🌐 **Requires JS Interop** - Needs browser APIs\n");
555            }
556
557            if analysis.wasm_compatibility.has_incompatible {
558                report.push_str("❌ **Has Incompatible Modules** - Some features won't work in WASM\n");
559            }
560
561            report.push_str("\n");
562        }
563
564        // Module-by-module breakdown
565        report.push_str("## Module Compatibility\n\n");
566
567        let mut modules: Vec<_> = analysis.wasm_compatibility.modules_by_compat.iter().collect();
568        modules.sort_by_key(|(name, _)| *name);
569
570        for (module, compat) in modules {
571            let icon = match compat {
572                WasmCompatibility::Full => "✅",
573                WasmCompatibility::Partial => "🟡",
574                WasmCompatibility::RequiresWasi => "📁",
575                WasmCompatibility::RequiresJsInterop => "🌐",
576                WasmCompatibility::Incompatible => "❌",
577            };
578
579            report.push_str(&format!("{} `{}` - {:?}\n", icon, module, compat));
580        }
581
582        // Unmapped modules warning
583        if !analysis.unmapped_modules.is_empty() {
584            report.push_str("\n## ⚠️  Unmapped Modules\n\n");
585            report.push_str("The following modules are not yet mapped to Rust crates:\n\n");
586
587            for module in &analysis.unmapped_modules {
588                report.push_str(&format!("- `{}`\n", module));
589            }
590
591            report.push_str("\nThese will need manual implementation or mapping.\n");
592        }
593
594        report
595    }
596}
597
598impl Default for ImportAnalyzer {
599    fn default() -> Self {
600        Self::new()
601    }
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607
608    #[test]
609    fn test_parse_simple_import() {
610        let analyzer = ImportAnalyzer::new();
611        let imports = analyzer.extract_imports("import json");
612
613        assert_eq!(imports.len(), 1);
614        assert_eq!(imports[0].module, "json");
615        assert_eq!(imports[0].import_type, ImportType::Module);
616        assert!(imports[0].items.is_empty());
617    }
618
619    #[test]
620    fn test_parse_import_with_alias() {
621        let analyzer = ImportAnalyzer::new();
622        let imports = analyzer.extract_imports("import datetime as dt");
623
624        assert_eq!(imports.len(), 1);
625        assert_eq!(imports[0].module, "datetime");
626        assert_eq!(imports[0].alias, Some("dt".to_string()));
627    }
628
629    #[test]
630    fn test_parse_from_import() {
631        let analyzer = ImportAnalyzer::new();
632        let imports = analyzer.extract_imports("from pathlib import Path");
633
634        assert_eq!(imports.len(), 1);
635        assert_eq!(imports[0].module, "pathlib");
636        assert_eq!(imports[0].items.len(), 1);
637        assert_eq!(imports[0].items[0].name, "Path");
638        assert_eq!(imports[0].items[0].alias, None);
639        assert_eq!(imports[0].import_type, ImportType::FromImport);
640    }
641
642    #[test]
643    fn test_parse_from_import_multiple() {
644        let analyzer = ImportAnalyzer::new();
645        let imports = analyzer.extract_imports("from datetime import datetime, timedelta");
646
647        assert_eq!(imports.len(), 1);
648        assert_eq!(imports[0].module, "datetime");
649        assert_eq!(imports[0].items.len(), 2);
650        assert_eq!(imports[0].items[0].name, "datetime");
651        assert_eq!(imports[0].items[1].name, "timedelta");
652    }
653
654    #[test]
655    fn test_parse_star_import() {
656        let analyzer = ImportAnalyzer::new();
657        let imports = analyzer.extract_imports("from os import *");
658
659        assert_eq!(imports.len(), 1);
660        assert_eq!(imports[0].module, "os");
661        assert_eq!(imports[0].import_type, ImportType::StarImport);
662    }
663
664    #[test]
665    fn test_analyze_mapped_modules() {
666        let analyzer = ImportAnalyzer::new();
667        let code = r#"
668import json
669from pathlib import Path
670import logging
671"#;
672
673        let analysis = analyzer.analyze(code);
674
675        assert_eq!(analysis.python_imports.len(), 3);
676        assert!(!analysis.rust_use_statements.is_empty());
677        assert!(analysis.unmapped_modules.is_empty());
678    }
679
680    #[test]
681    fn test_analyze_unmapped_module() {
682        let analyzer = ImportAnalyzer::new();
683        let code = "import nonexistent_module";
684
685        let analysis = analyzer.analyze(code);
686
687        assert_eq!(analysis.unmapped_modules.len(), 1);
688        assert_eq!(analysis.unmapped_modules[0], "nonexistent_module");
689    }
690
691    #[test]
692    fn test_wasm_compatibility_summary() {
693        let analyzer = ImportAnalyzer::new();
694        let code = r#"
695import json
696from pathlib import Path
697import asyncio
698"#;
699
700        let analysis = analyzer.analyze(code);
701
702        // json is full WASM, pathlib needs WASI, asyncio needs JS interop
703        assert!(!analysis.wasm_compatibility.fully_compatible);
704        assert!(analysis.wasm_compatibility.needs_wasi);
705        assert!(analysis.wasm_compatibility.needs_js_interop);
706    }
707
708    #[test]
709    fn test_generate_cargo_toml() {
710        let analyzer = ImportAnalyzer::new();
711        let code = "import json\nfrom datetime import datetime";
712
713        let analysis = analyzer.analyze(code);
714        let cargo_toml = analyzer.generate_cargo_toml_deps(&analysis);
715
716        assert!(cargo_toml.contains("[dependencies]"));
717    }
718
719    // ==================== New AST-based Tests ====================
720
721    #[test]
722    fn test_import_with_alias() {
723        let analyzer = ImportAnalyzer::new();
724        let imports = analyzer.extract_imports("import numpy as np");
725
726        assert_eq!(imports.len(), 1);
727        assert_eq!(imports[0].module, "numpy");
728        assert_eq!(imports[0].alias, Some("np".to_string()));
729        assert_eq!(imports[0].import_type, ImportType::Module);
730    }
731
732    #[test]
733    fn test_from_import_with_alias() {
734        let analyzer = ImportAnalyzer::new();
735        let imports = analyzer.extract_imports("from os import path as p");
736
737        assert_eq!(imports.len(), 1);
738        assert_eq!(imports[0].module, "os");
739        assert_eq!(imports[0].items.len(), 1);
740        assert_eq!(imports[0].items[0].name, "path");
741        assert_eq!(imports[0].items[0].alias, Some("p".to_string()));
742    }
743
744    #[test]
745    fn test_multiple_imports_one_line() {
746        let analyzer = ImportAnalyzer::new();
747        let imports = analyzer.extract_imports("import os, sys, json");
748
749        assert_eq!(imports.len(), 3);
750        assert_eq!(imports[0].module, "os");
751        assert_eq!(imports[1].module, "sys");
752        assert_eq!(imports[2].module, "json");
753    }
754
755    #[test]
756    fn test_from_import_multiple_with_aliases() {
757        let analyzer = ImportAnalyzer::new();
758        let imports = analyzer.extract_imports("from typing import List as L, Dict as D");
759
760        assert_eq!(imports.len(), 1);
761        assert_eq!(imports[0].module, "typing");
762        assert_eq!(imports[0].items.len(), 2);
763        assert_eq!(imports[0].items[0].name, "List");
764        assert_eq!(imports[0].items[0].alias, Some("L".to_string()));
765        assert_eq!(imports[0].items[1].name, "Dict");
766        assert_eq!(imports[0].items[1].alias, Some("D".to_string()));
767    }
768
769    #[test]
770    fn test_relative_import_single_dot() {
771        let analyzer = ImportAnalyzer::new();
772        let imports = analyzer.extract_imports("from . import utils");
773
774        assert_eq!(imports.len(), 1);
775        assert_eq!(imports[0].level, 1);
776        assert_eq!(imports[0].import_type, ImportType::FromImport);
777    }
778
779    #[test]
780    fn test_relative_import_double_dot() {
781        let analyzer = ImportAnalyzer::new();
782        let imports = analyzer.extract_imports("from .. import config");
783
784        assert_eq!(imports.len(), 1);
785        assert_eq!(imports[0].level, 2);
786    }
787
788    #[test]
789    fn test_relative_import_with_module() {
790        let analyzer = ImportAnalyzer::new();
791        let imports = analyzer.extract_imports("from .submodule import function");
792
793        assert_eq!(imports.len(), 1);
794        assert_eq!(imports[0].level, 1);
795        assert_eq!(imports[0].module, ".submodule");
796        assert_eq!(imports[0].items.len(), 1);
797        assert_eq!(imports[0].items[0].name, "function");
798    }
799
800    #[test]
801    fn test_resolve_relative_import_with_context() {
802        let mut analyzer = ImportAnalyzer::new();
803        analyzer.set_module_path("mypackage.subpackage.module".to_string());
804
805        let imports = analyzer.extract_imports("from . import utils");
806        assert_eq!(imports.len(), 1);
807        // Should resolve to "mypackage.subpackage"
808        assert!(imports[0].module.contains("mypackage.subpackage") || imports[0].module == "");
809    }
810
811    #[test]
812    fn test_multiline_import() {
813        let analyzer = ImportAnalyzer::new();
814        let code = r#"
815from typing import (
816    List,
817    Dict,
818    Optional
819)
820"#;
821        let imports = analyzer.extract_imports(code);
822
823        assert_eq!(imports.len(), 1);
824        assert_eq!(imports[0].module, "typing");
825        assert_eq!(imports[0].items.len(), 3);
826        assert_eq!(imports[0].items[0].name, "List");
827        assert_eq!(imports[0].items[1].name, "Dict");
828        assert_eq!(imports[0].items[2].name, "Optional");
829    }
830
831    #[test]
832    fn test_import_location_tracking() {
833        let analyzer = ImportAnalyzer::new();
834        let code = r#"import os
835import sys
836from pathlib import Path"#;
837
838        let imports = analyzer.extract_imports(code);
839
840        assert_eq!(imports.len(), 3);
841        // NOTE: Line numbers are currently set to 0 because TextRange API in rustpython-parser 0.3
842        // doesn't expose start.row/column publicly. This is a known limitation.
843        // In future versions or with direct AST access, we can extract line numbers.
844        // For now, we just verify that imports are detected correctly.
845        assert_eq!(imports[0].line, 0); // TODO: Update when TextRange API becomes available
846        assert_eq!(imports[1].line, 0);
847        assert_eq!(imports[2].line, 0);
848    }
849
850    #[test]
851    fn test_complex_import_scenario() {
852        let analyzer = ImportAnalyzer::new();
853        let code = r#"
854import os
855import sys as system
856from pathlib import Path
857from typing import List, Dict, Optional
858from collections import defaultdict as dd
859from . import utils
860from ..parent import config
861import json, re, random
862from os.path import join, exists as file_exists
863"#;
864
865        let imports = analyzer.extract_imports(code);
866
867        // Should capture all imports
868        assert!(imports.len() >= 8);
869
870        // Verify specific imports
871        let json_import = imports.iter().find(|i| i.module == "json");
872        assert!(json_import.is_some());
873
874        let sys_import = imports.iter().find(|i| i.module == "sys");
875        assert!(sys_import.is_some());
876        assert_eq!(sys_import.unwrap().alias, Some("system".to_string()));
877
878        // Check for from imports with aliases
879        let collections_import = imports.iter().find(|i| i.module == "collections");
880        assert!(collections_import.is_some());
881        if let Some(imp) = collections_import {
882            assert_eq!(imp.items.len(), 1);
883            assert_eq!(imp.items[0].name, "defaultdict");
884            assert_eq!(imp.items[0].alias, Some("dd".to_string()));
885        }
886    }
887
888    #[test]
889    fn test_error_handling_invalid_syntax() {
890        let analyzer = ImportAnalyzer::new();
891        // Invalid Python syntax
892        let imports = analyzer.extract_imports("import this is not valid python");
893
894        // Should return empty list and not panic
895        assert_eq!(imports.len(), 0);
896    }
897
898    #[test]
899    fn test_submodule_import() {
900        let analyzer = ImportAnalyzer::new();
901        let imports = analyzer.extract_imports("from os.path import join");
902
903        assert_eq!(imports.len(), 1);
904        assert_eq!(imports[0].module, "os.path");
905        assert_eq!(imports[0].items.len(), 1);
906        assert_eq!(imports[0].items[0].name, "join");
907    }
908
909    #[test]
910    fn test_namespace_package_import() {
911        let analyzer = ImportAnalyzer::new();
912        let imports = analyzer.extract_imports("from xml.etree import ElementTree");
913
914        assert_eq!(imports.len(), 1);
915        assert_eq!(imports[0].module, "xml.etree");
916        assert_eq!(imports[0].items.len(), 1);
917        assert_eq!(imports[0].items[0].name, "ElementTree");
918    }
919}