Skip to main content

codanna/parsing/
paths.rs

1//! Path utilities for module path computation
2//!
3//! Provides OS-agnostic path normalization for computing module paths from file paths.
4//! All functions use `Path` APIs instead of string manipulation to handle
5//! different path separators across operating systems.
6
7use std::path::{Path, PathBuf};
8
9/// Normalize a file path for module path computation.
10///
11/// Ensures the path is in a consistent format for `module_path_from_file`:
12/// - If `file_path` is relative, prepends `workspace_root` to make it absolute
13/// - If `file_path` is already absolute, returns it unchanged
14///
15/// This ensures language behaviors always receive paths in a consistent
16/// coordinate system where `strip_prefix(workspace_root)` will work.
17pub fn normalize_for_module_path(file_path: &Path, workspace_root: &Path) -> PathBuf {
18    if file_path.is_relative() {
19        workspace_root.join(file_path)
20    } else {
21        file_path.to_path_buf()
22    }
23}
24
25/// Strip configured source roots from a path.
26///
27/// Attempts to strip each source root in order, returning the first match.
28/// Uses `Path::strip_prefix` for OS-agnostic handling.
29///
30/// # Arguments
31/// * `path` - The path to strip (should be relative to workspace root)
32/// * `source_roots` - List of source root directories to try (e.g., `["src", "lib", "app"]`)
33///
34/// # Returns
35/// The path with the source root stripped, or the original path if no match.
36pub fn strip_source_root<'a>(path: &'a Path, source_roots: &[&str]) -> &'a Path {
37    for root in source_roots {
38        if let Ok(stripped) = path.strip_prefix(root) {
39            return stripped;
40        }
41    }
42    path
43}
44
45/// Strip configured source roots from a path, returning owned PathBuf.
46///
47/// Same as `strip_source_root` but returns an owned `PathBuf`.
48pub fn strip_source_root_owned(path: &Path, source_roots: &[&str]) -> PathBuf {
49    strip_source_root(path, source_roots).to_path_buf()
50}
51
52/// Strip file extension from a path string.
53///
54/// Extensions from the registry do NOT include the dot (e.g., "rs", "py").
55/// Tries each extension in order and returns the first match.
56///
57/// # Arguments
58/// * `path_str` - The path string to strip extension from
59/// * `extensions` - List of extensions WITHOUT dots (e.g., `["rs"]`, `["py", "pyi"]`)
60///
61/// # Returns
62/// The path with extension stripped, or original if no match.
63pub fn strip_extension<'a>(path_str: &'a str, extensions: &[&str]) -> &'a str {
64    for ext in extensions {
65        // Build the suffix with dot (e.g., ".rs")
66        let suffix = format!(".{ext}");
67        if let Some(stripped) = path_str.strip_suffix(&suffix) {
68            return stripped;
69        }
70    }
71    path_str
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use tempfile::TempDir;
78
79    #[test]
80    fn test_normalize_relative_path() {
81        let file_path = Path::new("src/foo/bar.rs");
82        let temp_dir = TempDir::new().unwrap();
83        let workspace_root = temp_dir.path();
84
85        let result = normalize_for_module_path(file_path, workspace_root);
86
87        assert!(result.is_absolute());
88        assert!(result.ends_with("src/foo/bar.rs"));
89    }
90
91    #[test]
92    fn test_normalize_absolute_path() {
93        let temp_dir = TempDir::new().unwrap();
94        let workspace_root = temp_dir.path();
95        let file_path = workspace_root.join("src/foo/bar.rs");
96
97        let result = normalize_for_module_path(&file_path, workspace_root);
98
99        assert_eq!(result, file_path);
100    }
101
102    #[test]
103    fn test_strip_source_root_matches_first() {
104        let path = Path::new("src/foo/bar.rs");
105        let source_roots = &["src", "lib", "app"];
106
107        let result = strip_source_root(path, source_roots);
108
109        assert_eq!(result, Path::new("foo/bar.rs"));
110    }
111
112    #[test]
113    fn test_strip_source_root_matches_second() {
114        let path = Path::new("lib/utils/helper.rs");
115        let source_roots = &["src", "lib", "app"];
116
117        let result = strip_source_root(path, source_roots);
118
119        assert_eq!(result, Path::new("utils/helper.rs"));
120    }
121
122    #[test]
123    fn test_strip_source_root_no_match() {
124        let path = Path::new("tests/integration.rs");
125        let source_roots = &["src", "lib", "app"];
126
127        let result = strip_source_root(path, source_roots);
128
129        assert_eq!(result, path);
130    }
131
132    #[test]
133    fn test_strip_source_root_empty_roots() {
134        let path = Path::new("src/foo/bar.rs");
135        let source_roots: &[&str] = &[];
136
137        let result = strip_source_root(path, source_roots);
138
139        assert_eq!(result, path);
140    }
141
142    #[test]
143    fn test_strip_extension_simple() {
144        assert_eq!(strip_extension("foo.rs", &["rs"]), "foo");
145        assert_eq!(strip_extension("bar.py", &["py", "pyi"]), "bar");
146    }
147
148    #[test]
149    fn test_strip_extension_compound_typescript() {
150        // TypeScript declaration files - order matters, longer first
151        let ts_extensions = &["d.ts", "tsx", "ts", "mts", "cts"];
152        assert_eq!(strip_extension("types.d.ts", ts_extensions), "types");
153        assert_eq!(strip_extension("component.tsx", ts_extensions), "component");
154        assert_eq!(strip_extension("main.ts", ts_extensions), "main");
155    }
156
157    #[test]
158    fn test_strip_extension_compound_php() {
159        // PHP class files - order matters, longer first
160        let php_extensions = &["class.php", "inc.php", "php", "inc"];
161        assert_eq!(strip_extension("User.class.php", php_extensions), "User");
162        assert_eq!(strip_extension("config.inc.php", php_extensions), "config");
163        assert_eq!(strip_extension("index.php", php_extensions), "index");
164    }
165
166    #[test]
167    fn test_strip_extension_no_match() {
168        assert_eq!(strip_extension("README.md", &["rs", "py"]), "README.md");
169        assert_eq!(strip_extension("no_extension", &["rs"]), "no_extension");
170    }
171
172    #[test]
173    fn test_strip_extension_priority() {
174        // First matching extension wins
175        let extensions = &["ts", "d.ts"]; // Wrong order
176        assert_eq!(strip_extension("types.d.ts", extensions), "types.d");
177
178        // Correct order: longer extensions first
179        let extensions = &["d.ts", "ts"];
180        assert_eq!(strip_extension("types.d.ts", extensions), "types");
181    }
182}