scryer-engine 0.3.0

Tree-sitter AST indexing and reference resolution engine for Scryer code intelligence
use ignore::gitignore::{Gitignore, GitignoreBuilder};
use ignore::{Match, WalkBuilder};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

/// Source files larger than this are not indexed.
///
/// Generated and minified files (bundles, vendored blobs) can be many megabytes; reading,
/// parsing and resolving their references costs far more than they are worth, and the
/// initial index holds every changed file in memory at once.
pub const MAX_SOURCE_FILE_BYTES: u64 = 2 * 1024 * 1024;

/// Filesystem scanner traversing workspace trees honoring `.gitignore`, `.ignore`, and global git filters.
#[derive(Debug, Clone)]
pub struct WorkspaceScanner {
    root: PathBuf,
    supported_extensions: HashSet<String>,
}

impl WorkspaceScanner {
    /// Create a new scanner rooted at `root` with default Rust (`.rs`), Python (`.py`), and TypeScript (`.ts`, `.tsx`) extension support.
    pub fn new(root: impl Into<PathBuf>) -> Self {
        let mut supported_extensions = HashSet::new();
        supported_extensions.insert("rs".to_string());
        supported_extensions.insert("py".to_string());
        supported_extensions.insert("ts".to_string());
        supported_extensions.insert("tsx".to_string());
        Self {
            root: root.into(),
            supported_extensions,
        }
    }

    /// Customize the supported file extensions to search for (without leading dot).
    pub fn with_extensions(mut self, extensions: &[&str]) -> Self {
        self.supported_extensions = extensions
            .iter()
            .map(|s| s.trim_start_matches('.').to_string())
            .collect();
        self
    }

    /// Check whether a specific path matches the scanner's supported extensions.
    pub fn is_supported(&self, path: &Path) -> bool {
        path.extension()
            .and_then(|ext| ext.to_str())
            .map(|ext| self.supported_extensions.contains(ext))
            .unwrap_or(false)
    }

    /// Root directory being scanned.
    pub fn root(&self) -> &Path {
        &self.root
    }

    fn canonical_root(&self) -> PathBuf {
        dunce::canonicalize(&self.root).unwrap_or_else(|_| self.root.clone())
    }

    /// The walker shared by [`Self::scan`] and [`Self::scan_dirs`], so both honor the same
    /// ignore rules.
    fn walker(root: &Path) -> ignore::Walk {
        WalkBuilder::new(root)
            .standard_filters(true)
            .hidden(true)
            .parents(true)
            .git_global(true)
            .git_ignore(true)
            .git_exclude(true)
            .require_git(false)
            .build()
    }

    /// Absolute paths of every non-ignored directory under `root`, including `root` itself.
    pub fn scan_dirs(&self) -> Vec<PathBuf> {
        let mut dirs: Vec<PathBuf> = Self::walker(&self.canonical_root())
            .filter_map(Result::ok)
            .filter(|entry| entry.file_type().is_some_and(|ft| ft.is_dir()))
            .map(ignore::DirEntry::into_path)
            .collect();
        dirs.sort();
        dirs
    }

    /// Traverse the directory tree and collect all matching source files relative to `root`.
    pub fn scan(&self) -> Vec<PathBuf> {
        self.scan_with_skips().files
    }

    /// Like [`Self::scan`], also reporting the supported files left out for being too large.
    pub fn scan_with_skips(&self) -> ScanOutcome {
        let canonical_root = self.canonical_root();
        let mut outcome = ScanOutcome::default();

        for result in Self::walker(&canonical_root) {
            let entry = match result {
                Ok(e) => e,
                Err(err) => {
                    tracing::debug!("Ignore walker error: {err}");
                    continue;
                }
            };

            if entry.file_type().is_some_and(|ft| ft.is_file()) {
                let full_path = entry.path();
                if self.is_supported(full_path) {
                    let rel_path = match full_path.strip_prefix(&canonical_root) {
                        Ok(rel) => rel.to_path_buf(),
                        Err(_) => full_path.to_path_buf(),
                    };
                    if entry
                        .metadata()
                        .is_ok_and(|m| m.len() > MAX_SOURCE_FILE_BYTES)
                    {
                        tracing::warn!(
                            "Skipping {}: larger than {} bytes",
                            full_path.display(),
                            MAX_SOURCE_FILE_BYTES
                        );
                        outcome.too_large.push(rel_path);
                        continue;
                    }
                    outcome.files.push(rel_path);
                }
            }
        }

        outcome.files.sort();
        outcome.too_large.sort();
        outcome
    }
}

/// The result of walking a workspace.
#[derive(Debug, Default)]
pub struct ScanOutcome {
    /// Supported source files to index, sorted.
    pub files: Vec<PathBuf>,
    /// Supported source files over [`MAX_SOURCE_FILE_BYTES`], sorted.
    pub too_large: Vec<PathBuf>,
}

/// Ignore files read in each directory, in precedence order (the walker's order).
const DIR_IGNORE_FILES: [&str; 2] = [".ignore", ".gitignore"];

