probe_code/path_resolver/
javascript.rs

1//! JavaScript/Node.js-specific path resolver implementation.
2
3use super::PathResolver;
4use std::path::{Path, PathBuf};
5use std::process::Command;
6
7/// A path resolver for JavaScript/Node.js packages.
8pub struct JavaScriptPathResolver;
9
10impl Default for JavaScriptPathResolver {
11    fn default() -> Self {
12        Self::new()
13    }
14}
15
16impl JavaScriptPathResolver {
17    /// Creates a new JavaScript path resolver.
18    pub fn new() -> Self {
19        JavaScriptPathResolver
20    }
21
22    /// Finds the nearest node_modules directory from the current directory upwards.
23    fn find_node_modules(&self) -> Result<PathBuf, String> {
24        // Start from the current directory
25        let mut current_dir =
26            std::env::current_dir().map_err(|e| format!("Failed to get current directory: {e}"))?;
27
28        // Look for node_modules in the current directory and its parents
29        loop {
30            let node_modules = current_dir.join("node_modules");
31            if node_modules.exists() && node_modules.is_dir() {
32                return Ok(node_modules);
33            }
34
35            // Go up one directory
36            if !current_dir.pop() {
37                // We've reached the root directory without finding node_modules
38                return Err("Could not find node_modules directory".to_string());
39            }
40        }
41    }
42
43    /// Resolves a package using npm's resolve functionality.
44    fn resolve_with_npm(&self, package_name: &str) -> Result<PathBuf, String> {
45        // Use npm to resolve the package
46        let output = Command::new("npm")
47            .args(["root", "-g"])
48            .output()
49            .map_err(|e| format!("Failed to execute 'npm root -g': {e}"))?;
50
51        if !output.status.success() {
52            return Err(format!(
53                "Error running 'npm root -g': {}",
54                String::from_utf8_lossy(&output.stderr)
55            ));
56        }
57
58        // Get the global node_modules path
59        let global_node_modules = String::from_utf8_lossy(&output.stdout).trim().to_string();
60        let global_package_path = Path::new(&global_node_modules).join(package_name);
61
62        if global_package_path.exists() {
63            return Ok(global_package_path);
64        }
65
66        // Try to find in local node_modules
67        if let Ok(node_modules) = self.find_node_modules() {
68            let local_package_path = node_modules.join(package_name);
69            if local_package_path.exists() {
70                return Ok(local_package_path);
71            }
72        }
73
74        // If we couldn't find it, try using require.resolve
75        let script = format!(
76            "try {{ console.log(require.resolve('{package_name}')) }} catch(e) {{ process.exit(1) }}"
77        );
78
79        let output = Command::new("node")
80            .args(["-e", &script])
81            .output()
82            .map_err(|e| format!("Failed to execute Node.js: {e}"))?;
83
84        if output.status.success() {
85            let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
86
87            // If the resolved path is a file, get its directory
88            let path = PathBuf::from(&path_str);
89            if path.is_file() {
90                if let Some(parent) = path.parent() {
91                    return Ok(parent.to_path_buf());
92                }
93            }
94
95            return Ok(path);
96        }
97
98        Err(format!(
99            "Could not resolve JavaScript package: {package_name}"
100        ))
101    }
102}
103
104impl PathResolver for JavaScriptPathResolver {
105    fn prefix(&self) -> &'static str {
106        "js:"
107    }
108
109    fn split_module_and_subpath(
110        &self,
111        full_path_after_prefix: &str,
112    ) -> Result<(String, Option<String>), String> {
113        if full_path_after_prefix.is_empty() {
114            return Err("JavaScript path cannot be empty".to_string());
115        }
116        if full_path_after_prefix.contains("..") {
117            return Err("JavaScript path cannot contain '..'".to_string());
118        }
119
120        // Trim potential trailing slash
121        let path = full_path_after_prefix.trim_end_matches('/');
122
123        if path.starts_with('@') {
124            // Scoped: @scope/package/maybe/subpath
125            let parts: Vec<&str> = path.splitn(3, '/').collect();
126            match parts.len() {
127                1 => Err(format!(
128                    "Invalid scoped package format (missing package name): {path}"
129                )), // e.g., "@scope"
130                2 => {
131                    // e.g., "@scope/package"
132                    let scope = parts[0];
133                    let pkg = parts[1];
134                    if scope.len() <= 1 || pkg.is_empty() || pkg.contains('/') {
135                        Err(format!("Invalid scoped package format: {path}"))
136                    } else {
137                        let module_name = format!("{scope}/{pkg}");
138                        Ok((module_name, None))
139                    }
140                }
141                3 => {
142                    // e.g., "@scope/package/subpath" or "@scope/package/"
143                    let scope = parts[0];
144                    let pkg = parts[1];
145                    let sub = parts[2];
146                    if scope.len() <= 1 || pkg.is_empty() || pkg.contains('/') {
147                        Err(format!("Invalid scoped package format: {path}"))
148                    } else {
149                        let module_name = format!("{scope}/{pkg}");
150                        // Handle trailing slash case "@scope/pkg/" -> subpath should be None
151                        let subpath_opt = if sub.is_empty() {
152                            None
153                        } else {
154                            Some(sub.to_string())
155                        };
156                        Ok((module_name, subpath_opt))
157                    }
158                }
159                _ => unreachable!("splitn(3) limits len to 3"),
160            }
161        } else {
162            // Regular: package/maybe/subpath
163            let mut parts = path.splitn(2, '/');
164            let module_name = parts.next().unwrap().to_string(); // Cannot fail on non-empty string
165            if module_name.is_empty() || module_name.starts_with('/') {
166                // Basic validation
167                Err(format!("Invalid package format: {path}"))
168            } else {
169                let subpath_opt = parts.next().filter(|s| !s.is_empty()).map(String::from); // Handle trailing slash "pkg/"
170                Ok((module_name, subpath_opt))
171            }
172        }
173    }
174
175    fn resolve(&self, module_name: &str) -> Result<PathBuf, String> {
176        // First, check if this is a path to a package.json file
177        let path = PathBuf::from(module_name);
178        if path.exists()
179            && path.is_file()
180            && path.file_name().is_some_and(|name| name == "package.json")
181        {
182            // If it's a package.json file, return its directory
183            return path.parent().map_or(
184                Err("Could not determine parent directory of package.json".to_string()),
185                |parent| Ok(parent.to_path_buf()),
186            );
187        }
188
189        // If it's a directory containing package.json, return the directory
190        let package_dir = PathBuf::from(module_name);
191        let package_json = package_dir.join("package.json");
192        if package_dir.exists() && package_dir.is_dir() && package_json.exists() {
193            return Ok(package_dir);
194        }
195
196        // Otherwise, try to resolve it as a package name
197        self.resolve_with_npm(module_name)
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use std::fs;
205
206    #[test]
207    fn test_js_path_resolver_with_directory() {
208        // Create a temporary directory with a package.json file
209        let temp_dir = tempfile::tempdir().unwrap();
210        let package_json_path = temp_dir.path().join("package.json");
211
212        // Write a minimal package.json
213        fs::write(
214            &package_json_path,
215            r#"{"name": "test-package", "version": "1.0.0"}"#,
216        )
217        .expect("Failed to write package.json");
218
219        let resolver = JavaScriptPathResolver::new();
220        let result = resolver.resolve(temp_dir.path().to_str().unwrap());
221
222        assert!(
223            result.is_ok(),
224            "Failed to resolve directory with package.json: {result:?}"
225        );
226        assert_eq!(result.unwrap(), temp_dir.path());
227    }
228
229    #[test]
230    fn test_js_path_resolver_with_package_json() {
231        // Create a temporary directory with a package.json file
232        let temp_dir = tempfile::tempdir().unwrap();
233        let package_json_path = temp_dir.path().join("package.json");
234
235        // Write a minimal package.json
236        fs::write(
237            &package_json_path,
238            r#"{"name": "test-package", "version": "1.0.0"}"#,
239        )
240        .expect("Failed to write package.json");
241
242        let resolver = JavaScriptPathResolver::new();
243        let result = resolver.resolve(package_json_path.to_str().unwrap());
244
245        assert!(result.is_ok(), "Failed to resolve package.json: {result:?}");
246        assert_eq!(result.unwrap(), temp_dir.path());
247    }
248
249    #[test]
250    fn test_js_path_resolver_npm_package() {
251        // Skip this test if npm is not installed
252        if Command::new("npm").arg("--version").output().is_err() {
253            println!("Skipping test_js_path_resolver_npm_package: npm is not installed");
254            return;
255        }
256
257        // This test is more complex as it requires npm to be installed
258        // We'll try to resolve a common package that might be installed globally
259        let resolver = JavaScriptPathResolver::new();
260
261        // Try to find the node_modules directory first
262        if resolver.find_node_modules().is_err() {
263            println!("Skipping test_js_path_resolver_npm_package: node_modules not found");
264            return;
265        }
266
267        // Try to resolve a common package like 'lodash' if it exists
268        let result = resolver.resolve("lodash");
269        if result.is_ok() {
270            let path = result.unwrap();
271            assert!(path.exists(), "Path does not exist: {path:?}");
272
273            // Check if it contains a package.json
274            let package_json = path.join("package.json");
275            assert!(
276                package_json.exists(),
277                "package.json not found: {package_json:?}"
278            );
279        } else {
280            println!("Skipping assertion for 'lodash': Package not found");
281        }
282    }
283}