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}