use regex::{Regex, RegexSet};
use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::LazyLock;
const OS_PATTERNS: &[&str] = &[
r"\.DS_Store",
r"Thumbs\.db",
r"desktop\.ini",
r"\.directory",
r"\.Spotlight-V100",
r"\.Trashes",
r"\.VolumeIcon\.icns",
r"\.AppleDouble",
r"\.LSOverride",
r"\.DocumentRevisions-V100",
r"\.fseventsd",
r"\.TemporaryItems",
r"\.com\.apple\.timemachine\.donotpresent",
r"\.AppleDB",
r"\.AppleDesktop",
r"Network Trash Folder",
r"Temporary Items",
r"\.apdisk",
r"ehthumbs\.db",
r"\._ ",
];
const IDE_PATTERNS: &[&str] = &[
r"\.vscode/",
r"\.idea/",
r"\.vs/",
r"\.sublime-",
r"\.atom/",
r"\.eclipse/",
r"\.metadata/",
r"\.settings/",
r"\.spyproject/",
r"\.nova/",
r"\.zed/",
r"\.brackets\.json",
r"\.emacs\.d/",
r"\.vim/",
r"nbproject/",
r"\.buildpath",
r"\.project",
r"\.classpath",
r"\.aptana/",
r"\.phpstorm\.meta\.php",
];
const TEMP_PATTERNS: &[&str] = &[
r"\.tmp$",
r"\.temp$",
r"\.swp$",
r"\.swo$",
r"~$",
r"\.bak$",
r"\.backup$",
r"\.orig$",
r"\.rej$",
r"\.cache$",
r"\.log$",
r"\.out$",
r"\.err$",
r"\.pid$",
r"\.lock$",
r"\.lockb$",
r"\.moved-aside",
r"\._ ",
];
const VCS_PATTERNS: &[&str] = &[r"\.git/", r"\.svn/", r"\.hg/", r"\.bzr/", r"\.gitkeep"];
const LANGUAGE_BUILD_SPECS: &[(&str, &[&str])] = &[
(
"nodejs",
&[
r"node_modules/",
r"dist/",
r"out/",
r"\.npm/",
r"\.yarn/",
r"\.pnpm-store/",
r"\.bun/",
r"\.next/",
r"\.nuxt/",
r"\.output/",
r"\.svelte-kit/",
r"\.astro/",
r"\.remix/",
r"\.vercel/",
r"\.netlify/",
r"\.firebase/",
r"\.parcel-cache/",
r"\.turbo/",
r"\.webpack/",
r"\.rollup\.cache/",
r"\.vite/",
r"\.swc/",
r"\.esbuild/",
r"\.nyc_output/",
r"\.eslintcache",
r"\.stylelintcache",
],
),
(
"python",
&[
r"__pycache__/",
r"\.pytest_cache/",
r"\.tox/",
r"\.nox/",
r"\.coverage",
r"htmlcov/",
r"\.mypy_cache/",
r"\.pytype/",
r"\.pyre/",
r"\.ruff_cache/",
r"\.ipynb_checkpoints/",
r"\.eggs/",
r"\.pip/",
r"\.venv/",
r"venv/",
r"env/",
r"\.env/",
r"virtualenv/",
],
),
("rust", &[r"target/", r"\.cargo/", r"\.rustup/"]),
(
"java",
&[
r"target/",
r"build/",
r"\.gradle/",
r"\.m2/",
r"\.mvn/",
r"\.sbt/",
r"\.ivy2/",
],
),
("go", &[r"vendor/", r"\.go/pkg/"]),
(
"cpp",
&[
r"\.ccache/",
r"\.sccache/",
r"build/",
r"cmake-build-",
r"CMakeFiles/",
],
),
(
"dotnet",
&[
r"bin/",
r"obj/",
r"packages/",
r"\.nuget/",
r"TestResults/",
r"publish/",
r"\.publish/",
],
),
(
"php",
&[
r"vendor/",
r"bootstrap/cache/",
r"storage/framework/",
r"storage/logs/",
r"var/cache/",
r"var/logs/",
r"tmp/cache/",
r"application/cache/",
],
),
(
"ruby",
&[
r"\.bundle/",
r"vendor/bundle/",
r"\.gem/",
r"log/",
r"tmp/",
r"coverage/",
r"\.yardoc/",
r"\.sass-cache/",
r"\.spring/",
],
),
(
"swift",
&[
r"\.build/",
r"\.swiftpm/",
r"DerivedData/",
r"Pods/",
r"Carthage/",
r"\.xcodeproj/",
r"\.xcworkspace/",
r"\.xcarchive/",
],
),
];
const PRUNE_DIRS_NON_BUILD: &[&str] = &[
".git",
".svn",
".hg",
".bzr",
".vscode",
".idea",
".vs",
".atom",
".eclipse",
".metadata",
".settings",
".spyproject",
".nova",
".zed",
".emacs.d",
".vim",
"nbproject",
".aptana",
];
fn prunable_dir_name(pattern: &str) -> Option<String> {
let name = pattern.strip_suffix('/')?;
if name.contains('/') {
return None;
}
let literal = name.replace(r"\.", ".");
(!literal.contains(|c: char| r"\[]()*+?{}|^$".contains(c))).then_some(literal)
}
const BINARY_EXTENSIONS: &[&str] = &[
"exe", "dll", "so", "dylib", "a", "lib", "o", "obj", "bin", "dat", "rlib", "pdb",
"zip", "tar", "gz", "bz2", "rar", "7z", "dmg", "iso", "jpg", "jpeg", "png", "gif", "bmp", "tiff", "ico", "svg", "webp", "heic", "heif",
"mp3", "mp4", "avi", "mov", "wmv", "flv", "wav", "flac", "m4a", "m4v", "aiff",
"ttf", "otf", "woff", "woff2", "eot", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "db", "sqlite", "sqlite3", "class", "jar", "war", "ear", "pyc", "pyo", "pyd", "apk", "ipa", "aab", "dex", "whl", "egg", "gem", "nupkg", "snupkg", "phar",
];
const GENERATED_SUFFIXES: &[&str] = &[
".min.js",
".min.mjs",
".min.css",
".bundle.js",
".bundle.css",
".js.map",
".css.map",
".pb.go",
".pb.gw.go",
".pb.cc",
".pb.h",
".pb.rs",
".pb.swift",
"_pb2.py",
"_pb2_grpc.py",
".pbobjc.h",
".pbobjc.m",
"_generated.go",
"_generated.rs",
".gen.go",
".gen.ts",
".gen.rs",
".g.dart",
".g.cs",
".freezed.dart",
".designer.cs",
".tab.c",
".tab.h",
".yy.c",
"_bindata.go",
];
const GENERATED_NAMES: &[&str] = &[
"package-lock.json",
"npm-shrinkwrap.json",
"pnpm-lock.yaml",
"bun.lockb",
"go.sum",
"flake.lock",
"bundle.js",
"bundle.css",
"vendor.js",
"polyfills.js",
];
fn compile_set(patterns: &[&str]) -> RegexSet {
RegexSet::new(patterns).expect("built-in patterns must compile")
}
static OS_SET: LazyLock<RegexSet> = LazyLock::new(|| compile_set(OS_PATTERNS));
static IDE_SET: LazyLock<RegexSet> = LazyLock::new(|| compile_set(IDE_PATTERNS));
static TEMP_SET: LazyLock<RegexSet> = LazyLock::new(|| compile_set(TEMP_PATTERNS));
static VCS_SET: LazyLock<RegexSet> = LazyLock::new(|| compile_set(VCS_PATTERNS));
static IGNORE_SET: LazyLock<RegexSet> = LazyLock::new(|| {
let all: Vec<&str> = OS_PATTERNS
.iter()
.chain(IDE_PATTERNS)
.chain(TEMP_PATTERNS)
.chain(VCS_PATTERNS)
.copied()
.collect();
compile_set(&all)
});
static BUILD_SET: LazyLock<RegexSet> = LazyLock::new(|| {
let all: Vec<&str> = LANGUAGE_BUILD_SPECS
.iter()
.flat_map(|(_, pats)| pats.iter())
.copied()
.collect();
compile_set(&all)
});
static LANGUAGE_BUILD_PATTERNS: LazyLock<HashMap<&'static str, Vec<Regex>>> = LazyLock::new(|| {
LANGUAGE_BUILD_SPECS
.iter()
.map(|(lang, pats)| {
let compiled = pats
.iter()
.map(|p| Regex::new(p).expect("built-in patterns must compile"))
.collect();
(*lang, compiled)
})
.collect()
});
static PRUNE_DIR_SET: LazyLock<HashSet<String>> = LazyLock::new(|| {
LANGUAGE_BUILD_SPECS
.iter()
.flat_map(|(_, patterns)| patterns.iter())
.filter_map(|pattern| prunable_dir_name(pattern))
.chain(PRUNE_DIRS_NON_BUILD.iter().map(|d| d.to_string()))
.collect()
});
static BINARY_EXTENSION_SET: LazyLock<HashSet<&'static str>> =
LazyLock::new(|| BINARY_EXTENSIONS.iter().copied().collect());
static GENERATED_NAME_SET: LazyLock<HashSet<&'static str>> =
LazyLock::new(|| GENERATED_NAMES.iter().copied().collect());
const BOILERPLATE_STEMS: &[&str] = &[
"license",
"licence",
"copying",
"copyright",
"notice",
"patents",
"authors",
"contributors",
];
static BOILERPLATE_STEM_SET: LazyLock<HashSet<&'static str>> =
LazyLock::new(|| BOILERPLATE_STEMS.iter().copied().collect());
#[derive(Debug, Default, Clone, Copy)]
pub struct CommonPatterns;
impl CommonPatterns {
pub fn new() -> Self {
Self
}
pub fn binary_extensions(&self) -> &'static [&'static str] {
BINARY_EXTENSIONS
}
pub fn generated_suffixes(&self) -> &'static [&'static str] {
GENERATED_SUFFIXES
}
pub fn matches_os_pattern(&self, path_str: &str) -> bool {
OS_SET.is_match(path_str)
}
pub fn matches_ide_pattern(&self, path_str: &str) -> bool {
IDE_SET.is_match(path_str)
}
pub fn matches_temp_pattern(&self, path_str: &str) -> bool {
TEMP_SET.is_match(path_str)
}
pub fn matches_vcs_pattern(&self, path_str: &str) -> bool {
VCS_SET.is_match(path_str)
}
pub fn is_binary_extension(&self, extension: &str) -> bool {
if extension.bytes().any(|b| b.is_ascii_uppercase()) {
BINARY_EXTENSION_SET.contains(extension.to_lowercase().as_str())
} else {
BINARY_EXTENSION_SET.contains(extension)
}
}
pub fn is_generated_file(&self, filename: &str) -> bool {
let lower = filename.to_lowercase();
if GENERATED_NAME_SET.contains(lower.as_str())
|| GENERATED_SUFFIXES
.iter()
.any(|suffix| lower.ends_with(suffix))
{
return true;
}
lower
.rsplit_once('.')
.and_then(|(stem, _)| stem.rsplit_once('.'))
.is_some_and(|(_, marker)| marker == "generated")
}
pub fn is_boilerplate_file(filename: &str) -> bool {
let lower = filename.to_lowercase();
let (stem, extension) = match lower.rsplit_once('.') {
Some((stem, extension)) => (stem, Some(extension)),
None => (lower.as_str(), None),
};
matches!(extension, None | Some("md" | "txt" | "rst" | "markdown"))
&& BOILERPLATE_STEM_SET.contains(stem)
}
pub fn should_ignore(&self, path_str: &str) -> bool {
IGNORE_SET.is_match(path_str)
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct LanguageBuildPatterns;
impl LanguageBuildPatterns {
pub fn new() -> Self {
Self
}
pub fn matches_build_pattern(&self, path_str: &str) -> bool {
BUILD_SET.is_match(path_str)
}
pub fn get_language_patterns(&self, language: &str) -> Option<&'static Vec<Regex>> {
LANGUAGE_BUILD_PATTERNS.get(language)
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct PatternMatcher {
common: CommonPatterns,
language_build: LanguageBuildPatterns,
}
fn to_pattern_separators(path: std::borrow::Cow<'_, str>) -> std::borrow::Cow<'_, str> {
if cfg!(windows) && path.contains('\\') {
std::borrow::Cow::Owned(path.replace('\\', "/"))
} else {
path
}
}
impl PatternMatcher {
pub fn new() -> Self {
Self {
common: CommonPatterns,
language_build: LanguageBuildPatterns,
}
}
pub fn relative_path<'a>(
&self,
path: &'a Path,
root: Option<&Path>,
) -> std::borrow::Cow<'a, str> {
let rendered = match root {
Some(root) => match path.strip_prefix(root) {
Ok(rel) => rel.to_string_lossy(),
Err(_) => path
.file_name()
.map(|n| n.to_string_lossy())
.unwrap_or_else(|| path.to_string_lossy()),
},
None => path.to_string_lossy(),
};
to_pattern_separators(rendered)
}
pub fn is_prunable_dir(&self, dir_name: &str) -> bool {
PRUNE_DIR_SET.contains(dir_name)
}
pub fn prunable_dirs(&self) -> impl Iterator<Item = &'static str> {
PRUNE_DIR_SET.iter().map(String::as_str)
}
pub fn should_ignore_file(&self, path_str: &str) -> bool {
self.common.should_ignore(path_str)
}
pub fn is_binary_file(&self, extension: &str) -> bool {
self.common.is_binary_extension(extension)
}
pub fn is_generated_file(&self, filename: &str) -> bool {
self.common.is_generated_file(filename)
}
pub fn is_boilerplate_file(filename: &str) -> bool {
CommonPatterns::is_boilerplate_file(filename)
}
pub fn matches_build_cache_pattern(&self, path_str: &str) -> bool {
self.language_build.matches_build_pattern(path_str)
}
pub fn is_excluded_path(&self, relative_path: &str) -> bool {
self.should_ignore_file(relative_path) || self.matches_build_cache_pattern(relative_path)
}
pub fn common_patterns(&self) -> &CommonPatterns {
&self.common
}
pub fn language_patterns(&self) -> &LanguageBuildPatterns {
&self.language_build
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn every_builtin_pattern_compiles() {
assert!(!OS_SET.is_empty());
assert!(!IDE_SET.is_empty());
assert!(!TEMP_SET.is_empty());
assert!(!VCS_SET.is_empty());
assert_eq!(
IGNORE_SET.len(),
OS_PATTERNS.len() + IDE_PATTERNS.len() + TEMP_PATTERNS.len() + VCS_PATTERNS.len()
);
let build_total: usize = LANGUAGE_BUILD_SPECS.iter().map(|(_, p)| p.len()).sum();
assert_eq!(BUILD_SET.len(), build_total);
}
#[test]
fn combined_ignore_set_agrees_with_categories() {
let m = PatternMatcher::new();
let probes = [
".DS_Store",
"src/.DS_Store",
".vscode/settings.json",
"a/.idea/workspace.xml",
"notes.tmp",
"editor.swp",
"backup~",
".git/config",
".gitignore",
"src/main.rs",
"lib/util.py",
];
for p in probes {
let per_category = m.common.matches_os_pattern(p)
|| m.common.matches_ide_pattern(p)
|| m.common.matches_temp_pattern(p)
|| m.common.matches_vcs_pattern(p);
assert_eq!(
m.should_ignore_file(p),
per_category,
"combined set disagreed with per-category union for {p:?}"
);
}
}
#[test]
fn pruning_never_drops_files_the_patterns_would_keep() {
let m = PatternMatcher::new();
for dir in m.prunable_dirs() {
let probe = format!("{dir}/some/file.rs");
assert!(
m.is_excluded_path(&probe),
"{dir:?} is pruned during traversal but {probe:?} is not excluded by any pattern; \
pruning and filtering would disagree"
);
}
}
#[test]
fn every_single_segment_build_pattern_is_pruned() {
let m = PatternMatcher::new();
for (language, patterns) in LANGUAGE_BUILD_SPECS {
for pattern in *patterns {
let Some(name) = prunable_dir_name(pattern) else {
continue;
};
assert!(
m.is_prunable_dir(&name),
"{language} excludes {pattern:?} by pattern but {name:?} is not pruned, so \
the whole tree is walked only to be discarded"
);
}
}
}
#[test]
fn prunable_dir_name_only_accepts_literal_single_segments() {
assert_eq!(
prunable_dir_name("node_modules/").as_deref(),
Some("node_modules")
);
assert_eq!(prunable_dir_name(r"\.venv/").as_deref(), Some(".venv"));
assert_eq!(
prunable_dir_name(r"\.rollup\.cache/").as_deref(),
Some(".rollup.cache")
);
assert_eq!(prunable_dir_name("vendor/bundle/"), None);
assert_eq!(prunable_dir_name("cmake-build-"), None);
assert_eq!(prunable_dir_name(r"\.coverage"), None);
}
#[test]
fn common_build_output_directories_are_excluded() {
let m = PatternMatcher::new();
for probe in [
"dist/bundle.js",
"out/index.html",
"node_modules/left-pad/index.js",
"target/debug/deps/x.rs",
"build/classes/A.class",
] {
assert!(m.is_excluded_path(probe), "{probe:?} should be excluded");
}
assert!(!m.is_excluded_path("src/distance.rs"));
assert!(!m.is_excluded_path("src/outbox/handler.rs"));
}
#[test]
fn build_patterns_match_relative_locations() {
let m = PatternMatcher::new();
assert!(m.matches_build_cache_pattern("node_modules/left-pad/index.js"));
assert!(m.matches_build_cache_pattern("target/debug/build.rs"));
assert!(m.matches_build_cache_pattern("__pycache__/mod.pyc"));
assert!(!m.matches_build_cache_pattern("src/main.rs"));
}
#[test]
fn absolute_ancestors_never_exclude_a_project() {
let m = PatternMatcher::new();
let hostile_roots = [
"/tmp/checkout",
"/build/ci/workspace",
"/home/dev/env/projects",
"/var/log/app",
"/Users/dev/bin/scratch",
"/srv/vendor/site",
"/opt/target/repo",
"/data/coverage/repo",
"/mnt/dist/repo",
"/home/obj/repo",
];
for root in hostile_roots {
let root = PathBuf::from(root);
let file = root.join("src/main.rs");
let rel = m.relative_path(&file, Some(&root));
assert_eq!(rel, "src/main.rs");
assert!(
!m.is_excluded_path(&rel),
"a project rooted at {root:?} was excluded by its own ancestor directories"
);
}
}
#[test]
fn absolute_paths_would_be_excluded_without_relativization() {
let m = PatternMatcher::new();
assert!(m.is_excluded_path("/tmp/checkout/src/main.rs"));
assert!(m.is_excluded_path("/build/ci/workspace/src/main.rs"));
}
#[test]
fn relative_path_falls_back_to_file_name_outside_root() {
let m = PatternMatcher::new();
let root = PathBuf::from("/tmp/project");
let outside = PathBuf::from("/elsewhere/build/main.rs");
assert_eq!(m.relative_path(&outside, Some(&root)), "main.rs");
}
#[test]
fn binary_extensions_are_case_insensitive() {
let m = PatternMatcher::new();
for ext in ["png", "PNG", "PnG", "exe", "EXE"] {
assert!(m.is_binary_file(ext), "{ext} should be binary");
}
for ext in ["rs", "py", "RS"] {
assert!(!m.is_binary_file(ext), "{ext} should not be binary");
}
}
#[test]
fn matcher_is_cheap_to_clone_and_send() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<PatternMatcher>();
assert_eq!(std::mem::size_of::<PatternMatcher>(), 0);
}
}