Skip to main content

sqry_core/graph/
path_resolver.rs

1//! Path resolution for import statements across languages
2//!
3//! This module provides utilities for resolving relative and absolute import paths
4//! to canonical module identifiers, ensuring that import edges in the graph point
5//! to the correct target nodes.
6//!
7//! # Problem
8//!
9//! Without proper path resolution, relative imports collapse together:
10//! - `src/foo/index.js` importing `"./utils"` → points to `"./utils"`
11//! - `src/bar/index.js` importing `"./utils"` → points to same `"./utils"`
12//!
13//! These refer to different files but create the same module node.
14//!
15//! # Solution
16//!
17//! This module resolves import paths to canonical forms:
18//! - Relative paths (`./utils`, `../lib/helper`) → absolute paths
19//! - Normalize path separators (handle both `/` and `\`)
20//! - Handle `.` and `..` components
21//! - Preserve absolute paths and package imports as-is
22//!
23//! # Example
24//!
25//! ```rust
26//! use sqry_core::graph::path_resolver::resolve_import_path;
27//! use std::path::Path;
28//!
29//! // Relative import from src/components/Button.js
30//! let source_file = Path::new("src/components/Button.js");
31//! let import_path = "./Icon";
32//! let resolved = resolve_import_path(source_file, import_path).unwrap();
33//! assert_eq!(resolved, "src/components/Icon");
34//!
35//! // Parent directory import
36//! let import_path = "../utils/helpers";
37//! let resolved = resolve_import_path(source_file, import_path).unwrap();
38//! assert_eq!(resolved, "src/utils/helpers");
39//!
40//! // Package imports are preserved
41//! let resolved = resolve_import_path(source_file, "react").unwrap();
42//! assert_eq!(resolved, "react");
43//! ```
44
45use crate::graph::error::{GraphBuilderError, GraphResult};
46use crate::graph::node::Span;
47use std::path::{Component, Path};
48
49/// Resolve an import path to a canonical module identifier
50///
51/// This function handles:
52/// - Relative paths: `./foo`, `../bar/baz`
53/// - Absolute paths: `/usr/lib/module`
54/// - Package imports: `react`, `@babel/core` (returned as-is)
55/// - Path normalization: Remove `.` and `..` components
56///
57/// # Arguments
58///
59/// * `source_file` - The file containing the import statement
60/// * `import_path` - The import path string from the AST
61///
62/// # Returns
63///
64/// A canonical module identifier string, or an error if path resolution fails
65///
66/// # Errors
67///
68/// Returns [`GraphBuilderError::ParseError`] when the import path is empty, the
69/// source file lacks a parent directory, or normalization fails after joining
70/// path components.
71///
72/// # Examples
73///
74/// ```rust
75/// use sqry_core::graph::path_resolver::resolve_import_path;
76/// use std::path::Path;
77///
78/// let source = Path::new("src/app/main.js");
79///
80/// // Relative imports
81/// assert_eq!(resolve_import_path(source, "./utils").unwrap(), "src/app/utils");
82/// assert_eq!(resolve_import_path(source, "../lib/db").unwrap(), "src/lib/db");
83///
84/// // Package imports (preserved)
85/// assert_eq!(resolve_import_path(source, "lodash").unwrap(), "lodash");
86/// assert_eq!(resolve_import_path(source, "@types/node").unwrap(), "@types/node");
87/// ```
88pub fn resolve_import_path(source_file: &Path, import_path: &str) -> GraphResult<String> {
89    // Trim whitespace and quotes
90    let import_path = import_path.trim();
91
92    // Empty imports are invalid
93    if import_path.is_empty() {
94        return Err(GraphBuilderError::ParseError {
95            span: Span::default(),
96            reason: "Empty import path".to_string(),
97        });
98    }
99
100    // Package imports (not starting with . or /) - return as-is
101    // Examples: "react", "@babel/core", "lodash/fp"
102    if !import_path.starts_with('.') && !import_path.starts_with('/') {
103        return Ok(import_path.to_string());
104    }
105
106    // Absolute paths - normalize but keep absolute
107    if import_path.starts_with('/') {
108        let path = Path::new(import_path);
109        return normalize_path(path).ok_or_else(|| GraphBuilderError::ParseError {
110            span: Span::default(),
111            reason: format!("Failed to normalize absolute path: {import_path}"),
112        });
113    }
114
115    // Relative imports - resolve against source file's directory
116    let source_dir = source_file
117        .parent()
118        .ok_or_else(|| GraphBuilderError::ParseError {
119            span: Span::default(),
120            reason: format!(
121                "Source file has no parent directory: {}",
122                source_file.display()
123            ),
124        })?;
125
126    // Join source directory with import path
127    let full_path = source_dir.join(import_path);
128
129    // Normalize the path (remove . and .. components)
130    normalize_path(&full_path).ok_or_else(|| GraphBuilderError::ParseError {
131        span: Span::default(),
132        reason: format!("Failed to normalize import path: {}", full_path.display()),
133    })
134}
135
136/// Normalize a path by removing `.` and `..` components
137///
138/// This function:
139/// - Removes `.` components (current directory)
140/// - Resolves `..` components (parent directory)
141/// - Converts path to forward slashes for consistency
142/// - Preserves relative vs absolute nature of the path
143///
144/// # Arguments
145///
146/// * `path` - The path to normalize
147///
148/// # Returns
149///
150/// A normalized path string, or None if the path cannot be normalized
151/// (e.g., too many `..` components going above root)
152///
153/// # Examples
154///
155/// ```rust
156/// use sqry_core::graph::path_resolver::normalize_path;
157/// use std::path::Path;
158///
159/// assert_eq!(
160///     normalize_path(Path::new("src/./app/../lib/utils")).unwrap(),
161///     "src/lib/utils"
162/// );
163///
164/// assert_eq!(
165///     normalize_path(Path::new("./foo/./bar")).unwrap(),
166///     "foo/bar"
167/// );
168/// ```
169pub fn normalize_path(path: &Path) -> Option<String> {
170    let mut components = Vec::new();
171
172    for component in path.components() {
173        match component {
174            Component::CurDir => {
175                // Skip "." components
176            }
177            Component::ParentDir => {
178                // Go up one level by popping last component
179                // If we can't pop, the path is invalid (too many ..)
180                if components.is_empty() {
181                    return None;
182                }
183                components.pop();
184            }
185            Component::Normal(name) => {
186                // Regular path component
187                components.push(name.to_string_lossy().to_string());
188            }
189            Component::RootDir => {
190                // Absolute path marker - keep it
191                components.clear();
192                components.push(String::new()); // Marker for absolute path
193            }
194            Component::Prefix(_) => {
195                // Windows prefix (C:, \\server\share, etc.)
196                // For now, preserve as-is
197                components.push(component.as_os_str().to_string_lossy().to_string());
198            }
199        }
200    }
201
202    // Join components with forward slashes for consistency across platforms
203    if components.is_empty() {
204        Some(".".to_string())
205    } else if components.len() == 1 && components[0].is_empty() {
206        // Root directory
207        Some("/".to_string())
208    } else {
209        // Filter out the root marker (empty string at start)
210        let is_absolute = !components.is_empty() && components[0].is_empty();
211        let parts: Vec<&str> = components
212            .iter()
213            .filter(|s| !s.is_empty())
214            .map(std::string::String::as_str)
215            .collect();
216
217        if is_absolute {
218            Some(format!("/{}", parts.join("/")))
219        } else {
220            Some(parts.join("/"))
221        }
222    }
223}
224
225/// Resolve a Python module import path
226///
227/// Python imports can be:
228/// - Absolute: `import os`, `from package.module import foo`
229/// - Relative: `from . import sibling`, `from .. import parent`
230/// - Relative with module: `from .subpkg import module`
231///
232/// This function handles the unique aspects of Python's import system:
233/// - Dot notation for package hierarchy (`package.subpkg.module`)
234/// - Relative imports with leading dots (`from .. import X`)
235///
236/// # Arguments
237///
238/// * `source_file` - The Python file containing the import
239/// * `import_path` - The module path (may start with dots for relative imports)
240/// * `is_from_import` - Whether this is a `from X import Y` statement
241///
242/// # Returns
243///
244/// A canonical module identifier
245///
246/// # Errors
247///
248/// Returns [`GraphBuilderError::ParseError`] when the import path is empty,
249/// contains more leading dots than available parent directories, or when the
250/// resolved path cannot be normalized.
251///
252/// # Examples
253///
254/// ```rust
255/// use sqry_core::graph::path_resolver::resolve_python_import;
256/// use std::path::Path;
257///
258/// let source = Path::new("mypackage/subpkg/module.py");
259///
260/// // Absolute imports (preserved)
261/// assert_eq!(resolve_python_import(source, "os.path", false).unwrap(), "os.path");
262///
263/// // Relative imports with module name
264/// assert_eq!(
265///     resolve_python_import(source, ".sibling", true).unwrap(),
266///     "mypackage/subpkg/sibling"
267/// );
268///
269/// // Parent package imports
270/// assert_eq!(
271///     resolve_python_import(source, "..", true).unwrap(),
272///     "mypackage"
273/// );
274/// ```
275pub fn resolve_python_import(
276    source_file: &Path,
277    import_path: &str,
278    _is_from_import: bool,
279) -> GraphResult<String> {
280    let import_path = import_path.trim();
281
282    if import_path.is_empty() {
283        return Err(GraphBuilderError::ParseError {
284            span: Span::default(),
285            reason: "Empty Python import path".to_string(),
286        });
287    }
288
289    // If not a relative import (no leading dots), return as-is
290    if !import_path.starts_with('.') {
291        return Ok(import_path.to_string());
292    }
293
294    // Count leading dots to determine relative level
295    let leading_dots = import_path.chars().take_while(|&c| c == '.').count();
296    let module_name = &import_path[leading_dots..];
297
298    // Get the source file's directory
299    let source_dir = source_file
300        .parent()
301        .ok_or_else(|| GraphBuilderError::ParseError {
302            span: Span::default(),
303            reason: format!(
304                "Python file has no parent directory: {}",
305                source_file.display()
306            ),
307        })?;
308
309    // Start from source directory and go up (leading_dots - 1) times
310    // Note: `from . import X` means current package (0 levels up)
311    //       `from .. import X` means parent package (1 level up)
312    let mut target_dir = source_dir.to_path_buf();
313    for _ in 1..leading_dots {
314        target_dir = target_dir
315            .parent()
316            .ok_or_else(|| GraphBuilderError::ParseError {
317                span: Span::default(),
318                reason: format!("Too many leading dots in import: {import_path}"),
319            })?
320            .to_path_buf();
321    }
322
323    // If there's a module name after the dots, append it
324    let resolved_path = if module_name.is_empty() {
325        // Just the package itself (from . import X or from .. import X)
326        target_dir
327    } else {
328        // Convert dots in module name to path separators
329        let module_path = module_name.replace('.', "/");
330        target_dir.join(module_path)
331    };
332
333    // Normalize and convert to string
334    normalize_path(&resolved_path).ok_or_else(|| GraphBuilderError::ParseError {
335        span: Span::default(),
336        reason: format!(
337            "Failed to normalize Python import path: {}",
338            resolved_path.display()
339        ),
340    })
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn test_resolve_import_path_relative() {
349        let source = Path::new("src/components/Button.js");
350
351        // Same directory
352        assert_eq!(
353            resolve_import_path(source, "./Icon").unwrap(),
354            "src/components/Icon"
355        );
356
357        // Parent directory
358        assert_eq!(
359            resolve_import_path(source, "../utils/helpers").unwrap(),
360            "src/utils/helpers"
361        );
362
363        // Complex relative path
364        assert_eq!(
365            resolve_import_path(source, "./../lib/db").unwrap(),
366            "src/lib/db"
367        );
368    }
369
370    #[test]
371    fn test_resolve_import_path_package() {
372        let source = Path::new("src/app/main.js");
373
374        // Regular package
375        assert_eq!(resolve_import_path(source, "react").unwrap(), "react");
376
377        // Scoped package
378        assert_eq!(
379            resolve_import_path(source, "@types/node").unwrap(),
380            "@types/node"
381        );
382
383        // Package with subpath
384        assert_eq!(
385            resolve_import_path(source, "lodash/fp").unwrap(),
386            "lodash/fp"
387        );
388    }
389
390    #[test]
391    fn test_resolve_import_path_absolute() {
392        let source = Path::new("src/app/main.js");
393
394        // Absolute path
395        assert_eq!(
396            resolve_import_path(source, "/usr/lib/module").unwrap(),
397            "/usr/lib/module"
398        );
399    }
400
401    #[test]
402    fn test_normalize_path() {
403        // Remove . components
404        assert_eq!(
405            normalize_path(Path::new("src/./app/./main.js")).unwrap(),
406            "src/app/main.js"
407        );
408
409        // Resolve .. components
410        assert_eq!(
411            normalize_path(Path::new("src/app/../lib/db")).unwrap(),
412            "src/lib/db"
413        );
414
415        // Complex normalization
416        assert_eq!(
417            normalize_path(Path::new("a/b/c/../../d/./e")).unwrap(),
418            "a/d/e"
419        );
420    }
421
422    #[test]
423    fn test_normalize_path_invalid() {
424        // Too many .. components
425        assert!(normalize_path(Path::new("a/../..")).is_none());
426    }
427
428    #[test]
429    fn test_resolve_python_import_absolute() {
430        let source = Path::new("mypackage/module.py");
431
432        // Absolute imports (not relative)
433        assert_eq!(resolve_python_import(source, "os", false).unwrap(), "os");
434        assert_eq!(
435            resolve_python_import(source, "os.path", false).unwrap(),
436            "os.path"
437        );
438    }
439
440    #[test]
441    fn test_resolve_python_import_relative() {
442        let source = Path::new("mypackage/subpkg/module.py");
443
444        // from . import sibling
445        assert_eq!(
446            resolve_python_import(source, ".", true).unwrap(),
447            "mypackage/subpkg"
448        );
449
450        // from .sibling import foo
451        assert_eq!(
452            resolve_python_import(source, ".sibling", true).unwrap(),
453            "mypackage/subpkg/sibling"
454        );
455
456        // from .. import parent
457        assert_eq!(
458            resolve_python_import(source, "..", true).unwrap(),
459            "mypackage"
460        );
461
462        // from ..other_subpkg import utils
463        assert_eq!(
464            resolve_python_import(source, "..other_subpkg", true).unwrap(),
465            "mypackage/other_subpkg"
466        );
467    }
468
469    #[test]
470    fn test_resolve_python_import_nested_dots() {
471        let source = Path::new("pkg/sub1/sub2/module.py");
472
473        // from ...toplevel import X
474        assert_eq!(
475            resolve_python_import(source, "...toplevel", true).unwrap(),
476            "pkg/toplevel"
477        );
478    }
479
480    #[test]
481    fn test_different_path_separators() {
482        // Ensure we handle both forward and back slashes
483        let source_unix = Path::new("src/components/Button.js");
484
485        // Forward slash in import (most common)
486        assert_eq!(
487            resolve_import_path(source_unix, "./Icon").unwrap(),
488            "src/components/Icon"
489        );
490    }
491
492    #[test]
493    fn test_relative_import_collision_fix() {
494        // This test demonstrates the fix for HIGH finding #1
495
496        // Two different files importing "./utils"
497        let file1 = Path::new("src/foo/index.js");
498        let file2 = Path::new("src/bar/index.js");
499
500        let resolved1 = resolve_import_path(file1, "./utils").unwrap();
501        let resolved2 = resolve_import_path(file2, "./utils").unwrap();
502
503        // Before the fix, both would be "./utils"
504        // After the fix, they are different canonical paths
505        assert_eq!(resolved1, "src/foo/utils");
506        assert_eq!(resolved2, "src/bar/utils");
507        assert_ne!(resolved1, resolved2);
508    }
509}