react-native-directory-cli 0.1.0

React Native Directory CLI — query 2400+ RN packages from the terminal
use anyhow::Result;
use regex::Regex;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use walkdir::{DirEntry, WalkDir};

#[derive(Debug, Clone)]
pub struct Location {
    pub file: PathBuf,
    pub line: usize,
}

#[derive(Debug, Default)]
pub struct ScanResult {
    pub files_scanned: usize,
    pub imports_by_package: HashMap<String, Vec<Location>>,
}

const IGNORED_DIRS: &[&str] = &[
    "node_modules",
    ".git",
    ".hg",
    ".svn",
    "dist",
    "build",
    "Pods",
    ".gradle",
    ".expo",
    ".next",
    ".turbo",
    "coverage",
    "target",
    ".cache",
    ".idea",
    ".vscode",
];

const SOURCE_EXTENSIONS: &[&str] = &["ts", "tsx", "js", "jsx", "mjs", "cjs"];

fn is_hidden(entry: &DirEntry) -> bool {
    entry
        .file_name()
        .to_str()
        .map(|s| s.starts_with('.') && s != "." && s != "..")
        .unwrap_or(false)
        && entry.depth() > 0
        && entry.file_type().is_dir()
}

fn is_ignored_dir(entry: &DirEntry) -> bool {
    if !entry.file_type().is_dir() {
        return false;
    }
    let name = match entry.file_name().to_str() {
        Some(n) => n,
        None => return false,
    };
    IGNORED_DIRS.contains(&name) || is_hidden(entry)
}

fn is_source_file(path: &Path) -> bool {
    path.extension()
        .and_then(|e| e.to_str())
        .map(|e| SOURCE_EXTENSIONS.contains(&e))
        .unwrap_or(false)
}

fn import_regex() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(r#"(?m)(?:from\s+|require\s*\(\s*|import\s+)['"]([^'"./][^'"]*)['"]"#)
            .expect("valid regex")
    })
}

pub fn extract_package_name(import: &str) -> Option<String> {
    if import.is_empty() || import.starts_with('.') || import.starts_with('/') {
        return None;
    }
    if import.starts_with('@') {
        let mut iter = import.splitn(3, '/');
        let scope = iter.next()?;
        let name = iter.next()?;
        if scope.len() < 2 || name.is_empty() {
            return None;
        }
        Some(format!("{scope}/{name}"))
    } else {
        Some(import.split('/').next()?.to_string())
    }
}

fn scan_file(path: &Path, result: &mut ScanResult) -> Result<()> {
    let content = match fs::read_to_string(path) {
        Ok(c) => c,
        Err(_) => return Ok(()),
    };
    result.files_scanned += 1;

    let re = import_regex();
    for (line_idx, line) in content.lines().enumerate() {
        for cap in re.captures_iter(line) {
            if let Some(m) = cap.get(1) {
                if let Some(pkg) = extract_package_name(m.as_str()) {
                    result
                        .imports_by_package
                        .entry(pkg)
                        .or_default()
                        .push(Location {
                            file: path.to_path_buf(),
                            line: line_idx + 1,
                        });
                }
            }
        }
    }
    Ok(())
}

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

    #[test]
    fn bare_package() {
        assert_eq!(extract_package_name("lodash"), Some("lodash".into()));
    }

    #[test]
    fn scoped_package() {
        assert_eq!(
            extract_package_name("@react-native-async-storage/async-storage"),
            Some("@react-native-async-storage/async-storage".into())
        );
    }

    #[test]
    fn scoped_with_subpath() {
        assert_eq!(
            extract_package_name("@legendapp/list/reanimated"),
            Some("@legendapp/list".into())
        );
    }

    #[test]
    fn bare_with_subpath() {
        assert_eq!(
            extract_package_name("react-native/Libraries/Foo"),
            Some("react-native".into())
        );
    }

    #[test]
    fn relative_import_skipped() {
        assert_eq!(extract_package_name("./foo"), None);
        assert_eq!(extract_package_name("../../bar"), None);
        assert_eq!(extract_package_name("/abs/path"), None);
    }

    #[test]
    fn empty_string_skipped() {
        assert_eq!(extract_package_name(""), None);
    }

    #[test]
    fn import_regex_captures_various_shapes() {
        let re = import_regex();
        let samples = [
            (r#"import X from "foo""#, Some("foo")),
            (r#"import X from 'foo'"#, Some("foo")),
            (r#"import 'side-effect'"#, Some("side-effect")),
            (r#"const x = require("foo")"#, Some("foo")),
            (r#"const x = require ( 'foo' )"#, Some("foo")),
        ];
        for (src, expected) in samples {
            let cap = re.captures(src).and_then(|c| c.get(1)).map(|m| m.as_str());
            assert_eq!(cap, expected, "input: {src}");
        }
    }
}

pub fn scan_project(root: &Path) -> Result<ScanResult> {
    let mut result = ScanResult::default();

    for entry in WalkDir::new(root)
        .follow_links(false)
        .into_iter()
        .filter_entry(|e| !is_ignored_dir(e))
    {
        let entry = match entry {
            Ok(e) => e,
            Err(_) => continue,
        };
        if entry.file_type().is_file() && is_source_file(entry.path()) {
            let _ = scan_file(entry.path(), &mut result);
        }
    }

    Ok(result)
}