diff --git a/lib/src/error.rs b/lib/src/error.rs index 88b7d7c..e583640 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -219,6 +219,31 @@ pub enum RuntimeError { #[diagnostic(code(watchexec::runtime::clearscreen))] Clearscreen(#[from] clearscreen::Error), + /// Error received when parsing a glob from an [`IgnoreFile`](crate::ignore::files::IgnoreFile) fails. + #[error("cannot parse glob from ignore '{file}': {err}")] + #[diagnostic(code(watchexec::runtime::ignore_file_glob))] + IgnoreFileGlob { + /// The path to the erroring ignore file. + file: PathBuf, + + /// The underlying error. + #[source] + err: ignore::Error, + // TODO: extract glob error into diagnostic + }, + + /// Error received when an [`IgnoreFile`](crate::ignore::files::IgnoreFile) cannot be read. + #[error("cannot read ignore '{file}': {err}")] + #[diagnostic(code(watchexec::runtime::ignore_file_read))] + IgnoreFileRead { + /// The path to the erroring ignore file. + file: PathBuf, + + /// The underlying error. + #[source] + err: std::io::Error, + }, + /// Error emitted by a [`Filterer`](crate::filter::Filterer). /// /// With built-in filterers this will probably be a dynbox of diff --git a/lib/src/ignore.rs b/lib/src/ignore.rs index 9e853c9..e17214f 100644 --- a/lib/src/ignore.rs +++ b/lib/src/ignore.rs @@ -1,3 +1,251 @@ //! Ignore files: find them, parse them, interpret them. +use std::path::{Path, PathBuf}; + +use futures::stream::{FuturesUnordered, StreamExt}; +use ignore::{ + gitignore::{Gitignore, GitignoreBuilder}, + Match, +}; +use tokio::fs::read_to_string; +use tracing::{trace, trace_span}; + +use crate::{ + error::RuntimeError, + event::{Event, FileType}, + filter::Filterer, +}; + pub mod files; + +/// A path-only filterer dedicated to ignore files. +/// +/// This reads and compiles ignore files, and should be used for handling ignore files. It's created +/// with a project origin and a list of ignore files, and new ignore files can be added later +/// (unless [`finish`](Filter::finish()) is called). +/// +/// It implements [`Filterer`] so it can be used directly in another filterer; it is not designed to +/// be used as a standalone filterer. +#[derive(Debug)] +pub struct Filter { + origin: PathBuf, + builder: Option, + compiled: Gitignore, +} + +impl Filter { + /// Read ignore files from disk and load them for filtering. + pub async fn new( + origin: impl AsRef, + files: &[files::IgnoreFile], + ) -> Result { + let (files_contents, errors): (Vec<_>, Vec<_>) = files + .iter() + .map(|file| async move { + trace!(?file, "loading ignore file"); + let content = read_to_string(&file.path).await.map_err(|err| { + RuntimeError::IgnoreFileRead { + file: file.path.clone(), + err, + } + })?; + Ok((file.clone(), content)) + }) + .collect::>() + .collect::>() + .await + .into_iter() + .map(|res| match res { + Ok(o) => (Some(o), None), + Err(e) => (None, Some(e)), + }) + .unzip(); + + let errors: Vec = errors.into_iter().flatten().collect(); + if !errors.is_empty() { + return Err(RuntimeError::Set(errors)); + } + + let origin = origin.as_ref(); + let mut builder = GitignoreBuilder::new(origin); + for (file, content) in files_contents.into_iter().flatten() { + let _span = trace_span!("loading ignore file", ?file).entered(); + for line in content.lines() { + if line.is_empty() || line.starts_with('#') { + continue; + } + + trace!(?line, "adding ignore line"); + builder + .add_line(file.applies_in.clone(), line) + .map_err(|err| RuntimeError::IgnoreFileGlob { + file: file.path.clone(), + err, + })?; + } + } + + trace!("compiling globset"); + let compiled = builder + .build() + .map_err(|err| RuntimeError::IgnoreFileGlob { + file: "set of ignores".into(), + err, + })?; + + trace!( + files=%files.len(), + ignores=%compiled.num_ignores(), + allows=%compiled.num_whitelists(), + "ignore files loaded and compiled", + ); + + Ok(Self { + origin: origin.to_owned(), + builder: Some(builder), + compiled, + }) + } + + /// Deletes the internal builder, to save memory. + /// + /// This makes it impossible to add new ignore files without re-compiling the whole set. + pub fn finish(&mut self) { + self.builder = None; + } + + /// Reads and adds an ignore file, if the builder is available. + /// + /// Does nothing silently otherwise. + pub async fn add_file(&mut self, file: &files::IgnoreFile) -> Result<(), RuntimeError> { + if let Some(ref mut builder) = self.builder { + trace!(?file, "reading ignore file"); + let content = + read_to_string(&file.path) + .await + .map_err(|err| RuntimeError::IgnoreFileRead { + file: file.path.clone(), + err, + })?; + + let _span = trace_span!("loading ignore file", ?file).entered(); + for line in content.lines() { + if line.is_empty() || line.starts_with('#') { + continue; + } + + trace!(?line, "adding ignore line"); + builder + .add_line(file.applies_in.clone(), line) + .map_err(|err| RuntimeError::IgnoreFileGlob { + file: file.path.clone(), + err, + })?; + } + + let pre_ignores = self.compiled.num_ignores(); + let pre_allows = self.compiled.num_whitelists(); + + trace!("recompiling globset"); + let recompiled = builder + .build() + .map_err(|err| RuntimeError::IgnoreFileGlob { + file: file.path.clone(), + err, + })?; + + trace!( + new_ignores=%(recompiled.num_ignores() - pre_ignores), + new_allows=%(recompiled.num_whitelists() - pre_allows), + "ignore file loaded and set recompiled", + ); + self.compiled = recompiled; + } + + Ok(()) + } + + /// Check a particular folder path against the ignore set. + /// + /// Returns `false` if the folder should be ignored. + /// + /// Note that this is a slightly different implementation than the [`Filterer`] trait, as the + /// latter handles events with multiple associated paths. + pub fn check_dir(&self, path: &Path) -> bool { + let _span = trace_span!("check_dir", ?path).entered(); + + trace!("checking against compiled ignore files"); + match if path.strip_prefix(&self.origin).is_ok() { + trace!("checking against path or parents"); + self.compiled.matched_path_or_any_parents(path, true) + } else { + trace!("checking against path only"); + self.compiled.matched(path, true) + } { + Match::None => { + trace!("no match (pass)"); + true + } + Match::Ignore(glob) => { + if glob.from().map_or(true, |f| path.strip_prefix(f).is_ok()) { + trace!(?glob, "positive match (fail)"); + false + } else { + trace!(?glob, "positive match, but not in scope (pass)"); + true + } + } + Match::Whitelist(glob) => { + trace!(?glob, "negative match (pass)"); + true + } + } + } +} + +impl Filterer for Filter { + /// Filter an event. + /// + /// This implementation never errors. It returns `Ok(false)` if the event is ignored according + /// to the ignore files, and `Ok(true)` otherwise. + fn check_event(&self, event: &Event) -> Result { + let _span = trace_span!("filterer_check").entered(); + let mut pass = true; + + for (path, file_type) in event.paths() { + let _span = trace_span!("path", ?path).entered(); + let is_dir = file_type + .map(|t| matches!(t, FileType::Dir)) + .unwrap_or(false); + + trace!("checking against compiled ignore files"); + match if path.strip_prefix(&self.origin).is_ok() { + trace!("checking against path or parents"); + self.compiled.matched_path_or_any_parents(path, is_dir) + } else { + trace!("checking against path only"); + self.compiled.matched(path, is_dir) + } { + Match::None => { + trace!("no match (pass)"); + pass &= true; + } + Match::Ignore(glob) => { + if glob.from().map_or(true, |f| path.strip_prefix(f).is_ok()) { + trace!(?glob, "positive match (fail)"); + pass &= false; + } else { + trace!(?glob, "positive match, but not in scope (ignore)"); + } + } + Match::Whitelist(glob) => { + trace!(?glob, "negative match (pass)"); + pass = true; + } + } + } + + trace!(?pass, "verdict"); + Ok(pass) + } +}