From f20d02a7f894469b513c5e0e1839e2a753280870 Mon Sep 17 00:00:00 2001 From: Matt Green Date: Tue, 11 Oct 2016 22:43:53 -0400 Subject: [PATCH] Support for gitignore files --- Cargo.toml | 3 +- src/gitignore.rs | 255 +++++++++++++++++++++++++++++++++++++ src/main.rs | 17 ++- src/notification_filter.rs | 15 ++- 4 files changed, 282 insertions(+), 8 deletions(-) create mode 100644 src/gitignore.rs diff --git a/Cargo.toml b/Cargo.toml index e47d62b..d41ca99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "watchexec" -version = "0.10.1" +version = "0.11.0" authors = ["Matt Green "] [profile.release] @@ -8,7 +8,6 @@ lto = true [dependencies] glob = "0.2.11" -libc = "0.2.16" notify = "2.6.3" [dependencies.clap] diff --git a/src/gitignore.rs b/src/gitignore.rs new file mode 100644 index 0000000..f4c10af --- /dev/null +++ b/src/gitignore.rs @@ -0,0 +1,255 @@ +extern crate glob; + +use std::fs; +use std::io; +use std::io::Read; +use std::path::{Path, PathBuf}; + +pub struct File { + patterns: Vec +} + +struct Pattern { + pattern: glob::Pattern, + str: String, + root: PathBuf, + negated: bool, + #[allow(dead_code)] + directory: bool, + anchored: bool +} + +#[derive(Debug)] +pub enum Error { + Glob(glob::PatternError), + Io(io::Error), +} + +impl File { + pub fn new(path: &Path) -> Result { + let mut file = try!(fs::File::open(path)); + let mut contents = String::new(); + try!(file.read_to_string(&mut contents)); + + let root = path.parent().unwrap(); + let patterns = try!(File::parse(&contents, &root)); + + Ok(File { + patterns: patterns + }) + } + + fn parse(contents: &str, root: &Path) -> Result, Error> { + contents + .lines() + .filter(|l| !l.is_empty()) + .filter(|l| !l.starts_with("#")) + .map(|l| Pattern::new(l, root)) + .collect() + } + + pub fn is_excluded(&self, path: &Path) -> bool { + let mut excluded = false; + + for pattern in self.patterns.iter() { + let matched = pattern.matches(path); + + if matched { + if pattern.negated { + excluded = false; + } + else { + excluded = true; + } + } + } + + excluded + } +} + +impl Pattern { + fn new(pattern: &str, root: &Path) -> Result { + let mut normalized = String::from(pattern); + let mut negated = false; + let mut directory = false; + let mut anchored = false; + + if normalized.starts_with("!") { + normalized.remove(0); + negated = true; + } + + if normalized.starts_with("/") { + normalized.remove(0); + anchored = true; + } + + if normalized.ends_with("/") { + normalized.pop(); + directory = true; + } + + if normalized.starts_with("\\#") || normalized.starts_with("\\!") { + normalized.remove(0); + } + + let pat = try!(glob::Pattern::new(&normalized)); + + Ok(Pattern { + pattern: pat, + str: String::from(normalized), + root: root.to_path_buf(), + negated: negated, + directory: directory, + anchored: anchored + }) + } + + fn matches(&self, path: &Path) -> bool { + let options = glob::MatchOptions { + case_sensitive: false, + require_literal_separator: true, + require_literal_leading_dot: false + }; + + let stripped_path = match path.strip_prefix(&self.root) { + Ok(p) => p, + Err(_) => return false + }; + + let mut result = false; + + if self.anchored { + let first_component = stripped_path.iter().next(); + result = match first_component { + Some(s) => self.pattern.matches_path_with(Path::new(&s), &options), + None => false + } + } + else if !self.str.contains("/") { + result = stripped_path.iter().any(|c| { + self.pattern.matches_path_with(Path::new(c), &options) + }); + } + else { + if self.pattern.matches_path_with(stripped_path, &options) { + result = true; + } + } + + result + } +} + +impl From for Error { + fn from(error: glob::PatternError) -> Error { + Error::Glob(error) + } +} + +impl From for Error { + fn from(error: io::Error) -> Error { + Error::Io(error) + } +} + +//fn main() { + //let cwd = env::current_dir().unwrap(); + //let gitignore_file = cwd.join(".gitignore"); + //let file = File::new(&gitignore_file).unwrap(); + + //for arg in env::args().skip(1) { + //let path = cwd.join(&arg); + //let matches = file.is_excluded(&path); + //println!("File: {}, Excluded: {}", arg, matches); + //} +//} + +#[cfg(test)] +mod tests { + use super::Pattern; + use std::path::PathBuf; + + fn base_dir() -> PathBuf { + PathBuf::from("/home/user/dir") + } + + fn build_pattern(pattern: &str) -> Pattern { + Pattern::new(pattern, &base_dir()).unwrap() + } + + #[test] + fn test_matches_exact() { + let pattern = build_pattern("Cargo.toml"); + + assert!(pattern.matches(&base_dir().join("Cargo.toml"))); + } + + #[test] + fn test_matches_simple_wildcard() { + let pattern = build_pattern("targ*"); + + assert!(pattern.matches(&base_dir().join("target"))); + } + + #[test] + fn test_does_not_match() { + let pattern = build_pattern("Cargo.toml"); + + assert!(!pattern.matches(&base_dir().join("src").join("main.rs"))); + } + + #[test] + fn test_matches_subdir() { + let pattern = build_pattern("target"); + + assert!(pattern.matches(&base_dir().join("target").join("file"))); + assert!(pattern.matches(&base_dir().join("target").join("subdir").join("file"))); + } + + #[test] + fn test_wildcard_with_dir() { + let pattern = build_pattern("target/f*"); + + assert!(pattern.matches(&base_dir().join("target").join("file"))); + assert!(!pattern.matches(&base_dir().join("target").join("subdir").join("file"))); + } + + #[test] + fn test_leading_slash() { + let pattern = build_pattern("/*.c"); + + assert!(pattern.matches(&base_dir().join("cat-file.c"))); + assert!(!pattern.matches(&base_dir().join("mozilla-sha1").join("sha1.c"))); + } + + #[test] + fn test_leading_double_wildcard() { + let pattern = build_pattern("**/foo"); + + assert!(pattern.matches(&base_dir().join("foo"))); + assert!(pattern.matches(&base_dir().join("target").join("foo"))); + assert!(pattern.matches(&base_dir().join("target").join("subdir").join("foo"))); + } + + #[test] + fn test_trailing_double_wildcard() { + let pattern = build_pattern("abc/**"); + + assert!(!pattern.matches(&base_dir().join("def").join("foo"))); + assert!(pattern.matches(&base_dir().join("abc").join("foo"))); + assert!(pattern.matches(&base_dir().join("abc").join("subdir").join("foo"))); + } + + #[test] + fn test_sandwiched_double_wildcard() { + let pattern = build_pattern("a/**/b"); + + assert!(pattern.matches(&base_dir().join("a").join("b"))); + assert!(pattern.matches(&base_dir().join("a").join("x").join("b"))); + assert!(pattern.matches(&base_dir().join("a").join("x").join("y").join("b"))); + } + +} + diff --git a/src/main.rs b/src/main.rs index 791600f..03313f6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ extern crate clap; -extern crate libc; extern crate notify; +mod gitignore; mod notification_filter; mod runner; @@ -45,7 +45,7 @@ fn wait(rx: &Receiver, filter: &NotificationFilter, verbose: bool) -> Res fn main() { let args = App::new("watchexec") - .version("0.10.1") + .version("0.11.0") .about("Execute commands when watched files change") .arg(Arg::with_name("path") .help("Path to watch") @@ -97,7 +97,18 @@ fn main() { let verbose = args.is_present("verbose"); let cwd = env::current_dir().unwrap(); - let mut filter = NotificationFilter::new(&cwd).expect("unable to create notification filter"); + + let mut gitignore_file = None; + let gitignore_path = cwd.join(".gitignore"); + if gitignore_path.exists() { + if verbose { + println!("*** Found .gitignore file: {}", gitignore_path.to_str().unwrap()); + } + + gitignore_file = gitignore::File::new(&gitignore_path).ok(); + } + + let mut filter = NotificationFilter::new(&cwd, gitignore_file).expect("unable to create notification filter"); // Add default ignore list let dotted_dirs = Path::new(".*").join("*"); diff --git a/src/notification_filter.rs b/src/notification_filter.rs index 87e6745..22ec8e9 100644 --- a/src/notification_filter.rs +++ b/src/notification_filter.rs @@ -1,5 +1,6 @@ extern crate glob; +use gitignore; use std::io; use std::path::{Path,PathBuf}; @@ -8,7 +9,8 @@ use self::glob::{Pattern,PatternError}; pub struct NotificationFilter { cwd: PathBuf, filters: Vec, - ignores: Vec + ignores: Vec, + ignore_file: Option } #[derive(Debug)] @@ -30,13 +32,14 @@ impl From for NotificationError { } impl NotificationFilter { - pub fn new(current_dir: &Path) -> Result { + pub fn new(current_dir: &Path, ignore_file: Option) -> Result { let canonicalized = try!(current_dir.canonicalize()); Ok(NotificationFilter { cwd: canonicalized, filters: vec![], - ignores: vec![] + ignores: vec![], + ignore_file: ignore_file }) } @@ -102,6 +105,12 @@ impl NotificationFilter { } } + if let Some(ref ignore_file) = self.ignore_file { + if ignore_file.is_excluded(path) { + return true; + } + } + self.filters.len() > 0 } }