From e90bbcb9bde0efd8315a4844eadd914036af7123 Mon Sep 17 00:00:00 2001 From: Ryan James Spencer Date: Sat, 5 Oct 2019 20:55:28 +1000 Subject: [PATCH] Support a dedicated ignore file ref. https://github.com/passcod/cargo-watch/issues/127 This adds support for a dedicated ignore file by the name of `.ignore` a la `fd`, `ripgrep`, et. al. This purely just mimics what `Gitignore` is doing except it doesn't ignore `.git` directories. There might be more I need to tweak and the interface might be too obtuse, but this is a first pass. I've also added a `--no-ignore` flag which ignores both `.gitignore` and the dedicated `.ignore`. It might make sense to add a specific flag that ignores `.ignore` but respects `.gitignore` to support the old behaviour, but I wasn't sure what to name it. --- src/cli.rs | 5 + src/ignore.rs | 347 +++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/notification_filter.rs | 36 +++- src/run.rs | 10 +- 5 files changed, 389 insertions(+), 10 deletions(-) create mode 100644 src/ignore.rs diff --git a/src/cli.rs b/src/cli.rs index 756bf82..5a3a10f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,6 +20,7 @@ pub struct Args { pub run_initially: bool, pub no_shell: bool, pub no_vcs_ignore: bool, + pub no_ignore: bool, pub once: bool, pub poll: bool, pub poll_interval: u32, @@ -122,6 +123,9 @@ where .arg(Arg::with_name("no-vcs-ignore") .help("Skip auto-loading of .gitignore files for filtering") .long("no-vcs-ignore")) + .arg(Arg::with_name("no-ignore") + .help("Skip auto-loading of ignore files (.gitignore, .ignore, etc.) for filtering") + .long("no-ignore")) .arg(Arg::with_name("no-default-ignore") .help("Skip auto-ignoring of commonly ignored globs") .long("no-default-ignore")) @@ -226,6 +230,7 @@ where run_initially: !args.is_present("postpone"), no_shell: args.is_present("no-shell"), no_vcs_ignore: args.is_present("no-vcs-ignore"), + no_ignore: args.is_present("no-ignore"), once: args.is_present("once"), poll: args.occurrences_of("poll") > 0, poll_interval: poll_interval, diff --git a/src/ignore.rs b/src/ignore.rs new file mode 100644 index 0000000..f9a2f93 --- /dev/null +++ b/src/ignore.rs @@ -0,0 +1,347 @@ +extern crate globset; + +use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; +use std::collections::HashSet; +use std::fs; +use std::io; +use std::io::Read; +use std::path::{Path, PathBuf}; + +pub struct Ignore { + files: Vec, +} + +#[derive(Debug)] +pub enum Error { + GlobSet(globset::Error), + Io(io::Error), +} + +struct IgnoreFile { + set: GlobSet, + patterns: Vec, + root: PathBuf, +} + +struct Pattern { + pattern: String, + pattern_type: PatternType, + anchored: bool, +} + +enum PatternType { + Ignore, + Whitelist, +} + +#[derive(PartialEq)] +enum MatchResult { + Ignore, + Whitelist, + None, +} + +pub fn load(paths: &[PathBuf]) -> Ignore { + let mut files = vec![]; + let mut checked_dirs = HashSet::new(); + + for path in paths { + let mut p = path.to_owned(); + + loop { + if !checked_dirs.contains(&p) { + checked_dirs.insert(p.clone()); + + let ignore_path = p.join(".ignore"); + if ignore_path.exists() { + match IgnoreFile::new(&ignore_path) { + Ok(f) => { + debug!("Loaded {:?}", ignore_path); + files.push(f); + } + Err(_) => debug!("Unable to load {:?}", ignore_path), + } + } + } + + if p.parent().is_none() { + break; + } + + p.pop(); + } + } + + Ignore::new(files) +} + +impl Ignore { + fn new(files: Vec) -> Ignore { + Ignore { files: files } + } + + pub fn is_excluded(&self, path: &Path) -> bool { + let mut applicable_files: Vec<&IgnoreFile> = self + .files + .iter() + .filter(|f| path.starts_with(&f.root)) + .collect(); + applicable_files.sort_by(|l, r| l.root_len().cmp(&r.root_len())); + + // TODO: add user ignores + + let mut result = MatchResult::None; + + for file in applicable_files { + match file.matches(path) { + MatchResult::Ignore => result = MatchResult::Ignore, + MatchResult::Whitelist => result = MatchResult::Whitelist, + MatchResult::None => {} + } + } + + result == MatchResult::Ignore + } +} + +impl IgnoreFile { + pub fn new(path: &Path) -> Result { + let mut file = fs::File::open(path)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + + let lines = contents.lines().collect(); + let root = path.parent().unwrap(); + + IgnoreFile::from_strings(lines, root) + } + + pub fn from_strings(strs: Vec<&str>, root: &Path) -> Result { + let mut builder = GlobSetBuilder::new(); + let mut patterns = vec![]; + + let parsed_patterns = IgnoreFile::parse(strs); + for p in parsed_patterns { + let mut pat = String::from(p.pattern.clone()); + if !p.anchored && !pat.starts_with("**/") { + pat = "**/".to_string() + &pat; + } + + if !pat.ends_with("/**") { + pat = pat + "/**"; + } + + let glob = GlobBuilder::new(&pat).literal_separator(true).build()?; + + builder.add(glob); + patterns.push(p); + } + + Ok(IgnoreFile { + set: builder.build()?, + patterns: patterns, + root: root.to_owned(), + }) + } + + #[cfg(test)] + fn is_excluded(&self, path: &Path) -> bool { + self.matches(path) == MatchResult::Ignore + } + + fn matches(&self, path: &Path) -> MatchResult { + let stripped = path.strip_prefix(&self.root); + if !stripped.is_ok() { + return MatchResult::None; + } + + let matches = self.set.matches(stripped.unwrap()); + + for &i in matches.iter().rev() { + let pattern = &self.patterns[i]; + return match pattern.pattern_type { + PatternType::Whitelist => MatchResult::Whitelist, + PatternType::Ignore => MatchResult::Ignore, + }; + } + + MatchResult::None + } + + pub fn root_len(&self) -> usize { + self.root.as_os_str().len() + } + + fn parse(contents: Vec<&str>) -> Vec { + contents + .iter() + .filter(|l| !l.is_empty()) + .filter(|l| !l.starts_with('#')) + .map(|l| Pattern::parse(l)) + .collect() + } +} + +impl Pattern { + fn parse(pattern: &str) -> Pattern { + let mut normalized = String::from(pattern); + + let pattern_type = if normalized.starts_with('!') { + normalized.remove(0); + PatternType::Whitelist + } else { + PatternType::Ignore + }; + + let anchored = if normalized.starts_with('/') { + normalized.remove(0); + true + } else { + false + }; + + if normalized.ends_with('/') { + normalized.pop(); + } + + if normalized.starts_with("\\#") || normalized.starts_with("\\!") { + normalized.remove(0); + } + + Pattern { + pattern: normalized, + pattern_type: pattern_type, + anchored: anchored, + } + } +} + +impl From for Error { + fn from(error: globset::Error) -> Error { + Error::GlobSet(error) + } +} + +impl From for Error { + fn from(error: io::Error) -> Error { + Error::Io(error) + } +} + +#[cfg(test)] +mod tests { + use super::IgnoreFile; + use std::path::PathBuf; + + fn base_dir() -> PathBuf { + PathBuf::from("/home/user/dir") + } + + fn build_ignore(pattern: &str) -> IgnoreFile { + IgnoreFile::from_strings(vec![pattern], &base_dir()).unwrap() + } + + #[test] + fn test_matches_exact() { + let file = build_ignore("Cargo.toml"); + + assert!(file.is_excluded(&base_dir().join("Cargo.toml"))); + } + + #[test] + fn test_does_not_match() { + let file = build_ignore("Cargo.toml"); + + assert!(!file.is_excluded(&base_dir().join("src").join("main.rs"))); + } + + #[test] + fn test_matches_simple_wildcard() { + let file = build_ignore("targ*"); + + assert!(file.is_excluded(&base_dir().join("target"))); + } + + #[test] + fn test_matches_subdir_exact() { + let file = build_ignore("target"); + + assert!(file.is_excluded(&base_dir().join("target/"))); + } + + #[test] + fn test_matches_subdir() { + let file = build_ignore("target"); + + assert!(file.is_excluded(&base_dir().join("target").join("file"))); + assert!(file.is_excluded(&base_dir().join("target").join("subdir").join("file"))); + } + + #[test] + fn test_wildcard_with_dir() { + let file = build_ignore("target/f*"); + + assert!(file.is_excluded(&base_dir().join("target").join("file"))); + assert!(!file.is_excluded(&base_dir().join("target").join("subdir").join("file"))); + } + + #[test] + fn test_leading_slash() { + let file = build_ignore("/*.c"); + + assert!(file.is_excluded(&base_dir().join("cat-file.c"))); + assert!(!file.is_excluded(&base_dir().join("mozilla-sha1").join("sha1.c"))); + } + + #[test] + fn test_leading_double_wildcard() { + let file = build_ignore("**/foo"); + + assert!(file.is_excluded(&base_dir().join("foo"))); + assert!(file.is_excluded(&base_dir().join("target").join("foo"))); + assert!(file.is_excluded(&base_dir().join("target").join("subdir").join("foo"))); + } + + #[test] + fn test_trailing_double_wildcard() { + let file = build_ignore("abc/**"); + + assert!(!file.is_excluded(&base_dir().join("def").join("foo"))); + assert!(file.is_excluded(&base_dir().join("abc").join("foo"))); + assert!(file.is_excluded(&base_dir().join("abc").join("subdir").join("foo"))); + } + + #[test] + fn test_sandwiched_double_wildcard() { + let file = build_ignore("a/**/b"); + + assert!(file.is_excluded(&base_dir().join("a").join("b"))); + assert!(file.is_excluded(&base_dir().join("a").join("x").join("b"))); + assert!(file.is_excluded(&base_dir().join("a").join("x").join("y").join("b"))); + } + + #[test] + fn test_empty_file_never_excludes() { + let file = IgnoreFile::from_strings(vec![], &base_dir()).unwrap(); + + assert!(!file.is_excluded(&base_dir().join("target"))); + } + + #[test] + fn test_checks_all_patterns() { + let patterns = vec!["target", "target2"]; + let file = IgnoreFile::from_strings(patterns, &base_dir()).unwrap(); + + assert!(file.is_excluded(&base_dir().join("target").join("foo.txt"))); + assert!(file.is_excluded(&base_dir().join("target2").join("bar.txt"))); + } + + #[test] + fn test_handles_whitelisting() { + let patterns = vec!["target", "!target/foo.txt"]; + let file = IgnoreFile::from_strings(patterns, &base_dir()).unwrap(); + + assert!(!file.is_excluded(&base_dir().join("target").join("foo.txt"))); + assert!(file.is_excluded(&base_dir().join("target").join("blah.txt"))); + } +} diff --git a/src/lib.rs b/src/lib.rs index ac9c0cd..6371f48 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -18,6 +18,7 @@ extern crate winapi; pub mod cli; pub mod error; mod gitignore; +mod ignore; mod notification_filter; pub mod pathop; mod process; diff --git a/src/notification_filter.rs b/src/notification_filter.rs index 632772b..51d43cc 100644 --- a/src/notification_filter.rs +++ b/src/notification_filter.rs @@ -3,20 +3,23 @@ extern crate glob; use error; use gitignore::Gitignore; use globset::{Glob, GlobSet, GlobSetBuilder}; +use ignore::Ignore; use std::path::Path; pub struct NotificationFilter { filters: GlobSet, filter_count: usize, ignores: GlobSet, - ignore_files: Gitignore, + gitignore_files: Gitignore, + ignore_files: Ignore, } impl NotificationFilter { pub fn new( filters: &[String], ignores: &[String], - ignore_files: Gitignore, + gitignore_files: Gitignore, + ignore_files: Ignore, ) -> error::Result { let mut filter_set_builder = GlobSetBuilder::new(); for f in filters { @@ -39,6 +42,7 @@ impl NotificationFilter { filters: filter_set_builder.build()?, filter_count: filters.len(), ignores: ignore_set_builder.build()?, + gitignore_files: gitignore_files, ignore_files: ignore_files, }) } @@ -54,6 +58,11 @@ impl NotificationFilter { } if self.ignore_files.is_excluded(path) { + debug!("Ignoring {:?}: matched ignore file", path); + return true; + } + + if self.gitignore_files.is_excluded(path) { debug!("Ignoring {:?}: matched gitignore file", path); return true; } @@ -70,19 +79,26 @@ impl NotificationFilter { mod tests { use super::NotificationFilter; use gitignore; + use ignore; use std::path::Path; #[test] fn test_allows_everything_by_default() { - let filter = NotificationFilter::new(&[], &[], gitignore::load(&[])).unwrap(); + let filter = + NotificationFilter::new(&[], &[], gitignore::load(&[]), ignore::load(&[])).unwrap(); assert!(!filter.is_excluded(&Path::new("foo"))); } #[test] fn test_filename() { - let filter = - NotificationFilter::new(&[], &["test.json".into()], gitignore::load(&[])).unwrap(); + let filter = NotificationFilter::new( + &[], + &["test.json".into()], + gitignore::load(&[]), + ignore::load(&[]), + ) + .unwrap(); assert!(filter.is_excluded(&Path::new("/path/to/test.json"))); assert!(filter.is_excluded(&Path::new("test.json"))); @@ -91,7 +107,8 @@ mod tests { #[test] fn test_multiple_filters() { let filters = &["*.rs".into(), "*.toml".into()]; - let filter = NotificationFilter::new(filters, &[], gitignore::load(&[])).unwrap(); + let filter = + NotificationFilter::new(filters, &[], gitignore::load(&[]), ignore::load(&[])).unwrap(); assert!(!filter.is_excluded(&Path::new("hello.rs"))); assert!(!filter.is_excluded(&Path::new("Cargo.toml"))); @@ -101,7 +118,8 @@ mod tests { #[test] fn test_multiple_ignores() { let ignores = &["*.rs".into(), "*.toml".into()]; - let filter = NotificationFilter::new(&[], ignores, gitignore::load(&[])).unwrap(); + let filter = + NotificationFilter::new(&[], ignores, gitignore::load(&[]), ignore::load(&[])).unwrap(); assert!(filter.is_excluded(&Path::new("hello.rs"))); assert!(filter.is_excluded(&Path::new("Cargo.toml"))); @@ -111,7 +129,9 @@ mod tests { #[test] fn test_ignores_take_precedence() { let ignores = &["*.rs".into(), "*.toml".into()]; - let filter = NotificationFilter::new(ignores, ignores, gitignore::load(&[])).unwrap(); + let filter = + NotificationFilter::new(ignores, ignores, gitignore::load(&[]), ignore::load(&[])) + .unwrap(); assert!(filter.is_excluded(&Path::new("hello.rs"))); assert!(filter.is_excluded(&Path::new("Cargo.toml"))); diff --git a/src/run.rs b/src/run.rs index 8324fb7..cf25005 100644 --- a/src/run.rs +++ b/src/run.rs @@ -2,6 +2,7 @@ use cli::{clear_screen, Args}; use env_logger; use error::{Error, Result}; use gitignore; +use ignore; use log; use notification_filter::NotificationFilter; #[cfg(target_os = "linux")] @@ -87,8 +88,13 @@ where ); } - let gitignore = gitignore::load(if args.no_vcs_ignore { &[] } else { &paths }); - let filter = NotificationFilter::new(&args.filters, &args.ignores, gitignore)?; + let ignore = ignore::load(if args.no_ignore { &[] } else { &paths }); + let gitignore = gitignore::load(if args.no_vcs_ignore || args.no_ignore { + &[] + } else { + &paths + }); + let filter = NotificationFilter::new(&args.filters, &args.ignores, gitignore, ignore)?; let (tx, rx) = channel(); let poll = args.poll.clone();