diff --git a/Cargo.lock b/Cargo.lock index 3f0a8b7..54f8020 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,6 +248,9 @@ name = "cc" version = "1.0.70" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26a6ce4b6a484fa3edb70f7efa6fc430fd2b87285fe8b84304fd0936faa0dc0" +dependencies = [ + "jobserver", +] [[package]] name = "cfg-if" @@ -593,6 +596,16 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.0.0" @@ -745,6 +758,21 @@ version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0a01e0497841a3b2db4f8afa483cce65f7e96a3498bd6c541734792aeac8fe7" +[[package]] +name = "git2" +version = "0.13.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a8057932925d3a9d9e4434ea016570d37420ddb1ceed45a174d577f24ed6700" +dependencies = [ + "bitflags 1.2.1", + "libc", + "libgit2-sys", + "log", + "openssl-probe", + "openssl-sys", + "url", +] + [[package]] name = "globset" version = "0.4.8" @@ -890,6 +918,17 @@ dependencies = [ "tokio-io-timeout", ] +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "indenter" version = "0.3.3" @@ -971,6 +1010,15 @@ version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71991ff56294aa922b450139ee08b3bfc70982c6b2c7562771375cf73542dd4" +[[package]] +name = "jobserver" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af25a77299a7f711a01975c35a6a424eb6862092cc2d6c72c4ed6cbc56dfc1fa" +dependencies = [ + "libc", +] + [[package]] name = "kqueue" version = "1.0.4" @@ -1016,6 +1064,46 @@ version = "0.2.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd8f7255a17a627354f321ef0055d63b898c6fb27eff628af4d1b66b7331edf6" +[[package]] +name = "libgit2-sys" +version = "0.12.24+1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddbd6021eef06fb289a8f54b3c2acfdd85ff2a585dfbb24b8576325373d2152c" +dependencies = [ + "cc", + "libc", + "libssh2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", +] + +[[package]] +name = "libssh2-sys" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0186af0d8f171ae6b9c4c90ec51898bad5d08a2d5e470903a50d9ad8959cbee" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de5435b8549c16d423ed0c03dbaafe57cf6c3344744f1242520d59c9d8ecec66" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linked-hash-map" version = "0.5.4" @@ -1070,6 +1158,12 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "matches" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e378b66a060d48947b590737b30a1be76706c8dd7b8ba0f2fe3989c68a853f" + [[package]] name = "memchr" version = "2.4.1" @@ -1343,6 +1437,25 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" +[[package]] +name = "openssl-probe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28988d872ab76095a6e6ac88d99b54fd267702734fd7ffe610ca27f533ddb95a" + +[[package]] +name = "openssl-sys" +version = "0.9.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69df2d8dfc6ce3aaf44b40dec6f487d5a886516cf6879c49e98e0710f310a058" +dependencies = [ + "autocfg", + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "owo-colors" version = "1.3.0" @@ -1472,6 +1585,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9b1041b4387893b91ee6746cddfc28516aff326a3519fb2adf820932c5e6cb" + [[package]] name = "polling" version = "2.1.0" @@ -2143,6 +2262,21 @@ dependencies = [ "winapi", ] +[[package]] +name = "tinyvec" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83b2a3d4d9091d0abd7eba4dc2710b1718583bd4d8992e2190720ea38f391f7" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + [[package]] name = "tokio" version = "1.12.0" @@ -2411,6 +2545,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "unicode-bidi" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a01404663e3db436ed2746d9fefef640d868edae3cceb81c3b8d5732fda678f" + [[package]] name = "unicode-linebreak" version = "0.1.2" @@ -2420,6 +2560,15 @@ dependencies = [ "regex", ] +[[package]] +name = "unicode-normalization" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.8.0" @@ -2444,12 +2593,30 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "percent-encoding", +] + [[package]] name = "uuid" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.3" @@ -2535,12 +2702,14 @@ name = "watchexec" version = "1.17.1" dependencies = [ "async-recursion", + "async-stream", "atomic-take", "clearscreen", "color-eyre", "command-group", "dunce", "futures", + "git2", "globset", "miette", "nom 7.0.0", diff --git a/lib/Cargo.toml b/lib/Cargo.toml index d0c04c7..8dd0076 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -29,6 +29,8 @@ regex = "1.5.4" thiserror = "1.0.26" tracing = "0.1.26" unicase = "2.6.0" +async-stream = "0.3.2" +git2 = "0.13.22" [dependencies.command-group] version = "1.0.5" diff --git a/lib/src/config.rs b/lib/src/config.rs index c8d44fe..ee7adc2 100644 --- a/lib/src/config.rs +++ b/lib/src/config.rs @@ -185,13 +185,13 @@ pub struct InitConfig { } impl Default for InitConfig { - fn default() -> Self { - Self { + fn default() -> Self { + Self { error_handler: Box::new(()) as _, error_channel_size: 64, event_channel_size: 1024, } - } + } } impl InitConfig { diff --git a/lib/src/error.rs b/lib/src/error.rs index 3a0f95c..b539bd7 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -1,6 +1,6 @@ //! Error types for critical, runtime, and specialised errors. -use std::{path::PathBuf}; +use std::path::PathBuf; use miette::Diagnostic; use thiserror::Error; @@ -9,7 +9,11 @@ use tokio::{ task::JoinError, }; -use crate::{action, event::Event, fs::{self, Watcher}}; +use crate::{ + action, + event::Event, + fs::{self, Watcher}, +}; /// Errors which are not recoverable and stop watchexec execution. #[derive(Debug, Diagnostic, Error)] diff --git a/lib/src/filter/tagged.rs b/lib/src/filter/tagged.rs index 2a99cd0..e74257f 100644 --- a/lib/src/filter/tagged.rs +++ b/lib/src/filter/tagged.rs @@ -11,15 +11,14 @@ use unicase::UniCase; use crate::error::RuntimeError; use crate::event::{Event, Tag}; use crate::filter::Filterer; -use crate::project::ProjectType; // to make filters pub use globset::Glob; pub use regex::Regex; +pub mod error; mod parse; pub mod swaplock; -pub mod error; #[derive(Debug)] pub struct TaggedFilterer { @@ -121,7 +120,11 @@ impl TaggedFilterer { // Ok(Some(bool)) => the match was applied, bool is the result // Ok(None) => for some precondition, the match was not done (mismatched tag, out of context, …) - fn match_tag(&self, filter: &Filter, tag: &Tag) -> Result, error::TaggedFiltererError> { + fn match_tag( + &self, + filter: &Filter, + tag: &Tag, + ) -> Result, error::TaggedFiltererError> { trace!(?tag, matcher=?filter.on, "matching filter to tag"); match (tag, filter.on) { (tag, Matcher::Tag) => filter.matches(tag.discriminant_name()), @@ -220,7 +223,9 @@ impl TaggedFilterer { /// /// This parses and compiles the glob, and wraps any error with nice [miette] diagnostics. pub fn glob(s: &str) -> Result { - Glob::new(s).map_err(error::TaggedFiltererError::GlobParse).map(|g| Pattern::Glob(g.compile_matcher())) + Glob::new(s) + .map_err(error::TaggedFiltererError::GlobParse) + .map(|g| Pattern::Glob(g.compile_matcher())) } } @@ -269,38 +274,6 @@ impl Filter { } }) } - - /// Returns a set of ignores for the given project type. - pub fn for_project(project_type: ProjectType) -> Vec { - const ERR: &str = "static pattern"; - - // TODO: use gitignores - - match project_type { - ProjectType::Git => vec![ - Self::from_glob_ignore("/.git").expect(ERR), - ], - ProjectType::Mercurial => todo!(), - ProjectType::Pijul => todo!(), - ProjectType::Fossil => todo!(), - ProjectType::Cargo => vec![ - Self::from_glob_ignore("/debug").expect(ERR), - Self::from_glob_ignore("/target").expect(ERR), - Self::from_glob_ignore("Cargo.lock").expect(ERR), - Self::from_glob_ignore("**/*.rs.bk").expect(ERR), - Self::from_glob_ignore("*.pdb").expect(ERR), - ], - ProjectType::JavaScript => todo!(), - ProjectType::Bundler => todo!(), - ProjectType::RubyGem => todo!(), - ProjectType::Pip => todo!(), - } - } - - pub(crate) fn from_glob_ignore(glob: impl AsRef) -> Result { - let glob = Glob::new(glob.as_ref())?.compile_matcher(); - Ok(Self { in_path: None, on: Matcher::Path, op: Op::NotGlob, pat: Pattern::Glob(glob), negate: false }) - } } #[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] diff --git a/lib/src/filter/tagged/error.rs b/lib/src/filter/tagged/error.rs index 0fc295b..87d46aa 100644 --- a/lib/src/filter/tagged/error.rs +++ b/lib/src/filter/tagged/error.rs @@ -1,12 +1,15 @@ //! Error type for TaggedFilterer. -use std::{collections::HashMap}; +use std::collections::HashMap; use miette::Diagnostic; use thiserror::Error; -use tokio::sync::watch::{error::SendError}; +use tokio::sync::watch::error::SendError; -use crate::{error::RuntimeError, filter::tagged::{Filter, Matcher}}; +use crate::{ + error::RuntimeError, + filter::tagged::{Filter, Matcher}, +}; /// Errors emitted by the TaggedFilterer. #[derive(Debug, Diagnostic, Error)] @@ -42,10 +45,10 @@ pub enum TaggedFiltererError { } impl From for RuntimeError { - fn from(err: TaggedFiltererError) -> Self { - Self::Filterer { + fn from(err: TaggedFiltererError) -> Self { + Self::Filterer { kind: "tagged", err: Box::new(err) as _, } - } + } } diff --git a/lib/src/ignore_files.rs b/lib/src/ignore_files.rs index 22a10a7..c9f0ced 100644 --- a/lib/src/ignore_files.rs +++ b/lib/src/ignore_files.rs @@ -1,13 +1,29 @@ //! Find ignore files, like `.gitignore`, `.ignore`, and others. -use std::path::{Path, PathBuf}; +use std::{ + env, + io::{Error, ErrorKind}, + path::{Path, PathBuf}, +}; -use crate::{error::RuntimeError, project::ProjectType}; +use futures::{pin_mut, Stream, StreamExt}; +use tokio::fs::{metadata, read_dir}; +use crate::project::ProjectType; + +/// An ignore file. +/// +/// This records both the path to the ignore file and some basic metadata about it: which project +/// type it applies to if any, and which subtree it applies in if any (`None` = global ignore file). #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct IgnoreFile { + /// The path to the ignore file. pub path: PathBuf, - pub applies_in: PathBuf, + + /// The path to the subtree the ignore file applies to, or `None` for global ignores. + pub applies_in: Option, + + /// Which project type the ignore file applies to, or was found through. pub applies_to: Option, } @@ -22,22 +38,257 @@ pub struct IgnoreFile { /// /// Importantly, this should be called from the origin of the project, not a subfolder. This /// function will not discover the project origin, and will not traverse parent directories. Use the -/// [`project::origin`](crate::project::origin) function for that. +/// [`project::origins`](crate::project::origins) function for that. /// /// This function also does not distinguish between project folder types, and collects all files for /// all supported VCSs and other project types. Use the `applies_to` field to filter the results. -pub async fn from_origin(path: impl AsRef) -> Result, RuntimeError> { - todo!() +/// +/// All errors (permissions, etc) are collected and returned alongside the ignore files: you may +/// want to show them to the user while still using whatever ignores were successfully found. Errors +/// from files not being found are silently ignored (the files are just not returned). +/// +/// ## Special case: project-local git config specifying `core.excludesFile` +/// +/// If the project's `.git/config` specifies a value for `core.excludesFile`, this function will +/// return an `IgnoreFile { path: path/to/that/file, applies_in: None, applies_to: Some(ProjectType::Git) }`. +/// This is the only case in which the `applies_in` field is None from this function. When such is +/// received the global Git ignore files found by [`from_environment()`] **should be ignored**. +pub async fn from_origin(path: impl AsRef) -> (Vec, Vec) { + let base = path.as_ref().to_owned(); + let mut files = Vec::new(); + let mut errors = Vec::new(); + + match find_file(base.join(".git/config")).await { + Err(err) => errors.push(err), + Ok(None) => {} + Ok(Some(path)) => match git2::Config::open(&path) { + Err(err) => errors.push(Error::new(ErrorKind::Other, err)), + Ok(config) => { + if let Ok(excludes) = config.get_path("core.excludesFile") { + discover_file( + &mut files, + &mut errors, + None, + Some(ProjectType::Git), + excludes, + ) + .await; + } + } + }, + } + + discover_file( + &mut files, + &mut errors, + Some(base.clone()), + Some(ProjectType::Bazaar), + base.join(".bzrignore"), + ) + .await; + + discover_file( + &mut files, + &mut errors, + Some(base.clone()), + Some(ProjectType::Darcs), + base.join("_darcs/prefs/boring"), + ) + .await; + + discover_file( + &mut files, + &mut errors, + Some(base.clone()), + Some(ProjectType::Fossil), + base.join(".fossil-settings/ignore-glob"), + ) + .await; + + discover_file( + &mut files, + &mut errors, + Some(base.clone()), + Some(ProjectType::Git), + base.join(".git/info/exclude"), + ) + .await; + + let dirs = all_dirs(base); + pin_mut!(dirs); + while let Some(p) = dirs.next().await { + match p { + Err(err) => errors.push(err), + Ok(dir) => { + discover_file( + &mut files, + &mut errors, + Some(dir.clone()), + None, + dir.join(".ignore"), + ) + .await; + + discover_file( + &mut files, + &mut errors, + Some(dir.clone()), + Some(ProjectType::Git), + dir.join(".gitignore"), + ) + .await; + + discover_file( + &mut files, + &mut errors, + Some(dir.clone()), + Some(ProjectType::Mercurial), + dir.join(".hgignore"), + ) + .await; + } + } + } + + (files, errors) } /// Finds all ignore files that apply to the current runtime. /// /// This considers: -/// - System-wide ignore files (e.g. `/etc/git/ignore`) -/// - User-specific ignore files (e.g. `~/.gitignore`) +/// - User-specific git ignore files (e.g. `~/.gitignore`) /// - Git configurable ignore files (e.g. with `core.excludesFile` in system or user config) -/// - Other VCS ignore files in system and user locations and config (e.g. `~/.hgignore`) -/// - Files from the `WATCHEXEC_IGNORE_FILES` environment variable (comma-separated). -pub async fn from_environment() -> Result, RuntimeError> { - todo!() +/// - `$XDG_CONFIG_HOME/watchexec/ignore`, as well as other locations (APPDATA on Windows…) +/// - Files from the `WATCHEXEC_IGNORE_FILES` environment variable (comma-separated) +/// +/// All errors (permissions, etc) are collected and returned alongside the ignore files: you may +/// want to show them to the user while still using whatever ignores were successfully found. Errors +/// from files not being found are silently ignored (the files are just not returned). +pub async fn from_environment() -> (Vec, Vec) { + let mut files = Vec::new(); + let mut errors = Vec::new(); + + for path in env::var("WATCHEXEC_IGNORE_FILES") + .unwrap_or_default() + .split(',') + { + discover_file(&mut files, &mut errors, None, None, PathBuf::from(path)).await; + } + + let mut found_git_global = false; + match git2::Config::open_default() { + Err(err) => errors.push(Error::new(ErrorKind::Other, err)), + Ok(config) => { + if let Ok(excludes) = config.get_path("core.excludesFile") { + if discover_file( + &mut files, + &mut errors, + None, + Some(ProjectType::Git), + excludes, + ) + .await + { + found_git_global = true; + } + } + } + } + + if !found_git_global { + let mut tries = Vec::with_capacity(5); + if let Ok(home) = env::var("XDG_CONFIG_HOME") { + tries.push(Path::new(&home).join("git/ignore")); + } + if let Ok(home) = env::var("APPDATA") { + tries.push(Path::new(&home).join(".gitignore")); + } + if let Ok(home) = env::var("USERPROFILE") { + tries.push(Path::new(&home).join(".gitignore")); + } + if let Ok(home) = env::var("HOME") { + tries.push(Path::new(&home).join(".config/git/ignore")); + tries.push(Path::new(&home).join(".gitignore")); + } + + for path in tries { + if discover_file(&mut files, &mut errors, None, Some(ProjectType::Git), path).await { + break; + } + } + } + + let mut homes = Vec::with_capacity(5); + if let Ok(home) = env::var("XDG_CONFIG_HOME") { + homes.push(Path::new(&home).join("watchexec/ignore")); + } + if let Ok(home) = env::var("APPDATA") { + homes.push(Path::new(&home).join("watchexec/ignore")); + } + if let Ok(home) = env::var("USERPROFILE") { + homes.push(Path::new(&home).join(".watchexec/ignore")); + } + if let Ok(home) = env::var("HOME") { + homes.push(Path::new(&home).join(".watchexec/ignore")); + } + + for path in homes { + if discover_file(&mut files, &mut errors, None, None, path).await { + break; + } + } + + (files, errors) +} + +#[inline] +async fn discover_file( + files: &mut Vec, + errors: &mut Vec, + applies_in: Option, + applies_to: Option, + path: PathBuf, +) -> bool { + match find_file(path).await { + Err(err) => { + errors.push(err); + false + } + Ok(None) => false, + Ok(Some(path)) => { + files.push(IgnoreFile { + path, + applies_in, + applies_to, + }); + true + } + } +} + +async fn find_file(path: PathBuf) -> Result, Error> { + match metadata(&path).await { + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err), + Ok(meta) if meta.is_file() && meta.len() > 0 => Ok(Some(path)), + Ok(_) => Ok(None), + } +} + +fn all_dirs(path: PathBuf) -> impl Stream> { + async_stream::try_stream! { + yield path.clone(); + let mut to_visit = vec![path]; + + while let Some(path) = to_visit.pop() { + let mut dir = read_dir(&path).await?; + while let Some(entry) = dir.next_entry().await? { + if entry.file_type().await?.is_dir() { + let path = entry.path(); + to_visit.push(path.clone()); + yield path; + } + } + } + } } diff --git a/lib/src/project.rs b/lib/src/project.rs index 07efce5..482849e 100644 --- a/lib/src/project.rs +++ b/lib/src/project.rs @@ -1,31 +1,35 @@ //! Detect project type and origin. -use std::path::{Path, PathBuf}; +use std::{ + io::Error, + path::{Path, PathBuf}, +}; -use crate::error::CriticalError; - -pub async fn origin(path: impl AsRef) -> Result { +pub async fn origins(path: impl AsRef) -> Result, Error> { todo!() } /// Returns all project types detected at this given origin. /// -/// This should be called with the result of [`origin()`], or a project origin if already known; it +/// This should be called with a result of [`origins()`], or a project origin if already known; it /// will not find the origin itself. -pub async fn types(path: impl AsRef) -> Result, CriticalError> { +pub async fn types(path: impl AsRef) -> Result, Error> { todo!() } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] pub enum ProjectType { + Bazaar, + Darcs, + Fossil, Git, Mercurial, Pijul, - Fossil, + Bundler, Cargo, JavaScript, - Bundler, - RubyGem, Pip, + RubyGem, }