/// Applies the same ignore rules as [`WorkspaceScanner::scan`] to individual paths, for
/// filtering file watcher events.
///
/// Honors hidden files, `.ignore` and `.gitignore` in every directory from the root down,
/// `.git/info/exclude`, and the global git excludes file. Per-directory matchers are cached;
/// call [`IgnoreFilter::invalidate`] when an ignore file changes.
pub struct IgnoreFilter {
    root: PathBuf,
    exclude: Gitignore,
    global: Gitignore,
    dirs: HashMap<PathBuf, [Gitignore; 2]>,
}

impl IgnoreFilter {
    /// Build a filter for the workspace rooted at `root`.
    pub fn new(root: &Path) -> Self {
        let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
        let mut exclude = GitignoreBuilder::new(&root);
        exclude.add(root.join(".git").join("info").join("exclude"));
        Self {
            exclude: exclude.build().unwrap_or_else(|_| Gitignore::empty()),
            global: GitignoreBuilder::new(&root).build_global().0,
            dirs: HashMap::new(),
            root,
        }
    }

    /// Whether `path` (absolute) would be skipped by a full scan.
    ///
    /// Paths outside the root are always ignored.
    pub fn is_ignored(&mut self, path: &Path) -> bool {
        let Ok(rel) = path.strip_prefix(&self.root) else {
            return true;
        };
        let hidden = rel
            .components()
            .any(|c| c.as_os_str().to_str().is_some_and(|s| s.starts_with('.')));
        if hidden {
            return true;
        }

        let is_dir = path.is_dir();
        // Deepest directory first, as nested ignore files override their parents.
        let dirs: Vec<PathBuf> = path
            .ancestors()
            .skip(1)
            .take_while(|dir| dir.starts_with(&self.root))
            .map(Path::to_path_buf)
            .collect();
        for kind in 0..DIR_IGNORE_FILES.len() {
            for dir in &dirs {
                let matcher = &self.dir_matchers(dir)[kind];
                match matcher.matched_path_or_any_parents(path, is_dir) {
                    Match::None => {}
                    m => return m.is_ignore(),
                }
            }
        }
        for matcher in [&self.exclude, &self.global] {
            match matcher.matched_path_or_any_parents(path, is_dir) {
                Match::None => {}
                m => return m.is_ignore(),
            }
        }
        false
    }

    /// Drop cached rules if `path` is an ignore file, so edits to it take effect.
    ///
    /// Returns whether `path` was an ignore file.
    pub fn invalidate(&mut self, path: &Path) -> bool {
        let is_ignore_file = path
            .file_name()
            .and_then(|n| n.to_str())
            .is_some_and(|n| DIR_IGNORE_FILES.contains(&n));
        if is_ignore_file && let Some(dir) = path.parent() {
            self.dirs.remove(dir);
        }
        is_ignore_file
    }

    fn dir_matchers(&mut self, dir: &Path) -> &[Gitignore; 2] {
        self.dirs.entry(dir.to_path_buf()).or_insert_with(|| {
            DIR_IGNORE_FILES.map(|name| {
                let file = dir.join(name);
                if !file.is_file() {
                    return Gitignore::empty();
                }
                let mut builder = GitignoreBuilder::new(dir);
                builder.add(file);
                builder.build().unwrap_or_else(|_| Gitignore::empty())
            })
        })
    }
}

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

    #[test]
    fn ignore_filter_matches_scan() {
        let temp = tempfile::tempdir().unwrap();
        let root = dunce::canonicalize(temp.path()).unwrap();
        let write = |rel: &str, body: &str| {
            let path = root.join(rel);
            fs::create_dir_all(path.parent().unwrap()).unwrap();
            fs::write(path, body).unwrap();
        };
        write(".gitignore", "target/\n*.gen.rs\n");
        write("src/lib.rs", "");
        write("src/out.gen.rs", "");
        write("target/debug/build.rs", "");
        write(".hidden/x.rs", "");
        write("pkg/.gitignore", "local.rs\n!keep.gen.rs\n");
        write("pkg/local.rs", "");
        write("pkg/keep.gen.rs", "");
        write("pkg/main.rs", "");

        let scanned = WorkspaceScanner::new(&root).scan();
        let mut filter = IgnoreFilter::new(&root);
        for rel in [
            "src/lib.rs",
            "src/out.gen.rs",
            "target/debug/build.rs",
            ".hidden/x.rs",
            "pkg/local.rs",
            "pkg/keep.gen.rs",
            "pkg/main.rs",
        ] {
            assert_eq!(
                filter.is_ignored(&root.join(rel)),
                !scanned.contains(&PathBuf::from(rel)),
                "{rel} (scanned: {scanned:?})"
            );
        }

        // Editing an ignore file takes effect after invalidation.
        write("pkg/.gitignore", "main.rs\n");
        filter.invalidate(&root.join("pkg/.gitignore"));
        assert!(filter.is_ignored(&root.join("pkg/main.rs")));
        assert!(!filter.is_ignored(&root.join("pkg/local.rs")));
    }
}