use std::path::{Path, PathBuf};
use tracing::debug;
pub const ALWAYS_IGNORE_DIRS: &[&str] = &[
".git",
".hg",
".svn",
".bzr",
"_darcs",
".fossil",
".DS_Store",
".Spotlight-V100",
".Trashes",
"Thumbs.db",
"desktop.ini",
"$RECYCLE.BIN",
"__pycache__",
".pytest_cache",
".mypy_cache",
".pytype",
".pyre",
".hypothesis",
".tox",
".nox",
"cython_debug",
".eggs",
"node_modules",
".npm",
".yarn",
".pnpm-store",
".next",
".nuxt",
".output",
".svelte-kit",
".angular",
".parcel-cache",
".turbo",
".idea",
".vscode",
".vs",
".settings",
".gradle",
"_build",
".elixir_ls",
"Pods",
"DerivedData",
"xcuserdata",
".bundle",
".venv",
"venv",
".cache",
".sass-cache",
".eslintcache",
".stylelintcache",
".tmp",
".temp",
"tmp",
"temp",
"target",
];
#[derive(Debug, Clone)]
struct GitIgnorePattern {
pattern: String,
negated: bool,
dir_only: bool,
}
#[derive(Debug, Clone)]
struct GitIgnoreSpec {
base_dir: PathBuf,
patterns: Vec<GitIgnorePattern>,
}
pub struct GitIgnoreParser {
root_dir: PathBuf,
specs: Vec<GitIgnoreSpec>,
}
impl GitIgnoreParser {
pub fn new(root_dir: &Path) -> Self {
let root_dir = root_dir
.canonicalize()
.unwrap_or_else(|_| root_dir.to_path_buf());
let mut parser = Self {
root_dir,
specs: Vec::new(),
};
parser.load_gitignore_files();
parser
}
pub fn is_ignored(&self, path: &Path) -> bool {
let abs_path = if path.is_absolute() {
path.to_path_buf()
} else {
self.root_dir.join(path)
};
let rel = match abs_path.strip_prefix(&self.root_dir) {
Ok(r) => r,
Err(_) => return false,
};
for component in rel.components() {
let s = component.as_os_str().to_string_lossy();
if ALWAYS_IGNORE_DIRS.contains(&s.as_ref()) {
return true;
}
}
let mut ignored = false;
for spec in &self.specs {
let spec_rel = match abs_path.strip_prefix(&spec.base_dir) {
Ok(r) => r,
Err(_) => continue,
};
let match_str = spec_rel.to_string_lossy().replace('\\', "/");
let is_dir = abs_path.is_dir();
for pat in &spec.patterns {
if pat.dir_only && !is_dir {
continue;
}
if matches_pattern(&pat.pattern, &match_str) {
ignored = !pat.negated;
}
}
}
ignored
}
pub fn is_always_ignored(name: &str) -> bool {
ALWAYS_IGNORE_DIRS.contains(&name)
}
fn load_gitignore_files(&mut self) {
let root_gitignore = self.root_dir.join(".gitignore");
if root_gitignore.exists()
&& let Some(spec) = self.parse_gitignore(&root_gitignore, &self.root_dir.clone())
{
self.specs.push(spec);
}
self.walk_for_gitignores(&self.root_dir.clone());
}
fn walk_for_gitignores(&mut self, dir: &Path) {
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
if ALWAYS_IGNORE_DIRS.contains(&name.as_str()) {
continue;
}
let gitignore = path.join(".gitignore");
if gitignore.exists()
&& let Some(spec) = self.parse_gitignore(&gitignore, &path)
{
self.specs.push(spec);
}
self.walk_for_gitignores(&path);
}
}
fn parse_gitignore(&self, gitignore_path: &Path, base_dir: &Path) -> Option<GitIgnoreSpec> {
let content = std::fs::read_to_string(gitignore_path).ok()?;
let mut patterns = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let (pattern, negated) = if let Some(rest) = trimmed.strip_prefix('!') {
(rest.to_string(), true)
} else {
(trimmed.to_string(), false)
};
let dir_only = pattern.ends_with('/');
let pattern = if dir_only {
pattern.trim_end_matches('/').to_string()
} else {
pattern
};
patterns.push(GitIgnorePattern {
pattern,
negated,
dir_only,
});
}
if patterns.is_empty() {
debug!("No patterns in {}", gitignore_path.display());
return None;
}
Some(GitIgnoreSpec {
base_dir: base_dir.to_path_buf(),
patterns,
})
}
}
fn matches_pattern(pattern: &str, path: &str) -> bool {
let pattern = pattern.strip_prefix('/').unwrap_or(pattern);
if pattern.contains("**") {
let parts: Vec<&str> = pattern.split("**").collect();
if parts.len() == 2 {
let prefix = parts[0].trim_end_matches('/');
let suffix = parts[1].trim_start_matches('/');
if prefix.is_empty() && suffix.is_empty() {
return true;
}
if prefix.is_empty() {
return path.ends_with(suffix) || simple_match(suffix, path);
}
if suffix.is_empty() {
return path.starts_with(prefix) || simple_match(prefix, path);
}
return path.contains(prefix) && path.contains(suffix);
}
}
if !pattern.contains('/') {
let file_name = path.rsplit('/').next().unwrap_or(path);
return simple_match(pattern, file_name) || simple_match(pattern, path);
}
simple_match(pattern, path)
}
fn simple_match(pattern: &str, text: &str) -> bool {
let p: Vec<char> = pattern.chars().collect();
let t: Vec<char> = text.chars().collect();
simple_match_impl(&p, &t)
}
fn simple_match_impl(pattern: &[char], text: &[char]) -> bool {
if pattern.is_empty() {
return text.is_empty();
}
if pattern[0] == '*' {
let mut i = 0;
while i < pattern.len() && pattern[i] == '*' {
i += 1;
}
if i >= pattern.len() {
return true;
}
for j in 0..=text.len() {
if simple_match_impl(&pattern[i..], &text[j..]) {
return true;
}
}
return false;
}
if text.is_empty() {
return false;
}
if pattern[0] == '?' || pattern[0] == text[0] {
return simple_match_impl(&pattern[1..], &text[1..]);
}
false
}
impl std::fmt::Debug for GitIgnoreParser {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GitIgnoreParser")
.field("root_dir", &self.root_dir)
.field("specs_count", &self.specs.len())
.finish()
}
}
#[cfg(test)]
#[path = "gitignore_tests.rs"]
mod tests;