components-rs 0.1.1

Static analysis tooling for Components.js dependency injection projects
Documentation
use std::collections::HashSet;
use std::path::{Path, PathBuf};

use crate::error::Result;
use crate::fs::Fs;

/// Build all ancestor paths from the main module path, to be used as
/// starting points for node_modules scanning.
/// Mirrors `ModuleStateBuilder.buildNodeModuleImportPaths`.
pub fn build_node_module_import_paths(main_module_path: &Path) -> Vec<PathBuf> {
    let mut paths = Vec::new();
    let mut current = main_module_path.to_path_buf();
    paths.push(current.clone());
    while current.pop() {
        paths.push(current.clone());
    }
    paths
}

/// Recursively discover all node module paths starting from the given import paths.
/// Mirrors `ModuleStateBuilder.buildNodeModulePaths`.
pub async fn build_node_module_paths(
    fs: &dyn Fs,
    import_paths: &[PathBuf],
) -> Result<Vec<PathBuf>> {
    let mut result = Vec::new();
    let mut visited = HashSet::new();
    for path in import_paths {
        build_node_module_paths_inner(fs, path, &mut result, &mut visited).await?;
    }
    Ok(result)
}

async fn build_node_module_paths_inner(
    fs: &dyn Fs,
    path: &Path,
    result: &mut Vec<PathBuf>,
    visited: &mut HashSet<PathBuf>,
) -> Result<()> {
    let canonical = match fs.canonicalize(path).await {
        Ok(p) => p,
        Err(_) => return Ok(()),
    };

    if visited.contains(&canonical) {
        return Ok(());
    }
    visited.insert(canonical.clone());

    let has_package_json = fs.is_file(&canonical.join("package.json")).await;
    let node_modules_dir = canonical.join("node_modules");
    let has_node_modules = fs.is_dir(&node_modules_dir).await;

    if has_package_json || has_node_modules {
        result.push(canonical.clone());

        if has_node_modules {
            if let Ok(entries) = fs.read_dir(&node_modules_dir).await {
                for entry in entries {
                    if entry.name.starts_with('.') {
                        continue;
                    }

                    if entry.name.starts_with('@') {
                        // Scoped packages: iterate one level deeper
                        if let Ok(scoped_entries) = fs.read_dir(&entry.path).await {
                            for scoped_entry in scoped_entries {
                                Box::pin(build_node_module_paths_inner(
                                    fs,
                                    &scoped_entry.path,
                                    result,
                                    visited,
                                ))
                                .await?;
                            }
                        }
                    } else {
                        Box::pin(build_node_module_paths_inner(
                            fs,
                            &entry.path,
                            result,
                            visited,
                        ))
                        .await?;
                    }
                }
            }
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_build_import_paths() {
        let paths = build_node_module_import_paths(Path::new("/a/b/c"));
        assert_eq!(
            paths,
            vec![
                PathBuf::from("/a/b/c"),
                PathBuf::from("/a/b"),
                PathBuf::from("/a"),
                PathBuf::from("/"),
            ]
        );
    }
}