use ignore::gitignore::{Gitignore, GitignoreBuilder};
use ignore::{Match, WalkBuilder};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
pub const MAX_SOURCE_FILE_BYTES: u64 = 2 * 1024 * 1024;
#[derive(Debug, Clone)]
pub struct WorkspaceScanner {
root: PathBuf,
supported_extensions: HashSet<String>,
}
impl WorkspaceScanner {
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,
}
}
pub fn with_extensions(mut self, extensions: &[&str]) -> Self {
self.supported_extensions = extensions
.iter()
.map(|s| s.trim_start_matches('.').to_string())
.collect();
self
}
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)
}
pub fn root(&self) -> &Path {
&self.root
}
fn canonical_root(&self) -> PathBuf {
dunce::canonicalize(&self.root).unwrap_or_else(|_| self.root.clone())
}
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()
}
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
}
pub fn scan(&self) -> Vec<PathBuf> {
self.scan_with_skips().files
}
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
}
}
#[derive(Debug, Default)]
pub struct ScanOutcome {
pub files: Vec<PathBuf>,
pub too_large: Vec<PathBuf>,
}
const DIR_IGNORE_FILES: [&str; 2] = [".ignore", ".gitignore"];
pub struct IgnoreFilter {
root: PathBuf,
exclude: Gitignore,
global: Gitignore,
dirs: HashMap<PathBuf, [Gitignore; 2]>,
}
impl IgnoreFilter {
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,
}
}
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();
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
}
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:?})"
);
}
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")));
}
}