use std::{
collections::{HashMap, hash_map::Entry},
ffi::OsStr,
path::{Path, PathBuf},
sync::{Arc, Mutex, RwLock},
};
use crate::evaluator::{self, File, types::Result, utils};
#[derive(Debug, Default)]
pub struct Evaluator {
git_roots: RwLock<Vec<PathBuf>>,
files: Mutex<HashMap<PathBuf, Arc<File>>>,
}
impl Evaluator {
#[must_use]
pub fn is_ignored(&self, path: impl AsRef<Path>) -> bool {
let git_root = match self.evaluate_gitignore_files(path.as_ref()) {
(_, Some(is_ignored)) => {
log::debug!(
"{} is ignored by .gitignore: {is_ignored}",
path.as_ref().display()
);
return is_ignored;
}
(git_root, None) => git_root,
};
if let Some(git_root) = git_root {
if let Some(is_ignored) = self.evaluate_git_exclude_file(git_root, path) {
return is_ignored;
}
}
false
}
fn evaluate_gitignore_files(&self, path: impl AsRef<Path>) -> (Option<PathBuf>, Option<bool>) {
let mut closest_git_root = self.get_closest_already_encountered_git_root(&path);
let path_parts = path.as_ref().iter().collect::<Vec<&OsStr>>();
let closest_git_root_offset = closest_git_root
.as_ref()
.map_or(1, |root| root.components().count());
let mut is_in_git_root = closest_git_root.is_some();
let mut is_ignored = None;
for i in closest_git_root_offset..path_parts.len() {
let base_path: PathBuf = path_parts[0..i].iter().collect();
if closest_git_root
.as_ref()
.is_some_and(|git_root| git_root == &base_path)
{
} else if base_path.join(".git").exists() {
if is_in_git_root {
is_ignored = None;
log::debug!(
"Encountered recursive git root at: {}",
base_path.as_path().display()
);
} else {
is_in_git_root = true;
log::debug!("Encountered git root at: {}", base_path.as_path().display());
}
match self.git_roots.write() {
Ok(mut guard) => {
guard.push(base_path.clone());
}
Err(e) => {
log::error!(
"Unable to update git roots with newly encountered git root ({}): {}",
base_path.display(),
e
);
}
}
closest_git_root = Some(base_path.clone());
} else {
continue;
}
let potential_gitignore = base_path.join(".gitignore");
let gitignore_file = match self.get_or_parse_gitignore(potential_gitignore.as_path()) {
Ok(Some(gitignore_file)) => gitignore_file,
Ok(None) => continue,
Err(e) => {
log::error!(
"Failed to read .gitignore file at {}: {:?}",
potential_gitignore.display(),
e
);
continue;
}
};
let parent_path = path_parts[0..=i].iter().collect::<PathBuf>().join("");
if gitignore_file
.is_ignored(parent_path.as_path())
.is_some_and(|ignored| ignored)
{
log::debug!(
"{} is ignored so {} is ignored by association.",
parent_path.as_path().display(),
path.as_ref().display()
);
return (closest_git_root, Some(true));
}
if let Some(result) = gitignore_file.is_ignored(path.as_ref()) {
is_ignored = Some(result);
}
}
(closest_git_root, is_ignored)
}
fn evaluate_git_exclude_file(
&self,
git_root: impl AsRef<Path>,
path: impl AsRef<Path>,
) -> Option<bool> {
let exclude_file = git_root.as_ref().join(".git").join("info").join("exclude");
let gitignore_file = match self.get_or_parse_gitignore(&exclude_file) {
Ok(file) => file,
Err(e) => {
log::error!(
"Failed to read .gitignore file at {}: {:?}",
exclude_file.display(),
e
);
None
}
};
if let Some(gitignore_file) = gitignore_file {
if let Some(is_ignored) = gitignore_file.is_ignored(&path) {
log::debug!(
"{} is ignored by {}: {is_ignored}",
exclude_file.as_path().display(),
path.as_ref().display()
);
return Some(is_ignored);
}
}
None
}
fn get_closest_already_encountered_git_root(&self, path: impl AsRef<Path>) -> Option<PathBuf> {
if let Ok(already_encountered_git_roots) = self.git_roots.read() {
return already_encountered_git_roots
.iter()
.fold(None, |previous_match, git_root| {
if !path.as_ref().starts_with(git_root) {
return previous_match;
}
if previous_match
.is_none_or(|previous_match| path.as_ref().starts_with(previous_match))
{
return Some(git_root);
}
previous_match
})
.map(std::borrow::ToOwned::to_owned);
}
None
}
fn get_or_parse_gitignore(
&self,
potential_gitignore: impl AsRef<Path>,
) -> Result<Option<Arc<File>>> {
if !potential_gitignore.as_ref().exists() {
return Ok(None);
}
let mut guard = self.files.lock().map_err(|_| {
evaluator::Error::CachePoisoned(potential_gitignore.as_ref().to_path_buf())
})?;
let gitignore_file = match guard.entry(potential_gitignore.as_ref().to_path_buf()) {
Entry::Occupied(mut e) => {
{
let existing_file = e.get_mut();
let (target_checksum, _) = crate::utils::compute_checksum(
potential_gitignore.as_ref(),
)
.map_err(|e| evaluator::Error::FileError {
file: potential_gitignore.as_ref().to_path_buf(),
source: e,
})?;
if existing_file.checksum == target_checksum {
return Ok(Some(Arc::clone(existing_file)));
}
}
Arc::clone(&e.insert(Arc::new(utils::read_gitignore(
potential_gitignore.as_ref(),
)?)))
}
Entry::Vacant(e) => {
let gitignore_file = Arc::new(utils::read_gitignore(potential_gitignore.as_ref())?);
Arc::clone(e.insert(gitignore_file))
}
};
drop(guard);
Ok(Some(gitignore_file))
}
}