Skip to main content

tldr_cli/commands/remaining/
definition.rs

1//! Definition command - Go-to-definition functionality
2//!
3//! Finds where a symbol is defined in the codebase.
4//! Supports both position-based and name-based lookup.
5//!
6//! # Example
7//!
8//! ```bash
9//! # Position-based: find definition of symbol at line 10, column 5
10//! tldr definition src/main.py 10 5
11//!
12//! # Name-based: find definition by symbol name
13//! tldr definition --symbol MyClass --file src/main.py
14//!
15//! # Cross-file resolution with project context
16//! tldr definition --symbol helper --file src/main.py --project .
17//! ```
18
19use std::collections::HashSet;
20use std::fs;
21use std::path::{Path, PathBuf};
22
23use anyhow::Result;
24use clap::Args;
25use tree_sitter::{Node, Parser};
26use tree_sitter_python::LANGUAGE as PYTHON_LANGUAGE;
27
28use super::error::{RemainingError, RemainingResult};
29use super::types::{DefinitionResult, Location, SymbolInfo, SymbolKind};
30use crate::output::OutputWriter;
31
32use tldr_core::Language;
33
34// =============================================================================
35// Constants
36// =============================================================================
37
38/// Maximum depth for import resolution to prevent cycles
39const MAX_IMPORT_DEPTH: usize = 10;
40
41/// Python built-in functions
42const PYTHON_BUILTINS: &[&str] = &[
43    "abs",
44    "aiter",
45    "all",
46    "any",
47    "anext",
48    "ascii",
49    "bin",
50    "bool",
51    "breakpoint",
52    "bytearray",
53    "bytes",
54    "callable",
55    "chr",
56    "classmethod",
57    "compile",
58    "complex",
59    "delattr",
60    "dict",
61    "dir",
62    "divmod",
63    "enumerate",
64    "eval",
65    "exec",
66    "filter",
67    "float",
68    "format",
69    "frozenset",
70    "getattr",
71    "globals",
72    "hasattr",
73    "hash",
74    "help",
75    "hex",
76    "id",
77    "input",
78    "int",
79    "isinstance",
80    "issubclass",
81    "iter",
82    "len",
83    "list",
84    "locals",
85    "map",
86    "max",
87    "memoryview",
88    "min",
89    "next",
90    "object",
91    "oct",
92    "open",
93    "ord",
94    "pow",
95    "print",
96    "property",
97    "range",
98    "repr",
99    "reversed",
100    "round",
101    "set",
102    "setattr",
103    "slice",
104    "sorted",
105    "staticmethod",
106    "str",
107    "sum",
108    "super",
109    "tuple",
110    "type",
111    "vars",
112    "zip",
113    "__import__",
114];
115
116// =============================================================================
117// Graph Utils (TIGER-02 Mitigation)
118// =============================================================================
119
120/// Tracks visited nodes to detect cycles during import resolution
121pub struct DefinitionCycleDetector {
122    visited: HashSet<(PathBuf, String)>,
123}
124
125impl DefinitionCycleDetector {
126    /// Create a new cycle detector
127    pub fn new() -> Self {
128        Self {
129            visited: HashSet::new(),
130        }
131    }
132
133    /// Visit a (file, symbol) pair. Returns true if already visited (cycle detected).
134    pub fn visit(&mut self, file: &Path, symbol: &str) -> bool {
135        let key = (file.to_path_buf(), symbol.to_string());
136        !self.visited.insert(key)
137    }
138}
139
140impl Default for DefinitionCycleDetector {
141    fn default() -> Self {
142        Self::new()
143    }
144}
145
146// =============================================================================
147// CLI Arguments
148// =============================================================================
149
150/// Find symbol definition (go-to-definition)
151///
152/// Supports two modes:
153/// 1. Position-based: Find symbol at file:line:column and jump to its definition
154/// 2. Name-based: Find definition of a named symbol using --symbol and --file
155///
156/// # Example
157///
158/// ```bash
159/// # Position mode
160/// tldr definition src/main.py 10 5
161///
162/// # Name mode
163/// tldr definition --symbol MyClass --file src/main.py
164/// ```
165#[derive(Debug, Args)]
166pub struct DefinitionArgs {
167    /// Source file (positional, for position-based lookup)
168    pub file: Option<PathBuf>,
169
170    /// line number (1-indexed, for position-based lookup)
171    pub line: Option<u32>,
172
173    /// column number (0-indexed, for position-based lookup)
174    pub column: Option<u32>,
175
176    /// Find symbol by name instead of position
177    #[arg(long)]
178    pub symbol: Option<String>,
179
180    /// File to search in (used with --symbol)
181    #[arg(long = "file", name = "target_file")]
182    pub target_file: Option<PathBuf>,
183
184    /// Project root for cross-file resolution
185    #[arg(long)]
186    pub project: Option<PathBuf>,
187
188    /// Output file (optional, stdout if not specified)
189    #[arg(long, short = 'O')]
190    pub output: Option<PathBuf>,
191}
192
193impl DefinitionArgs {
194    /// Run the definition command
195    pub fn run(
196        &self,
197        format: crate::output::OutputFormat,
198        quiet: bool,
199        lang: Option<Language>,
200    ) -> Result<()> {
201        let writer = OutputWriter::new(format, quiet);
202
203        // Convert language option to string hint
204        let lang_hint = match lang {
205            Some(l) => format!("{:?}", l).to_lowercase(),
206            None => "auto".to_string(),
207        };
208
209        // Determine which mode we're in
210        let result = if let Some(ref symbol_name) = self.symbol {
211            // Name-based mode - require --file
212            let file = self.target_file.as_ref().ok_or_else(|| {
213                RemainingError::invalid_argument("--file is required with --symbol")
214            })?;
215
216            writer.progress(&format!(
217                "Finding definition of '{}' in {}...",
218                symbol_name,
219                file.display()
220            ));
221
222            find_definition_by_name(symbol_name, file, self.project.as_deref(), &lang_hint)?
223        } else {
224            // Position-based mode
225            let file = self
226                .file
227                .as_ref()
228                .ok_or_else(|| RemainingError::invalid_argument("file argument is required"))?;
229            let line = self
230                .line
231                .ok_or_else(|| RemainingError::invalid_argument("line argument is required"))?;
232            let column = self
233                .column
234                .ok_or_else(|| RemainingError::invalid_argument("column argument is required"))?;
235
236            writer.progress(&format!(
237                "Finding definition at {}:{}:{}...",
238                file.display(),
239                line,
240                column
241            ));
242
243            match find_definition_by_position(
244                file,
245                line,
246                column,
247                self.project.as_deref(),
248                &lang_hint,
249            ) {
250                Ok(result) => result,
251                Err(_) => {
252                    // Return a graceful "not found" result instead of failing
253                    DefinitionResult {
254                        symbol: SymbolInfo {
255                            name: format!("<unknown at {}:{}:{}>", file.display(), line, column),
256                            kind: SymbolKind::Variable,
257                            location: Some(Location::with_column(
258                                file.display().to_string(),
259                                line,
260                                column,
261                            )),
262                            type_annotation: None,
263                            docstring: None,
264                            is_builtin: false,
265                            module: None,
266                        },
267                        definition: None,
268                        type_definition: None,
269                    }
270                }
271            }
272        };
273
274        // Determine output format
275        let use_text = format == crate::output::OutputFormat::Text;
276
277        // Write output
278        if let Some(ref output_path) = self.output {
279            if use_text {
280                let text = format_definition_text(&result);
281                fs::write(output_path, text)?;
282            } else {
283                let json = serde_json::to_string_pretty(&result)?;
284                fs::write(output_path, json)?;
285            }
286        } else if use_text {
287            let text = format_definition_text(&result);
288            writer.write_text(&text)?;
289        } else {
290            writer.write(&result)?;
291        }
292
293        Ok(())
294    }
295}
296
297// =============================================================================
298// Core Functions
299// =============================================================================
300
301/// Find definition by symbol name
302pub fn find_definition_by_name(
303    symbol: &str,
304    file: &Path,
305    project: Option<&Path>,
306    lang_hint: &str,
307) -> RemainingResult<DefinitionResult> {
308    // Validate file exists
309    if !file.exists() {
310        return Err(RemainingError::file_not_found(file));
311    }
312
313    // Detect language
314    let language = detect_language(file, lang_hint)?;
315
316    // Only Python is supported currently
317    if language != Language::Python {
318        return Err(RemainingError::unsupported_language(format!(
319            "{:?}",
320            language
321        )));
322    }
323
324    // Check if it's a builtin
325    if is_builtin(symbol, &language) {
326        return Ok(DefinitionResult {
327            symbol: SymbolInfo {
328                name: symbol.to_string(),
329                kind: SymbolKind::Function,
330                location: None,
331                type_annotation: None,
332                docstring: None,
333                is_builtin: true,
334                module: Some("builtins".to_string()),
335            },
336            definition: None,
337            type_definition: None,
338        });
339    }
340
341    // Read and parse file
342    let source = fs::read_to_string(file).map_err(RemainingError::Io)?;
343
344    // Try to find the symbol in this file first
345    if let Some(result) = find_symbol_in_file(symbol, file, &source)? {
346        return Ok(result);
347    }
348
349    // If not found and we have a project context, try cross-file resolution
350    if let Some(project_root) = project {
351        let mut detector = DefinitionCycleDetector::new();
352        if let Some(result) = resolve_cross_file(symbol, file, project_root, &mut detector, 0)? {
353            return Ok(result);
354        }
355    }
356
357    Err(RemainingError::symbol_not_found(symbol, file))
358}
359
360/// Find definition by position (line, column)
361pub fn find_definition_by_position(
362    file: &Path,
363    line: u32,
364    column: u32,
365    project: Option<&Path>,
366    lang_hint: &str,
367) -> RemainingResult<DefinitionResult> {
368    // Validate file exists
369    if !file.exists() {
370        return Err(RemainingError::file_not_found(file));
371    }
372
373    // Detect language
374    let language = detect_language(file, lang_hint)?;
375
376    // Only Python is supported currently
377    if language != Language::Python {
378        return Err(RemainingError::unsupported_language(format!(
379            "{:?}",
380            language
381        )));
382    }
383
384    // Read and parse file
385    let source = fs::read_to_string(file).map_err(RemainingError::Io)?;
386
387    // Find symbol at position
388    let symbol_name = find_symbol_at_position(&source, line, column)?;
389
390    // Now find definition of that symbol
391    find_definition_by_name(&symbol_name, file, project, lang_hint)
392}
393
394/// Find symbol name at a given position
395fn find_symbol_at_position(source: &str, line: u32, column: u32) -> RemainingResult<String> {
396    let mut parser = Parser::new();
397    parser
398        .set_language(&PYTHON_LANGUAGE.into())
399        .map_err(|e| RemainingError::parse_error(PathBuf::from("<input>"), e.to_string()))?;
400
401    let tree = parser.parse(source, None).ok_or_else(|| {
402        RemainingError::parse_error(PathBuf::from("<input>"), "Failed to parse".to_string())
403    })?;
404
405    // Convert 1-indexed line to 0-indexed
406    let target_line = line.saturating_sub(1) as usize;
407    let target_col = column as usize;
408
409    // Find the node at the position
410    let root = tree.root_node();
411    let point = tree_sitter::Point::new(target_line, target_col);
412
413    let node = root
414        .descendant_for_point_range(point, point)
415        .ok_or_else(|| {
416            RemainingError::invalid_argument(format!(
417                "No symbol found at line {}, column {}",
418                line, column
419            ))
420        })?;
421
422    // Get the identifier
423    let text = node.utf8_text(source.as_bytes()).map_err(|_| {
424        RemainingError::parse_error(PathBuf::from("<input>"), "Invalid UTF-8".to_string())
425    })?;
426
427    // If this is an identifier, return it
428    if node.kind() == "identifier" || node.kind() == "property_identifier" {
429        return Ok(text.to_string());
430    }
431
432    // Try parent nodes to find an identifier
433    let mut current = Some(node);
434    while let Some(n) = current {
435        if n.kind() == "identifier" || n.kind() == "property_identifier" {
436            let text = n.utf8_text(source.as_bytes()).map_err(|_| {
437                RemainingError::parse_error(PathBuf::from("<input>"), "Invalid UTF-8".to_string())
438            })?;
439            return Ok(text.to_string());
440        }
441        current = n.parent();
442    }
443
444    // Return what we found
445    Ok(text.to_string())
446}
447
448/// Find a symbol definition within a single file
449fn find_symbol_in_file(
450    symbol: &str,
451    file: &Path,
452    source: &str,
453) -> RemainingResult<Option<DefinitionResult>> {
454    let mut parser = Parser::new();
455    parser
456        .set_language(&PYTHON_LANGUAGE.into())
457        .map_err(|e| RemainingError::parse_error(file.to_path_buf(), e.to_string()))?;
458
459    let tree = parser.parse(source, None).ok_or_else(|| {
460        RemainingError::parse_error(file.to_path_buf(), "Failed to parse".to_string())
461    })?;
462
463    let root = tree.root_node();
464
465    // Search for function/class/method definitions using tree traversal
466    if let Some((kind, location)) = find_definition_recursive(root, source, symbol, file) {
467        return Ok(Some(DefinitionResult {
468            symbol: SymbolInfo {
469                name: symbol.to_string(),
470                kind,
471                location: Some(location.clone()),
472                type_annotation: None,
473                docstring: None,
474                is_builtin: false,
475                module: None,
476            },
477            definition: Some(location),
478            type_definition: None,
479        }));
480    }
481
482    Ok(None)
483}
484
485/// Recursively search the AST for a definition
486fn find_definition_recursive(
487    node: Node,
488    source: &str,
489    target_name: &str,
490    file: &Path,
491) -> Option<(SymbolKind, Location)> {
492    match node.kind() {
493        "function_definition" => {
494            // Get the name child
495            if let Some(name_node) = node.child_by_field_name("name") {
496                if let Ok(name) = name_node.utf8_text(source.as_bytes()) {
497                    if name == target_name {
498                        // Check if inside a class by looking at parents
499                        let in_class = is_inside_class(node);
500                        let kind = if in_class {
501                            SymbolKind::Method
502                        } else {
503                            SymbolKind::Function
504                        };
505                        let location = Location::with_column(
506                            file.display().to_string(),
507                            name_node.start_position().row as u32 + 1,
508                            name_node.start_position().column as u32,
509                        );
510                        return Some((kind, location));
511                    }
512                }
513            }
514        }
515        "class_definition" => {
516            // Get the name child
517            if let Some(name_node) = node.child_by_field_name("name") {
518                if let Ok(name) = name_node.utf8_text(source.as_bytes()) {
519                    if name == target_name {
520                        let location = Location::with_column(
521                            file.display().to_string(),
522                            name_node.start_position().row as u32 + 1,
523                            name_node.start_position().column as u32,
524                        );
525                        return Some((SymbolKind::Class, location));
526                    }
527                }
528            }
529        }
530        "assignment" => {
531            // Check for variable assignments at module level
532            if let Some(left) = node.child_by_field_name("left") {
533                if left.kind() == "identifier" {
534                    if let Ok(name) = left.utf8_text(source.as_bytes()) {
535                        if name == target_name {
536                            let location = Location::with_column(
537                                file.display().to_string(),
538                                left.start_position().row as u32 + 1,
539                                left.start_position().column as u32,
540                            );
541                            return Some((SymbolKind::Variable, location));
542                        }
543                    }
544                }
545            }
546        }
547        _ => {}
548    }
549
550    // Search children
551    for i in 0..node.child_count() {
552        if let Some(child) = node.child(i) {
553            if let Some(result) = find_definition_recursive(child, source, target_name, file) {
554                return Some(result);
555            }
556        }
557    }
558
559    None
560}
561
562/// Check if a node is inside a class definition
563fn is_inside_class(node: Node) -> bool {
564    let mut current = node.parent();
565    while let Some(n) = current {
566        if n.kind() == "class_definition" {
567            return true;
568        }
569        current = n.parent();
570    }
571    false
572}
573
574/// Resolve symbol across files via imports
575fn resolve_cross_file(
576    symbol: &str,
577    current_file: &Path,
578    project_root: &Path,
579    detector: &mut DefinitionCycleDetector,
580    depth: usize,
581) -> RemainingResult<Option<DefinitionResult>> {
582    // Prevent infinite recursion
583    if depth >= MAX_IMPORT_DEPTH {
584        return Ok(None);
585    }
586
587    // Check for cycle
588    if detector.visit(current_file, symbol) {
589        return Ok(None);
590    }
591
592    // Read current file
593    let source = fs::read_to_string(current_file).map_err(RemainingError::Io)?;
594
595    // Find imports in the current file and check if symbol is imported
596    let imports = extract_imports(&source);
597
598    for (module_path, imported_names) in imports {
599        // Check if our symbol is imported from this module
600        let is_imported = imported_names.is_empty() // Star import or module import
601            || imported_names.contains(&symbol.to_string());
602
603        if is_imported {
604            // Resolve module path to file path
605            if let Some(resolved_path) =
606                resolve_module_path(&module_path, current_file, project_root)
607            {
608                if resolved_path.exists() {
609                    let module_source =
610                        fs::read_to_string(&resolved_path).map_err(RemainingError::Io)?;
611
612                    if let Some(result) =
613                        find_symbol_in_file(symbol, &resolved_path, &module_source)?
614                    {
615                        return Ok(Some(result));
616                    }
617
618                    // Recursively check imports in that file
619                    if let Some(result) = resolve_cross_file(
620                        symbol,
621                        &resolved_path,
622                        project_root,
623                        detector,
624                        depth + 1,
625                    )? {
626                        return Ok(Some(result));
627                    }
628                }
629            }
630        }
631    }
632
633    Ok(None)
634}
635
636/// Extract import statements from source code
637fn extract_imports(source: &str) -> Vec<(String, Vec<String>)> {
638    let mut imports = Vec::new();
639
640    for line in source.lines() {
641        let line = line.trim();
642        if line.starts_with("from ") {
643            if let Some(import_idx) = line.find(" import ") {
644                let module = &line[5..import_idx];
645                let names_str = &line[import_idx + 8..];
646                let names: Vec<String> = names_str
647                    .split(',')
648                    .map(|s| {
649                        s.trim()
650                            .split(" as ")
651                            .next()
652                            .unwrap_or("")
653                            .trim()
654                            .to_string()
655                    })
656                    .filter(|s| !s.is_empty() && s != "*")
657                    .collect();
658                imports.push((module.trim().to_string(), names));
659            }
660        } else if let Some(module) = line.strip_prefix("import ") {
661            let module = module.split(" as ").next().unwrap_or(module).trim();
662            imports.push((module.to_string(), Vec::new()));
663        }
664    }
665
666    imports
667}
668
669/// Resolve a module path to a file path
670///
671/// Handles both absolute imports (`os.path`) and relative imports (`.utils`, `..pkg.mod`).
672/// For relative imports, leading dots indicate the number of parent directories to traverse
673/// from the current file's location (1 dot = same package, 2 dots = parent, etc.).
674fn resolve_module_path(module: &str, current_file: &Path, project_root: &Path) -> Option<PathBuf> {
675    let current_dir = current_file.parent()?;
676
677    // Count leading dots for relative imports
678    let dot_count = module.chars().take_while(|&c| c == '.').count();
679
680    if dot_count > 0 {
681        // Relative import: strip the leading dots and resolve relative to current package
682        let remainder = &module[dot_count..];
683
684        // Navigate up (dot_count - 1) directories from the current file's directory.
685        // 1 dot  = same directory as current file
686        // 2 dots = parent directory
687        // 3 dots = grandparent directory, etc.
688        let mut base = current_dir.to_path_buf();
689        for _ in 1..dot_count {
690            base = base.parent()?.to_path_buf();
691        }
692
693        if remainder.is_empty() {
694            // "from . import X" - resolve to __init__.py in current package
695            let pkg_candidate = base.join("__init__.py");
696            if pkg_candidate.exists() {
697                return Some(pkg_candidate);
698            }
699            return None;
700        }
701
702        // Convert remaining dotted path to filesystem path
703        let rel_path = remainder.replace('.', "/");
704
705        // Try as a module file
706        let candidate = base.join(&rel_path).with_extension("py");
707        if candidate.exists() {
708            return Some(candidate);
709        }
710
711        // Try as a package directory
712        let pkg_candidate = base.join(&rel_path).join("__init__.py");
713        if pkg_candidate.exists() {
714            return Some(pkg_candidate);
715        }
716
717        return None;
718    }
719
720    // Absolute import: try relative to current directory first, then project root
721    let rel_path = module.replace('.', "/");
722
723    // Try relative to current file's directory
724    let candidate = current_dir.join(&rel_path).with_extension("py");
725    if candidate.exists() {
726        return Some(candidate);
727    }
728
729    // Try as package
730    let pkg_candidate = current_dir.join(&rel_path).join("__init__.py");
731    if pkg_candidate.exists() {
732        return Some(pkg_candidate);
733    }
734
735    // Try relative to project root
736    let candidate = project_root.join(&rel_path).with_extension("py");
737    if candidate.exists() {
738        return Some(candidate);
739    }
740
741    let pkg_candidate = project_root.join(&rel_path).join("__init__.py");
742    if pkg_candidate.exists() {
743        return Some(pkg_candidate);
744    }
745
746    None
747}
748
749// =============================================================================
750// Helper Functions
751// =============================================================================
752
753/// Check if a symbol is a language builtin
754pub fn is_builtin(name: &str, language: &Language) -> bool {
755    match language {
756        Language::Python => PYTHON_BUILTINS.contains(&name),
757        _ => false,
758    }
759}
760
761/// Detect language from file extension or hint
762fn detect_language(file: &Path, hint: &str) -> RemainingResult<Language> {
763    if hint != "auto" {
764        return match hint.to_lowercase().as_str() {
765            "python" | "py" => Ok(Language::Python),
766            "typescript" | "ts" => Ok(Language::TypeScript),
767            "javascript" | "js" => Ok(Language::JavaScript),
768            "rust" | "rs" => Ok(Language::Rust),
769            "go" | "golang" => Ok(Language::Go),
770            _ => Err(RemainingError::unsupported_language(hint)),
771        };
772    }
773
774    let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
775    match ext {
776        "py" => Ok(Language::Python),
777        "ts" | "tsx" => Ok(Language::TypeScript),
778        "js" | "jsx" => Ok(Language::JavaScript),
779        "rs" => Ok(Language::Rust),
780        "go" => Ok(Language::Go),
781        _ => Err(RemainingError::unsupported_language(ext)),
782    }
783}
784
785/// Format definition result as text
786fn format_definition_text(result: &DefinitionResult) -> String {
787    let mut output = String::new();
788
789    output.push_str("=== Definition Result ===\n\n");
790    output.push_str(&format!("Symbol: {}\n", result.symbol.name));
791    output.push_str(&format!("Kind: {:?}\n", result.symbol.kind));
792
793    if result.symbol.is_builtin {
794        output.push_str("Type: Built-in\n");
795        if let Some(ref module) = result.symbol.module {
796            output.push_str(&format!("Module: {}\n", module));
797        }
798    } else if let Some(ref location) = result.definition {
799        output.push_str("\nDefinition Location:\n");
800        output.push_str(&format!("  File: {}\n", location.file));
801        output.push_str(&format!("  Line: {}\n", location.line));
802        if location.column > 0 {
803            output.push_str(&format!("  Column: {}\n", location.column));
804        }
805    } else {
806        output.push_str("\nDefinition: Not found\n");
807    }
808
809    if let Some(ref type_def) = result.type_definition {
810        output.push_str("\nType Definition:\n");
811        output.push_str(&format!("  File: {}\n", type_def.file));
812        output.push_str(&format!("  Line: {}\n", type_def.line));
813    }
814
815    if let Some(ref docstring) = result.symbol.docstring {
816        output.push_str(&format!("\nDocstring:\n  {}\n", docstring));
817    }
818
819    output
820}
821
822// =============================================================================
823// Tests
824// =============================================================================
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829
830    #[test]
831    fn test_is_builtin_python() {
832        assert!(is_builtin("len", &Language::Python));
833        assert!(is_builtin("print", &Language::Python));
834        assert!(is_builtin("range", &Language::Python));
835        assert!(!is_builtin("my_func", &Language::Python));
836    }
837
838    #[test]
839    fn test_cycle_detector() {
840        let mut detector = DefinitionCycleDetector::new();
841
842        // First visit should return false (not a cycle)
843        assert!(!detector.visit(Path::new("file.py"), "symbol"));
844
845        // Second visit to same location should return true (cycle)
846        assert!(detector.visit(Path::new("file.py"), "symbol"));
847
848        // Different location should return false
849        assert!(!detector.visit(Path::new("other.py"), "symbol"));
850    }
851
852    #[test]
853    fn test_detect_language() {
854        assert_eq!(
855            detect_language(Path::new("test.py"), "auto").unwrap(),
856            Language::Python
857        );
858    }
859
860    #[test]
861    fn test_detect_language_with_hint() {
862        assert_eq!(
863            detect_language(Path::new("test.txt"), "python").unwrap(),
864            Language::Python
865        );
866    }
867
868    #[test]
869    fn test_extract_imports() {
870        let source = r#"
871from os import path, getcwd
872from sys import argv
873import json
874import re as regex
875"#;
876        let imports = extract_imports(source);
877
878        assert_eq!(imports.len(), 4);
879        assert_eq!(imports[0].0, "os");
880        assert!(imports[0].1.contains(&"path".to_string()));
881        assert!(imports[0].1.contains(&"getcwd".to_string()));
882        assert_eq!(imports[1].0, "sys");
883        assert!(imports[1].1.contains(&"argv".to_string()));
884        assert_eq!(imports[2].0, "json");
885        assert_eq!(imports[3].0, "re");
886    }
887
888    #[test]
889    fn test_extract_imports_relative() {
890        let source = r#"
891from .utils import echo, make_str
892from .exceptions import Abort
893from ._utils import FLAG_NEEDS_VALUE
894from . import types
895"#;
896        let imports = extract_imports(source);
897
898        assert_eq!(imports.len(), 4);
899        // Relative imports should preserve the dot prefix
900        assert_eq!(imports[0].0, ".utils");
901        assert!(imports[0].1.contains(&"echo".to_string()));
902        assert!(imports[0].1.contains(&"make_str".to_string()));
903        assert_eq!(imports[1].0, ".exceptions");
904        assert!(imports[1].1.contains(&"Abort".to_string()));
905        assert_eq!(imports[2].0, "._utils");
906        assert!(imports[2].1.contains(&"FLAG_NEEDS_VALUE".to_string()));
907        assert_eq!(imports[3].0, ".");
908        assert!(imports[3].1.contains(&"types".to_string()));
909    }
910
911    #[test]
912    fn test_resolve_module_path_relative_import() {
913        // Create a temp directory structure simulating a Python package
914        let dir = tempfile::tempdir().unwrap();
915        let pkg = dir.path().join("mypkg");
916        fs::create_dir_all(&pkg).unwrap();
917
918        // Create files
919        fs::write(pkg.join("__init__.py"), "").unwrap();
920        fs::write(pkg.join("core.py"), "from .utils import helper\n").unwrap();
921        fs::write(pkg.join("utils.py"), "def helper(): pass\n").unwrap();
922
923        let current_file = pkg.join("core.py");
924        let project_root = dir.path();
925
926        // Relative import ".utils" from core.py should resolve to utils.py in the same directory
927        let resolved = resolve_module_path(".utils", &current_file, project_root);
928        assert!(
929            resolved.is_some(),
930            "resolve_module_path should find .utils relative to core.py"
931        );
932        assert_eq!(
933            resolved.unwrap(),
934            pkg.join("utils.py"),
935            "Should resolve to sibling utils.py"
936        );
937    }
938
939    #[test]
940    fn test_resolve_module_path_relative_import_subpackage() {
941        let dir = tempfile::tempdir().unwrap();
942        let pkg = dir.path().join("mypkg");
943        let sub = pkg.join("sub");
944        fs::create_dir_all(&sub).unwrap();
945
946        fs::write(pkg.join("__init__.py"), "").unwrap();
947        fs::write(sub.join("__init__.py"), "").unwrap();
948        fs::write(pkg.join("core.py"), "").unwrap();
949        fs::write(sub.join("helpers.py"), "def helper(): pass\n").unwrap();
950
951        let current_file = pkg.join("core.py");
952        let project_root = dir.path();
953
954        // ".sub.helpers" from core.py should resolve to sub/helpers.py
955        let resolved = resolve_module_path(".sub.helpers", &current_file, project_root);
956        assert!(
957            resolved.is_some(),
958            "resolve_module_path should find .sub.helpers relative to core.py"
959        );
960        assert_eq!(
961            resolved.unwrap(),
962            sub.join("helpers.py"),
963            "Should resolve to sub/helpers.py"
964        );
965    }
966
967    #[test]
968    fn test_cross_file_definition_via_relative_import() {
969        let dir = tempfile::tempdir().unwrap();
970        let pkg = dir.path().join("mypkg");
971        fs::create_dir_all(&pkg).unwrap();
972
973        fs::write(pkg.join("__init__.py"), "").unwrap();
974        fs::write(
975            pkg.join("core.py"),
976            "from .utils import echo\n\ndef main():\n    echo('hello')\n",
977        )
978        .unwrap();
979        fs::write(pkg.join("utils.py"), "def echo(msg):\n    print(msg)\n").unwrap();
980
981        // Look for 'echo' starting from core.py with project context
982        let result =
983            find_definition_by_name("echo", &pkg.join("core.py"), Some(dir.path()), "python");
984
985        assert!(
986            result.is_ok(),
987            "Should find echo via cross-file resolution: {:?}",
988            result.err()
989        );
990        let result = result.unwrap();
991        assert_eq!(result.symbol.name, "echo");
992        assert_eq!(result.symbol.kind, SymbolKind::Function);
993        assert!(
994            result.definition.is_some(),
995            "Should have a definition location"
996        );
997        let def_loc = result.definition.unwrap();
998        assert!(
999            def_loc.file.contains("utils.py"),
1000            "Definition should be in utils.py, got: {}",
1001            def_loc.file
1002        );
1003        assert_eq!(def_loc.line, 1, "echo is defined on line 1 of utils.py");
1004    }
1005}