Skip to main content

components_rs/discovery/
node_modules.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use crate::error::Result;
5use crate::fs::Fs;
6
7/// Build all ancestor paths from the main module path, to be used as
8/// starting points for node_modules scanning.
9/// Mirrors `ModuleStateBuilder.buildNodeModuleImportPaths`.
10pub fn build_node_module_import_paths(main_module_path: &Path) -> Vec<PathBuf> {
11    let mut paths = Vec::new();
12    let mut current = main_module_path.to_path_buf();
13    paths.push(current.clone());
14    while current.pop() {
15        paths.push(current.clone());
16    }
17    paths
18}
19
20/// Recursively discover all node module paths starting from the given import paths.
21/// Mirrors `ModuleStateBuilder.buildNodeModulePaths`.
22pub async fn build_node_module_paths(
23    fs: &dyn Fs,
24    import_paths: &[PathBuf],
25) -> Result<Vec<PathBuf>> {
26    let mut result = Vec::new();
27    let mut visited = HashSet::new();
28    for path in import_paths {
29        build_node_module_paths_inner(fs, path, &mut result, &mut visited).await?;
30    }
31    Ok(result)
32}
33
34async fn build_node_module_paths_inner(
35    fs: &dyn Fs,
36    path: &Path,
37    result: &mut Vec<PathBuf>,
38    visited: &mut HashSet<PathBuf>,
39) -> Result<()> {
40    let canonical = match fs.canonicalize(path).await {
41        Ok(p) => p,
42        Err(_) => return Ok(()),
43    };
44
45    if visited.contains(&canonical) {
46        return Ok(());
47    }
48    visited.insert(canonical.clone());
49
50    let has_package_json = fs.is_file(&canonical.join("package.json")).await;
51    let node_modules_dir = canonical.join("node_modules");
52    let has_node_modules = fs.is_dir(&node_modules_dir).await;
53
54    if has_package_json || has_node_modules {
55        result.push(canonical.clone());
56
57        if has_node_modules {
58            if let Ok(entries) = fs.read_dir(&node_modules_dir).await {
59                for entry in entries {
60                    if entry.name.starts_with('.') {
61                        continue;
62                    }
63
64                    if entry.name.starts_with('@') {
65                        // Scoped packages: iterate one level deeper
66                        if let Ok(scoped_entries) = fs.read_dir(&entry.path).await {
67                            for scoped_entry in scoped_entries {
68                                Box::pin(build_node_module_paths_inner(
69                                    fs,
70                                    &scoped_entry.path,
71                                    result,
72                                    visited,
73                                ))
74                                .await?;
75                            }
76                        }
77                    } else {
78                        Box::pin(build_node_module_paths_inner(
79                            fs,
80                            &entry.path,
81                            result,
82                            visited,
83                        ))
84                        .await?;
85                    }
86                }
87            }
88        }
89    }
90
91    Ok(())
92}
93
94#[cfg(test)]
95mod tests {
96    use super::*;
97
98    #[test]
99    fn test_build_import_paths() {
100        let paths = build_node_module_import_paths(Path::new("/a/b/c"));
101        assert_eq!(
102            paths,
103            vec![
104                PathBuf::from("/a/b/c"),
105                PathBuf::from("/a/b"),
106                PathBuf::from("/a"),
107                PathBuf::from("/"),
108            ]
109        );
110    }
111}