From b15615bbaa81222d0f74020442a69a785312cf3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fe=CC=81lix=20Saparelli?= Date: Mon, 16 Aug 2021 21:49:12 +1200 Subject: [PATCH] Start on watchexec v2 --- .editorconfig | 3 +- Cargo.lock | 293 +++++++++++++++++++--- lib/.rustfmt.toml | 1 + lib/Cargo.toml | 21 +- lib/src/config.rs | 151 ------------ lib/src/error.rs | 141 +++++------ lib/src/event.rs | 36 +++ lib/src/fs.rs | 168 +++++++++++++ lib/src/gitignore.rs | 363 --------------------------- lib/src/ignore.rs | 367 --------------------------- lib/src/lib.rs | 28 +-- lib/src/notification_filter.rs | 160 ------------ lib/src/pathop.rs | 42 ---- lib/src/paths.rs | 289 ---------------------- lib/src/run.rs | 437 --------------------------------- lib/src/shell.rs | 197 --------------- lib/src/signal.rs | 207 ---------------- lib/src/watcher.rs | 62 ----- 18 files changed, 552 insertions(+), 2414 deletions(-) create mode 100644 lib/.rustfmt.toml delete mode 100644 lib/src/config.rs create mode 100644 lib/src/event.rs create mode 100644 lib/src/fs.rs delete mode 100644 lib/src/gitignore.rs delete mode 100644 lib/src/ignore.rs delete mode 100644 lib/src/notification_filter.rs delete mode 100644 lib/src/pathop.rs delete mode 100644 lib/src/paths.rs delete mode 100644 lib/src/run.rs delete mode 100644 lib/src/shell.rs delete mode 100644 lib/src/signal.rs delete mode 100644 lib/src/watcher.rs diff --git a/.editorconfig b/.editorconfig index a22fb32..0bdd715 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,9 +8,8 @@ charset = utf-8 trim_trailing_whitespace = true insert_final_newline = true -[Makefile] +[lib/src/*.rs] indent_style = tab -indent_size = 8 [*.toml] indent_size = 2 diff --git a/Cargo.lock b/Cargo.lock index f2bf8a6..1766df8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,6 +144,12 @@ version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" +[[package]] +name = "bytes" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040" + [[package]] name = "cache-padded" version = "1.1.1" @@ -195,11 +201,11 @@ dependencies = [ [[package]] name = "clearscreen" -version = "1.0.4" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eff80751e49709362458c0b612c3c682693d7a58966e0bf429872888f647e20" +checksum = "5a657a584d3350fd861a098df2c84e39bba869546101e4097fe123aca98c2d8d" dependencies = [ - "nix 0.20.0", + "nix 0.22.0", "terminfo", "thiserror", "which", @@ -235,9 +241,9 @@ dependencies = [ [[package]] name = "command-group" -version = "1.0.3" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba0e6179c8bfe0251e18e65da6f251f81451ead1968aa3c24401b425fc1a184b" +checksum = "758ddf93da6b6b45c6e44a73d07362945b98814e8c9bb57a8c9353f921c18ba3" dependencies = [ "nix 0.22.0", "winapi 0.3.9", @@ -271,6 +277,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "crossbeam-channel" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ed27e177f16d65f0f0c22a213e17c696ace5dd64b14258b52f9417ccb52db4" +dependencies = [ + "cfg-if 1.0.0", + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.5" @@ -505,7 +521,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ab7d1bd1bd33cc98b0889831b72da23c0aa4df9cec7e0702f46ecea04b35db6" dependencies = [ "bitflags 1.2.1", - "fsevent-sys", + "fsevent-sys 2.0.1", ] [[package]] @@ -517,6 +533,15 @@ dependencies = [ "libc", ] +[[package]] +name = "fsevent-sys" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e564d24da983c053beff1bb7178e237501206840a3e6bf4e267b9e8ae734a" +dependencies = [ + "libc", +] + [[package]] name = "fuchsia-zircon" version = "0.3.3" @@ -689,6 +714,15 @@ dependencies = [ "regex", ] +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "ident_case" version = "1.0.1" @@ -712,6 +746,17 @@ dependencies = [ "libc", ] +[[package]] +name = "inotify" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b031475cb1b103ee221afb806a23d35e0570bf7271d7588762ceba8127ed43b3" +dependencies = [ + "bitflags 1.2.1", + "inotify-sys", + "libc", +] + [[package]] name = "inotify-sys" version = "0.1.5" @@ -779,6 +824,26 @@ dependencies = [ "winapi-build", ] +[[package]] +name = "kqueue" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "058a107a784f8be94c7d35c1300f4facced2e93d2fbe5b1452b44e905ddca4a9" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8367585489f01bc55dd27404dcf56b95e6da061a256a666ab23be9ba96a2e587" +dependencies = [ + "bitflags 1.2.1", + "libc", +] + [[package]] name = "lazy_static" version = "1.4.0" @@ -803,6 +868,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" +[[package]] +name = "lock_api" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0382880606dff6d15c9476c416d18690b72742aa7b605bb6dd6ec9030fbf07eb" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.14" @@ -848,6 +922,28 @@ dependencies = [ "autocfg", ] +[[package]] +name = "miette" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5022841ae4424a04441131a02ea2cc5eafeb1b2cf337b1b3281f57c809b8eb32" +dependencies = [ + "indenter", + "miette-derive", + "thiserror", +] + +[[package]] +name = "miette-derive" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b33d4f2f59c7ad2219e01e5a20abc6bab961a4275c3692e99abb17d04c83278" +dependencies = [ + "proc-macro2", + "quote 1.0.9", + "syn 1.0.73", +] + [[package]] name = "miniz_oxide" version = "0.4.4" @@ -871,12 +967,25 @@ dependencies = [ "kernel32-sys", "libc", "log", - "miow", + "miow 0.2.2", "net2", "slab", "winapi 0.2.8", ] +[[package]] +name = "mio" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c2bdb6314ec10835cd3293dd268473a835c02b7b352e788be788b3c6ca6bb16" +dependencies = [ + "libc", + "log", + "miow 0.3.7", + "ntapi", + "winapi 0.3.9", +] + [[package]] name = "mio-extras" version = "2.0.6" @@ -885,7 +994,7 @@ checksum = "52403fe290012ce777c4626790c8951324a2b9e3316b3143779c72b029742f19" dependencies = [ "lazycell", "log", - "mio", + "mio 0.6.23", "slab", ] @@ -901,6 +1010,15 @@ dependencies = [ "ws2_32-sys", ] +[[package]] +name = "miow" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9f1c5b025cda876f66ef43a113f91ebc9f4ccef34843000e0adf6ebbab84e21" +dependencies = [ + "winapi 0.3.9", +] + [[package]] name = "nb-connect" version = "1.2.0" @@ -935,18 +1053,6 @@ dependencies = [ "void", ] -[[package]] -name = "nix" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa9b4819da1bc61c0ea48b63b7bc8604064dd43013e7cc325df098d49cd7c18a" -dependencies = [ - "bitflags 1.2.1", - "cc", - "cfg-if 1.0.0", - "libc", -] - [[package]] name = "nix" version = "0.22.0" @@ -979,15 +1085,33 @@ dependencies = [ "bitflags 1.2.1", "filetime", "fsevent", - "fsevent-sys", - "inotify", + "fsevent-sys 2.0.1", + "inotify 0.7.1", "libc", - "mio", + "mio 0.6.23", "mio-extras", "walkdir", "winapi 0.3.9", ] +[[package]] +name = "notify" +version = "5.0.0-pre.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c614e7ed2b1cf82ec99aeffd8cf6225ef5021b9951148eb161393c394855032c" +dependencies = [ + "bitflags 1.2.1", + "crossbeam-channel", + "filetime", + "fsevent-sys 4.0.0", + "inotify 0.9.3", + "kqueue", + "libc", + "mio 0.7.13", + "walkdir", + "winapi 0.3.9", +] + [[package]] name = "notify-rust" version = "4.5.2" @@ -1002,6 +1126,15 @@ dependencies = [ "zvariant_derive", ] +[[package]] +name = "ntapi" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6bb902e437b6d86e03cce10a7e2af662292c5dfef23b65899ea3ac9354ad44" +dependencies = [ + "winapi 0.3.9", +] + [[package]] name = "num-integer" version = "0.1.44" @@ -1021,6 +1154,16 @@ dependencies = [ "autocfg", ] +[[package]] +name = "num_cpus" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "objc" version = "0.2.7" @@ -1077,6 +1220,31 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "427c3892f9e783d91cc128285287e70a59e206ca452770ece88a76f7a3eddd72" +[[package]] +name = "parking_lot" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7a782938e745763fe6907fc6ba86946d72f49fe7e21de074e08128a99fb018" +dependencies = [ + "cfg-if 1.0.0", + "instant", + "libc", + "redox_syscall 0.2.9", + "smallvec", + "winapi 0.3.9", +] + [[package]] name = "phf" version = "0.8.0" @@ -1377,6 +1545,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6a9290e3c9cf0f18145ef7ffa62d68ee0bf5fcd651017e586dc7fd5da448c2" +[[package]] +name = "scopeguard" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + [[package]] name = "serde" version = "1.0.126" @@ -1440,6 +1614,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" +dependencies = [ + "libc", +] + [[package]] name = "similar" version = "1.3.0" @@ -1448,9 +1631,9 @@ checksum = "1ad1d488a557b235fc46dae55512ffbfc429d2482b08b4d9435ab07384ca8aec" [[package]] name = "siphasher" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbce6d4507c7e4a3962091436e56e95290cb71fa302d0d270e32130b75fbff27" +checksum = "729a25c17d72b06c68cb47955d44fda88ad2d3e7d77e025663fdd69b93dd71a1" [[package]] name = "slab" @@ -1458,6 +1641,12 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527" +[[package]] +name = "smallvec" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e" + [[package]] name = "socket2" version = "0.4.1" @@ -1609,6 +1798,37 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "tokio" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01cf844b23c6131f624accf65ce0e4e9956a8bb329400ea5bcc26ae3a5c20b0b" +dependencies = [ + "autocfg", + "bytes", + "libc", + "memchr", + "mio 0.7.13", + "num_cpus", + "once_cell", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "tokio-macros", + "winapi 0.3.9", +] + +[[package]] +name = "tokio-macros" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54473be61f4ebe4efd09cec9bd5d16fa51d70ea0192213d754d2d500457db110" +dependencies = [ + "proc-macro2", + "quote 1.0.9", + "syn 1.0.73", +] + [[package]] name = "toml" version = "0.5.8" @@ -1774,6 +1994,20 @@ checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" [[package]] name = "watchexec" version = "1.17.1" +dependencies = [ + "chrono", + "miette", + "notify 5.0.0-pre.11", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "watchexec" +version = "1.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c52e0868bc57765fa91593a173323f464855e53b27f779e1110ba0fb4cb6b406" dependencies = [ "clearscreen", "command-group", @@ -1783,7 +2017,7 @@ dependencies = [ "lazy_static", "log", "nix 0.22.0", - "notify", + "notify 4.0.17", "walkdir", "winapi 0.3.9", ] @@ -1800,7 +2034,7 @@ dependencies = [ "insta", "log", "notify-rust", - "watchexec", + "watchexec 1.17.1 (registry+https://github.com/rust-lang/crates.io-index)", ] [[package]] @@ -1814,11 +2048,12 @@ dependencies = [ [[package]] name = "which" -version = "4.1.0" +version = "4.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b55551e42cbdf2ce2bedd2203d0cc08dba002c27510f86dab6d0ce304cba3dfe" +checksum = "ea187a8ef279bc014ec368c27a920da2024d2a711109bfbe3440585d5cf27ad9" dependencies = [ "either", + "lazy_static", "libc", ] diff --git a/lib/.rustfmt.toml b/lib/.rustfmt.toml new file mode 100644 index 0000000..218e203 --- /dev/null +++ b/lib/.rustfmt.toml @@ -0,0 +1 @@ +hard_tabs = true diff --git a/lib/Cargo.toml b/lib/Cargo.toml index acd8de8..af615d1 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -15,18 +15,13 @@ readme = "README.md" edition = "2018" [dependencies] -clearscreen = "1.0.4" -command-group = "1.0.3" -derive_builder = "0.10.0" -glob = "0.3.0" -globset = "=0.4.6" -lazy_static = "1.1.0" -log = "0.4.14" -notify = "4.0.15" -walkdir = "2.3.2" +chrono = "0.4.19" +miette = "0.7.0" +notify = "5.0.0-pre.11" +thiserror = "1.0.26" +tracing = "0.1.26" -[target.'cfg(unix)'.dependencies] -nix = "0.22.0" +[dependencies.tokio] +version = "1.10.0" +features = ["full"] -[target.'cfg(windows)'.dependencies.winapi] -version = "0.3.9" diff --git a/lib/src/config.rs b/lib/src/config.rs deleted file mode 100644 index a06b8a6..0000000 --- a/lib/src/config.rs +++ /dev/null @@ -1,151 +0,0 @@ -//! Configuration for watchexec. -//! -//! The [`Config`] struct is not constructable, use [`ConfigBuilder`]. -//! -//! # Examples -//! -//! ``` -//! # use watchexec::config::ConfigBuilder; -//! ConfigBuilder::default() -//! .cmd(vec!["echo hello world".into()]) -//! .paths(vec![".".into()]) -//! .build() -//! .expect("mission failed"); -//! ``` - -use derive_builder::Builder; -use std::{path::PathBuf, time::Duration}; - -use crate::run::OnBusyUpdate; -use crate::Shell; - -/// Arguments to the watcher -#[derive(Builder, Clone, Debug)] -#[builder(setter(into, strip_option))] -#[builder(build_fn(validate = "Self::validate"))] -#[non_exhaustive] -pub struct Config { - /// Command to execute. - /// - /// When `shell` is [`Shell::None`], this is expected to be in “execvp(3)” - /// format: first program, rest arguments. Otherwise, all elements will be - /// joined together with a single space and passed to the shell. More - /// control can then be obtained by providing a 1-element vec, and doing - /// your own joining and/or escaping there. - pub cmd: Vec, - - /// List of paths to watch for changes. - pub paths: Vec, - - /// Positive filters (trigger only on matching changes). Glob format. - #[builder(default)] - pub filters: Vec, - - /// Negative filters (do not trigger on matching changes). Glob format. - #[builder(default)] - pub ignores: Vec, - - /// Clear the screen before each run. - #[builder(default)] - pub clear_screen: bool, - - /// If Some, send that signal (e.g. SIGHUP) to the command on change. - #[builder(default)] - pub signal: Option, - - /// Specify what to do when receiving updates while the command is running. - #[builder(default)] - pub on_busy_update: OnBusyUpdate, - - /// Interval to debounce the changes. - #[builder(default = "Duration::from_millis(100)")] - pub debounce: Duration, - - /// Run the commands right after starting. - #[builder(default = "true")] - pub run_initially: bool, - - /// Specify the shell to use. - #[builder(default)] - pub shell: Shell, - - /// Ignore metadata changes. - #[builder(default)] - pub no_meta: bool, - - /// Do not set WATCHEXEC_*_PATH environment variables for the process. - #[builder(default)] - pub no_environment: bool, - - /// Skip auto-loading .gitignore files - #[builder(default)] - pub no_vcs_ignore: bool, - - /// Skip auto-loading .ignore files - #[builder(default)] - pub no_ignore: bool, - - /// For testing only, always set to false. - #[builder(setter(skip), default)] - #[doc(hidden)] - pub once: bool, - - /// Force using the polling backend. - #[builder(default)] - pub poll: bool, - - /// Interval for polling. - #[builder(default = "Duration::from_secs(1)")] - pub poll_interval: Duration, - - /// Whether to use a process group to run the command. - #[builder(default = "true")] - pub use_process_group: bool, -} - -impl ConfigBuilder { - fn validate(&self) -> Result<(), String> { - if self.cmd.as_ref().map_or(true, Vec::is_empty) { - return Err("cmd must not be empty".into()); - } - - if self.paths.as_ref().map_or(true, Vec::is_empty) { - return Err("paths must not be empty".into()); - } - - Ok(()) - } - - #[deprecated(since = "1.15.0", note = "does nothing. set the log level instead")] - pub fn debug(&mut self, _: impl Into) -> &mut Self { - self - } - - /// Do not wrap the commands in a shell. - #[deprecated(since = "1.15.0", note = "use shell(Shell::None) instead")] - pub fn no_shell(&mut self, s: impl Into) -> &mut Self { - if s.into() { - self.shell(Shell::default()) - } else { - self.shell(Shell::None) - } - } - - #[deprecated(since = "1.15.0", note = "use on_busy_update(Restart) instead")] - pub fn restart(&mut self, b: impl Into) -> &mut Self { - if b.into() { - self.on_busy_update(OnBusyUpdate::Restart) - } else { - self - } - } - - #[deprecated(since = "1.15.0", note = "use on_busy_update(DoNothing) instead")] - pub fn watch_when_idle(&mut self, b: impl Into) -> &mut Self { - if b.into() { - self.on_busy_update(OnBusyUpdate::DoNothing) - } else { - self - } - } -} diff --git a/lib/src/error.rs b/lib/src/error.rs index 546242c..b16aa56 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -1,86 +1,69 @@ -use std::{error::Error as StdError, fmt, io, sync::PoisonError}; +//! Watchexec has two error types: for critical and for runtime errors. -pub type Result = ::std::result::Result; +use std::path::PathBuf; +use thiserror::Error; +use miette::Diagnostic; +use tokio::sync::mpsc; + +use crate::{event::Event, fs::Watcher}; + +/// Errors which are not recoverable and stop watchexec execution. +#[derive(Debug, Diagnostic, Error)] #[non_exhaustive] -pub enum Error { - Canonicalization(String, io::Error), - Glob(globset::Error), - Io(io::Error), - Notify(notify::Error), - Generic(String), - PoisonedLock, - ClearScreen(clearscreen::Error), +pub enum CriticalError { + /// A critical I/O error occurred. + #[error(transparent)] + #[diagnostic(code(watchexec::critical::io_error))] + IoError(#[from] std::io::Error), + + /// Error received when an event cannot be sent to the errors channel. + #[error("cannot send internal runtime error: {0}")] + #[diagnostic(code(watchexec::critical::error_channel_send))] + ErrorChannelSend(#[from] mpsc::error::SendError), } -impl StdError for Error {} +/// Errors which _may_ be recoverable, transient, or only affect a part of the operation, and should +/// be reported to the user and/or acted upon programatically, but will not outright stop watchexec. +#[derive(Debug, Diagnostic, Error)] +#[non_exhaustive] +pub enum RuntimeError { + /// Generic I/O error, with no additional context. + #[error(transparent)] + #[diagnostic(code(watchexec::runtime::io_error))] + IoError(#[from] std::io::Error), -impl From for Error { - fn from(err: String) -> Self { - Self::Generic(err) - } -} - -impl From for Error { - fn from(err: globset::Error) -> Self { - Self::Glob(err) - } -} - -impl From for Error { - fn from(err: io::Error) -> Self { - Self::Io(match err.raw_os_error() { - Some(7) => io::Error::new(io::ErrorKind::Other, "There are so many changed files that the environment variables of the command have been overrun. Try running with --no-meta or --no-environment."), - _ => err, - }) - } -} - -impl From for Error { - fn from(err: notify::Error) -> Self { - match err { - notify::Error::Io(err) => Self::Io(err), - other => Self::Notify(other), - } - } -} - -impl<'a, T> From> for Error { - fn from(_err: PoisonError) -> Self { - Self::PoisonedLock - } -} - -impl From for Error { - fn from(err: clearscreen::Error) -> Self { - match err { - clearscreen::Error::Io(err) => Self::Io(err), - other => Self::ClearScreen(other), - } - } -} - -impl fmt::Display for Error { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - let (error_type, error) = match self { - Self::Canonicalization(path, err) => ( - "Path", - format!("couldn't canonicalize '{}':\n{}", path, err), - ), - Self::Generic(err) => ("", err.clone()), - Self::Glob(err) => ("Globset", err.to_string()), - Self::Io(err) => ("I/O", err.to_string()), - Self::Notify(err) => ("Notify", err.to_string()), - Self::PoisonedLock => ("Internal", "poisoned lock".to_string()), - Self::ClearScreen(err) => ("ClearScreen", err.to_string()), - }; - - write!(f, "{} error: {}", error_type, error) - } -} - -impl fmt::Debug for Error { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - fmt::Display::fmt(self, f) - } + /// Error received when creating a filesystem watcher fails. + #[error("{kind:?} watcher failed to instantiate: {err}")] + #[diagnostic( + code(watchexec::runtime::fs_watcher_error), + help("perhaps retry with the poll watcher"), + )] + FsWatcherCreate { kind: Watcher, #[source] err: notify::Error }, + + /// Error received when reading a filesystem event fails. + #[error("{kind:?} watcher received an event that we could not read: {err}")] + #[diagnostic( + code(watchexec::runtime::fs_watcher_event), + )] + FsWatcherEvent { kind: Watcher, #[source] err: notify::Error }, + + /// Error received when adding to the pathset for the filesystem watcher fails. + #[error("while adding {path:?} to the {kind:?} watcher: {err}")] + #[diagnostic( + code(watchexec::runtime::fs_watcher_path_add), + )] + FsWatcherPathAdd { path: PathBuf, kind: Watcher, #[source] err: notify::Error }, + + /// Error received when removing from the pathset for the filesystem watcher fails. + #[error("while removing {path:?} from the {kind:?} watcher: {err}")] + #[diagnostic( + code(watchexec::runtime::fs_watcher_path_remove), + )] + FsWatcherPathRemove { path: PathBuf, kind: Watcher, #[source] err: notify::Error }, + + /// Error received when an event cannot be sent to the event channel. + #[error("cannot send event from {ctx}: {err}")] + #[diagnostic(code(watchexec::runtime::event_channel_send))] + EventChannelSend { ctx: &'static str, #[source] err: mpsc::error::TrySendError }, } diff --git a/lib/src/event.rs b/lib/src/event.rs new file mode 100644 index 0000000..15fbf6e --- /dev/null +++ b/lib/src/event.rs @@ -0,0 +1,36 @@ +//! Fundamentally, events in watchexec have three purposes: +//! +//! 1. To trigger the launch, restart, or other interruption of a process; +//! 2. To be filtered upon according to whatever set of criteria is desired; +//! 3. To carry information about what caused the event, which may be provided to the process. + +use chrono::NaiveDateTime; +use std::{collections::HashMap, path::PathBuf}; + +/// An event, as far as watchexec cares about. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct Event { + pub particulars: Vec, + pub metadata: HashMap>, +} + +/// Something which can be used to filter an event. +#[derive(Clone, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Particle { + Time(NaiveDateTime), + Path(PathBuf), + Source(Source), + Process(u32), +} + +/// The general origin of the event. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[non_exhaustive] +pub enum Source { + Filesystem, + Keyboard, + Mouse, + Time, +} + diff --git a/lib/src/fs.rs b/lib/src/fs.rs new file mode 100644 index 0000000..46c1a17 --- /dev/null +++ b/lib/src/fs.rs @@ -0,0 +1,168 @@ +use std::{collections::{HashMap, HashSet}, path::PathBuf}; + +use tokio::{sync::{mpsc, watch}}; +use tracing::{debug, trace}; + +use crate::{error::{CriticalError, RuntimeError}, event::{Event, Particle, Source}}; + +/// What kind of filesystem watcher to use. +/// +/// For now only native and poll watchers are supported. In the future there may be additional +/// watchers available on some platforms. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum Watcher { + Native, + Poll, +} + +impl Default for Watcher { + fn default() -> Self { + Self::Native + } +} + +impl Watcher { + fn create(self, f: impl notify::EventFn) -> Result, RuntimeError> { + match self { + Self::Native => notify::RecommendedWatcher::new(f).map(|w| Box::new(w) as Box), + Self::Poll => notify::PollWatcher::new(f).map(|w| Box::new(w) as Box), + }.map_err(|err| RuntimeError::FsWatcherCreate { kind: self, err }) + } +} + +/// The working data set of the filesystem worker. +/// +/// This is marked non-exhaustive so new configuration can be added without breaking. +#[derive(Clone, Debug, Default)] +#[non_exhaustive] +pub struct WorkingData { + pub pathset: Vec, + pub watcher: Watcher, +} + +/// Launch a filesystem event worker. +/// +/// This only does a bare minimum of setup; to actually start the work, you need to set a non-empty pathset on the +/// [`WorkingData`] with the [`watch`] channel. +pub async fn worker( + mut working: watch::Receiver, + errors: mpsc::Sender, + events: mpsc::Sender, +) -> Result<(), CriticalError> { + debug!("launching filesystem worker"); + + let mut watcher_type = Watcher::default(); + let mut watcher: Option> = None; + let mut pathset: HashSet = HashSet::new(); + + while working.changed().await.is_ok() { + // In separate scope so we drop the working read lock as early as we can + let (new_watcher, to_watch, to_drop) = { + let data = working.borrow(); + trace!(?data, "filesystem worker got a working data change"); + + if data.pathset.is_empty() { + trace!("no more watched paths, dropping watcher"); + watcher.take(); + pathset.drain(); + continue; + } + + if watcher.is_none() || watcher_type != data.watcher { + pathset.drain(); + + (Some(data.watcher), data.pathset.clone(), Vec::new()) + } else { + let mut to_watch = Vec::with_capacity(data.pathset.len()); + let mut to_drop = Vec::with_capacity(pathset.len()); + for path in data.pathset.iter() { + if !pathset.contains(path) { + to_watch.push(path.clone()); + } + } + + for path in pathset.iter() { + if !data.pathset.contains(path) { + to_drop.push(path.clone()); + } + } + + (None, to_watch, to_drop) + } + }; + + if let Some(kind) = new_watcher { + debug!(?kind, "creating new watcher"); + let n_errors = errors.clone(); + let n_events = events.clone(); + match kind.create(move |nev: Result | { + trace!(event = ?nev, "receiving possible event from watcher"); + + match nev { + Err(err) => { + n_errors.try_send(RuntimeError::FsWatcherEvent { kind, err }).ok(); + }, + + Ok(nev) => { + let mut particulars = Vec::with_capacity(4); + particulars.push(Particle::Source(Source::Filesystem)); + + for path in nev.paths { + particulars.push(Particle::Path(path)); + } + + if let Some(pid) = nev.attrs.process_id() { + particulars.push(Particle::Process(pid)); + } + + let ev = Event { + particulars, + metadata: HashMap::new(), // TODO + }; + + trace!(event = ?ev, "processed notify event into watchexec event"); + if let Err(err) = n_events.try_send(ev) { + n_errors.try_send(RuntimeError::EventChannelSend { + ctx: "fs watcher", + err, + }).ok(); + } + } + } + }) { + Ok(w) => { + watcher.insert(w); + watcher_type = kind; + }, + Err(e) => { + errors.send(e).await?; + } + } + } + + if let Some(w) = watcher.as_mut() { + debug!(?to_watch, ?to_drop, "applying changes to the watcher"); + + for path in to_drop { + trace!(?path, "removing path from the watcher"); + if let Err(err) = w.unwatch(&path) { + errors.send(RuntimeError::FsWatcherPathRemove { path, kind: watcher_type, err }).await?; + } else { + pathset.remove(&path); + } + } + + for path in to_watch { + trace!(?path, "adding path to the watcher"); + if let Err(err) = w.watch(&path, notify::RecursiveMode::Recursive) { + errors.send(RuntimeError::FsWatcherPathAdd { path, kind: watcher_type, err }).await?; + } else { + pathset.insert(path); + } + } + } + } + + Ok(()) +} diff --git a/lib/src/gitignore.rs b/lib/src/gitignore.rs deleted file mode 100644 index 9b3caba..0000000 --- a/lib/src/gitignore.rs +++ /dev/null @@ -1,363 +0,0 @@ -use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; -use log::debug; - -use std::borrow::ToOwned; -use std::fs; -use std::io; -use std::io::Read; -use std::path::{Path, PathBuf}; -use walkdir::WalkDir; - -pub struct Gitignore { - files: Vec, -} - -#[derive(Debug)] -pub enum Error { - GlobSet(globset::Error), - Io(io::Error), -} - -struct GitignoreFile { - 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]) -> Gitignore { - let mut files = vec![]; - - for path in paths { - let mut top_level_git_dir = None; - let mut p = Some(path.as_path()); - - while let Some(ref current) = p { - debug!("Looking in {:?} for a .git directory", current); - - // Stop if we see a .git directory - if let Ok(metadata) = current.join(".git").metadata() { - if metadata.is_dir() { - top_level_git_dir = Some(*current); - break; - } - } - - p = current.parent(); - } - - if let Some(root) = top_level_git_dir { - debug!("Found the top level git directory: {:?}", root); - // scan in subdirectories - for entry in WalkDir::new(root) - .into_iter() - .filter_map(Result::ok) - .filter(|e| e.file_type().is_file()) - .filter(|e| e.file_name() == ".gitignore") - { - let gitignore_path = entry.path(); - if let Ok(f) = GitignoreFile::new(gitignore_path) { - debug!("Loaded {:?}", gitignore_path); - files.push(f); - } else { - debug!("Unable to load {:?}", gitignore_path); - } - } - } - - // p.pop(); - } - - Gitignore::new(files) -} - -impl Gitignore { - const fn new(files: Vec) -> Self { - Self { files } - } - - pub fn is_excluded(&self, path: &Path) -> bool { - let mut applicable_files: Vec<&GitignoreFile> = self - .files - .iter() - .filter(|f| path.starts_with(&f.root)) - .collect(); - applicable_files.sort_by_key(|f| f.root_len()); - - // TODO: add user gitignores - - 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 GitignoreFile { - 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: Vec<_> = contents.lines().collect(); - let root = path.parent().expect("gitignore file is at filesystem root"); - - Self::from_strings(&lines, root) - } - - pub fn from_strings(strs: &[&str], root: &Path) -> Result { - let mut builder = GlobSetBuilder::new(); - let mut patterns = vec![]; - - let parsed_patterns = Self::parse(strs); - for p in parsed_patterns { - let mut pat = p.pattern.clone(); - if !p.anchored && !pat.starts_with("**/") { - pat = "**/".to_string() + &pat; - } - - if !pat.ends_with("/**") { - pat += "/**"; - } - - let glob = GlobBuilder::new(&pat).literal_separator(true).build()?; - - builder.add(glob); - patterns.push(p); - } - - Ok(Self { - set: builder.build()?, - 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 { - if let Ok(stripped) = path.strip_prefix(&self.root) { - let matches = self.set.matches(stripped); - if let Some(i) = matches.iter().rev().next() { - 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: &[&str]) -> Vec { - contents - .iter() - .filter_map(|l| { - if !l.is_empty() && !l.starts_with('#') { - Some(Pattern::parse(l)) - } else { - None - } - }) - .collect() - } -} - -impl Pattern { - fn parse(pattern: &str) -> Self { - 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); - } - - Self { - pattern: normalized, - pattern_type, - anchored, - } - } -} - -impl From for Error { - fn from(error: globset::Error) -> Self { - Self::GlobSet(error) - } -} - -impl From for Error { - fn from(error: io::Error) -> Self { - Self::Io(error) - } -} - -#[cfg(test)] -mod tests { - use super::GitignoreFile; - use std::path::PathBuf; - - fn base_dir() -> PathBuf { - PathBuf::from("/home/user/dir") - } - - fn build_gitignore(pattern: &str) -> GitignoreFile { - GitignoreFile::from_strings(&[pattern], &base_dir()).expect("test gitignore file invalid") - } - - #[test] - fn matches_exact() { - let file = build_gitignore("Cargo.toml"); - - assert!(file.is_excluded(&base_dir().join("Cargo.toml"))); - } - - #[test] - fn does_not_match() { - let file = build_gitignore("Cargo.toml"); - - assert!(!file.is_excluded(&base_dir().join("src").join("main.rs"))); - } - - #[test] - fn matches_simple_wildcard() { - let file = build_gitignore("targ*"); - - assert!(file.is_excluded(&base_dir().join("target"))); - } - - #[test] - fn matches_subdir_exact() { - let file = build_gitignore("target"); - - assert!(file.is_excluded(&base_dir().join("target/"))); - } - - #[test] - fn matches_subdir() { - let file = build_gitignore("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 wildcard_with_dir() { - let file = build_gitignore("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 leading_slash() { - let file = build_gitignore("/*.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 leading_double_wildcard() { - let file = build_gitignore("**/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 trailing_double_wildcard() { - let file = build_gitignore("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 sandwiched_double_wildcard() { - let file = build_gitignore("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 empty_file_never_excludes() { - let file = - GitignoreFile::from_strings(&[], &base_dir()).expect("test gitignore file invalid"); - - assert!(!file.is_excluded(&base_dir().join("target"))); - } - - #[test] - fn checks_all_patterns() { - let patterns = vec!["target", "target2"]; - let file = GitignoreFile::from_strings(&patterns, &base_dir()) - .expect("test gitignore file invalid"); - - assert!(file.is_excluded(&base_dir().join("target").join("foo.txt"))); - assert!(file.is_excluded(&base_dir().join("target2").join("bar.txt"))); - } - - #[test] - fn handles_negative_patterns() { - let patterns = vec!["target", "!target/foo.txt"]; - let file = GitignoreFile::from_strings(&patterns, &base_dir()) - .expect("test gitignore file invalid"); - - 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/lib/src/ignore.rs b/lib/src/ignore.rs deleted file mode 100644 index 794a113..0000000 --- a/lib/src/ignore.rs +++ /dev/null @@ -1,367 +0,0 @@ -use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; -use log::debug; -use std::collections::HashSet; -use std::fs; -use std::io; -use std::io::Read; -use std::path::{Path, PathBuf}; -use walkdir::WalkDir; - -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(); - - // walk up to root - // FIXME: this makes zero sense and should be removed - // but that would be a breaking change - loop { - if !checked_dirs.contains(&p) { - checked_dirs.insert(p.clone()); - - let ignore_path = p.join(".ignore"); - if ignore_path.exists() { - if let Ok(f) = IgnoreFile::new(&ignore_path) { - debug!("Loaded {:?}", ignore_path); - files.push(f); - } else { - debug!("Unable to load {:?}", ignore_path); - } - } - } - - if p.parent().is_none() { - break; - } - - p.pop(); - } - - //also look in subfolders - for entry in WalkDir::new(path) - .into_iter() - .filter_map(Result::ok) - .filter(|e| e.file_type().is_file()) - .filter(|e| e.file_name() == ".ignore") - { - let ignore_path = entry.path(); - if let Ok(f) = IgnoreFile::new(ignore_path) { - debug!("Loaded {:?}", ignore_path); - files.push(f); - } else { - debug!("Unable to load {:?}", ignore_path); - } - } - } - - Ignore::new(files) -} - -impl Ignore { - const fn new(files: Vec) -> Self { - Self { 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_key(|f| f.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: Vec<_> = contents.lines().collect(); - let root = path.parent().expect("ignore file is at filesystem root"); - - Self::from_strings(&lines, root) - } - - pub fn from_strings(strs: &[&str], root: &Path) -> Result { - let mut builder = GlobSetBuilder::new(); - let mut patterns = vec![]; - - let parsed_patterns = Self::parse(strs); - for p in parsed_patterns { - let mut pat = p.pattern.clone(); - if !p.anchored && !pat.starts_with("**/") { - pat = "**/".to_string() + &pat; - } - - if !pat.ends_with("/**") { - pat += "/**"; - } - - let glob = GlobBuilder::new(&pat).literal_separator(true).build()?; - - builder.add(glob); - patterns.push(p); - } - - Ok(Self { - set: builder.build()?, - 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 { - if let Ok(stripped) = path.strip_prefix(&self.root) { - let matches = self.set.matches(stripped); - if let Some(i) = matches.iter().rev().next() { - 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: &[&str]) -> Vec { - contents - .iter() - .filter_map(|l| { - if !l.is_empty() && !l.starts_with('#') { - Some(Pattern::parse(l)) - } else { - None - } - }) - .collect() - } -} - -impl Pattern { - fn parse(pattern: &str) -> Self { - 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); - } - - Self { - pattern: normalized, - pattern_type, - anchored, - } - } -} - -impl From for Error { - fn from(error: globset::Error) -> Self { - Self::GlobSet(error) - } -} - -impl From for Error { - fn from(error: io::Error) -> Self { - Self::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(&[pattern], &base_dir()).expect("test ignore file invalid") - } - - #[test] - fn matches_exact() { - let file = build_ignore("Cargo.toml"); - - assert!(file.is_excluded(&base_dir().join("Cargo.toml"))); - } - - #[test] - fn does_not_match() { - let file = build_ignore("Cargo.toml"); - - assert!(!file.is_excluded(&base_dir().join("src").join("main.rs"))); - } - - #[test] - fn matches_simple_wildcard() { - let file = build_ignore("targ*"); - - assert!(file.is_excluded(&base_dir().join("target"))); - } - - #[test] - fn matches_subdir_exact() { - let file = build_ignore("target"); - - assert!(file.is_excluded(&base_dir().join("target/"))); - } - - #[test] - fn 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 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 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 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 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 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 empty_file_never_excludes() { - let file = IgnoreFile::from_strings(&[], &base_dir()).expect("test ignore file invalid"); - - assert!(!file.is_excluded(&base_dir().join("target"))); - } - - #[test] - fn checks_all_patterns() { - let patterns = vec!["target", "target2"]; - let file = - IgnoreFile::from_strings(&patterns, &base_dir()).expect("test ignore file invalid"); - - assert!(file.is_excluded(&base_dir().join("target").join("foo.txt"))); - assert!(file.is_excluded(&base_dir().join("target2").join("bar.txt"))); - } - - #[test] - fn handles_whitelisting() { - let patterns = vec!["target", "!target/foo.txt"]; - let file = - IgnoreFile::from_strings(&patterns, &base_dir()).expect("test ignore file invalid"); - - 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/lib/src/lib.rs b/lib/src/lib.rs index aab4cf1..4826934 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,24 +1,20 @@ -//! [Watchexec]: the library +//! Watchexec: a library for utilities and programs which respond to events; +//! file changes, human interaction, and more. //! -//! From version 1.16.0, semver applies! +//! Also see the CLI tool: https://watchexec.github.io/ //! -//! [Watchexec]: https://github.com/watchexec/watchexec +//! This library is powered by [Tokio](https://tokio.rs), minimum version 1.10. +//! +//! The main way to use this crate involves constructing a [`Handler`] and running it. +//! +//! This crate does not itself use `unsafe`. However, it depends on a number of libraries which do, +//! most because they interact with the operating system. #![doc(html_favicon_url = "https://watchexec.github.io/logo:watchexec.svg")] #![doc(html_logo_url = "https://watchexec.github.io/logo:watchexec.svg")] #![warn(clippy::unwrap_used)] +#![forbid(unsafe_code)] -pub mod config; pub mod error; -mod gitignore; -mod ignore; -mod notification_filter; -pub mod pathop; -mod paths; -pub mod run; -mod shell; -mod signal; -mod watcher; - -pub use run::{run, watch, Handler}; -pub use shell::Shell; +pub mod event; +mod fs; diff --git a/lib/src/notification_filter.rs b/lib/src/notification_filter.rs deleted file mode 100644 index fc5df66..0000000 --- a/lib/src/notification_filter.rs +++ /dev/null @@ -1,160 +0,0 @@ -use crate::error; -use crate::gitignore::Gitignore; -use crate::ignore::Ignore; -use globset::{Glob, GlobSet, GlobSetBuilder}; -use log::debug; -use std::path::Path; - -pub struct NotificationFilter { - filters: GlobSet, - filter_count: usize, - ignores: GlobSet, - gitignore_files: Gitignore, - ignore_files: Ignore, -} - -impl NotificationFilter { - pub fn new( - filters: &[String], - ignores: &[String], - gitignore_files: Gitignore, - ignore_files: Ignore, - ) -> error::Result { - let mut filter_set_builder = GlobSetBuilder::new(); - for f in filters { - filter_set_builder.add(Glob::new(f)?); - debug!("Adding filter: \"{}\"", f); - } - - let mut ignore_set_builder = GlobSetBuilder::new(); - for i in ignores { - let mut ignore_path = Path::new(i).to_path_buf(); - if ignore_path.is_relative() && !i.starts_with('*') { - ignore_path = Path::new("**").join(&ignore_path); - } - if !i.ends_with('*') { - ignore_path = ignore_path.join("**"); - } - let pattern = ignore_path - .to_str() - .expect("corrupted memory (string -> path -> string)"); - ignore_set_builder.add(Glob::new(pattern)?); - debug!("Adding ignore: \"{}\"", pattern); - } - - Ok(Self { - filters: filter_set_builder.build()?, - filter_count: filters.len(), - ignores: ignore_set_builder.build()?, - gitignore_files, - ignore_files, - }) - } - - pub fn is_excluded(&self, path: &Path) -> bool { - if self.ignores.is_match(path) { - debug!("Ignoring {:?}: matched ignore filter", path); - return true; - } - - if self.filters.is_match(path) { - return false; - } - - 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; - } - - if self.filter_count > 0 { - debug!("Ignoring {:?}: did not match any given filters", path); - } - - self.filter_count > 0 - } -} - -#[cfg(test)] -mod tests { - use super::NotificationFilter; - use crate::gitignore; - use crate::ignore; - use std::path::Path; - - #[test] - fn test_allows_everything_by_default() { - let filter = NotificationFilter::new(&[], &[], gitignore::load(&[]), ignore::load(&[])) - .expect("test filter errors"); - - assert!(!filter.is_excluded(Path::new("foo"))); - } - - #[test] - fn test_filename() { - let filter = NotificationFilter::new( - &[], - &["test.json".into()], - gitignore::load(&[]), - ignore::load(&[]), - ) - .expect("test filter errors"); - - assert!(filter.is_excluded(Path::new("/path/to/test.json"))); - assert!(filter.is_excluded(Path::new("test.json"))); - } - - #[test] - fn test_multiple_filters() { - let filters = &["*.rs".into(), "*.toml".into()]; - let filter = NotificationFilter::new(filters, &[], gitignore::load(&[]), ignore::load(&[])) - .expect("test filter errors"); - - assert!(!filter.is_excluded(Path::new("hello.rs"))); - assert!(!filter.is_excluded(Path::new("Cargo.toml"))); - assert!(filter.is_excluded(Path::new("README.md"))); - } - - #[test] - fn test_multiple_ignores() { - let ignores = &["*.rs".into(), "*.toml".into()]; - let filter = NotificationFilter::new(&[], ignores, gitignore::load(&[]), ignore::load(&[])) - .expect("test filter errors"); - - assert!(filter.is_excluded(Path::new("hello.rs"))); - assert!(filter.is_excluded(Path::new("Cargo.toml"))); - assert!(!filter.is_excluded(Path::new("README.md"))); - } - - #[test] - fn test_ignores_take_precedence() { - let ignores = &["*.rs".into(), "*.toml".into()]; - let filter = - NotificationFilter::new(ignores, ignores, gitignore::load(&[]), ignore::load(&[])) - .expect("test filter errors"); - - assert!(filter.is_excluded(Path::new("hello.rs"))); - assert!(filter.is_excluded(Path::new("Cargo.toml"))); - assert!(filter.is_excluded(Path::new("README.md"))); - } - - #[test] - fn test_recursive_directory_ignore() { - let ignores = &["target".into()]; - let filter = NotificationFilter::new(&[], ignores, gitignore::load(&[]), ignore::load(&[])) - .expect("test filter errors"); - - assert!(filter.is_excluded(Path::new("target"))); - // Make sure that sub-directories/-files are recursively ignored. - assert!(filter.is_excluded(Path::new("target/rls"))); - assert!(filter.is_excluded(Path::new("target/rls/debug"))); - // Assert that files containing subsets of the path are not ignored. - assert!(!filter.is_excluded(Path::new("target-file"))); - assert!(!filter.is_excluded(Path::new("hello.rs"))); - assert!(!filter.is_excluded(Path::new("Cargo.toml"))); - } -} diff --git a/lib/src/pathop.rs b/lib/src/pathop.rs deleted file mode 100644 index dac1ac4..0000000 --- a/lib/src/pathop.rs +++ /dev/null @@ -1,42 +0,0 @@ -use notify::op; -use std::path::{Path, PathBuf}; - -/// Info about a path and its corresponding `notify` event -#[derive(Debug, Clone, Hash, Eq, PartialEq)] -pub struct PathOp { - pub path: PathBuf, - pub op: Option, - pub cookie: Option, -} - -impl PathOp { - pub fn new(path: &Path, op: Option, cookie: Option) -> Self { - Self { - path: path.to_path_buf(), - op, - cookie, - } - } - - pub const fn is_create(op_: op::Op) -> bool { - op_.contains(op::CREATE) - } - - pub const fn is_remove(op_: op::Op) -> bool { - op_.contains(op::REMOVE) - } - - pub const fn is_rename(op_: op::Op) -> bool { - op_.contains(op::RENAME) - } - - pub fn is_write(op_: op::Op) -> bool { - let mut write_or_close_write = op::WRITE; - write_or_close_write.toggle(op::CLOSE_WRITE); - op_.intersects(write_or_close_write) - } - - pub const fn is_meta(op_: op::Op) -> bool { - op_.contains(op::CHMOD) - } -} diff --git a/lib/src/paths.rs b/lib/src/paths.rs deleted file mode 100644 index 1c62a52..0000000 --- a/lib/src/paths.rs +++ /dev/null @@ -1,289 +0,0 @@ -use crate::pathop::PathOp; -use std::{ - collections::{HashMap, HashSet}, - path::PathBuf, -}; - -/// Collect `PathOp` details into op-categories to pass onto the exec'd command as env-vars -/// -/// `WRITTEN` -> `notify::ops::WRITE`, `notify::ops::CLOSE_WRITE` -/// `META_CHANGED` -> `notify::ops::CHMOD` -/// `REMOVED` -> `notify::ops::REMOVE` -/// `CREATED` -> `notify::ops::CREATE` -/// `RENAMED` -> `notify::ops::RENAME` -pub fn collect_path_env_vars(pathops: &[PathOp]) -> Vec<(String, String)> { - #[cfg(target_family = "unix")] - const ENV_SEP: &str = ":"; - #[cfg(not(target_family = "unix"))] - const ENV_SEP: &str = ";"; - - let mut by_op = HashMap::new(); // Paths as `String`s collected by `notify::op` - let mut all_pathbufs = HashSet::new(); // All unique `PathBuf`s - for pathop in pathops { - if let Some(op) = pathop.op { - // ignore pathops that don't have a `notify::op` set - if let Some(s) = pathop.path.to_str() { - // ignore invalid utf8 paths - all_pathbufs.insert(pathop.path.clone()); - let e = by_op.entry(op).or_insert_with(Vec::new); - e.push(s.to_owned()); - } - } - } - - let mut vars = Vec::new(); - // Only break off a common path if we have more than one unique path, - // otherwise we end up with a `COMMON_PATH` being set and other vars - // being present but empty. - let common_path = if all_pathbufs.len() > 1 { - let all_pathbufs: Vec = all_pathbufs.into_iter().collect(); - get_longest_common_path(&all_pathbufs) - } else { - None - }; - if let Some(ref common_path) = common_path { - vars.push(("WATCHEXEC_COMMON_PATH".to_string(), common_path.to_string())); - } - for (op, paths) in by_op { - let key = match op { - op if PathOp::is_create(op) => "WATCHEXEC_CREATED_PATH", - op if PathOp::is_remove(op) => "WATCHEXEC_REMOVED_PATH", - op if PathOp::is_rename(op) => "WATCHEXEC_RENAMED_PATH", - op if PathOp::is_write(op) => "WATCHEXEC_WRITTEN_PATH", - op if PathOp::is_meta(op) => "WATCHEXEC_META_CHANGED_PATH", - _ => continue, // ignore `notify::op::RESCAN`s - }; - - let paths = if let Some(ref common_path) = common_path { - paths - .iter() - .map(|path_str| path_str.trim_start_matches(common_path).to_string()) - .collect::>() - } else { - paths - }; - vars.push((key.to_string(), paths.as_slice().join(ENV_SEP))); - } - vars -} - -pub fn get_longest_common_path(paths: &[PathBuf]) -> Option { - match paths.len() { - 0 => return None, - 1 => return paths[0].to_str().map(ToString::to_string), - _ => {} - }; - - let mut longest_path: Vec<_> = paths[0].components().collect(); - - for path in &paths[1..] { - let mut greatest_distance = 0; - for component_pair in path.components().zip(longest_path.iter()) { - if component_pair.0 != *component_pair.1 { - break; - } - - greatest_distance += 1; - } - - if greatest_distance != longest_path.len() { - longest_path.truncate(greatest_distance); - } - } - - let mut result = PathBuf::new(); - for component in longest_path { - result.push(component.as_os_str()); - } - - result.to_str().map(ToString::to_string) -} - -#[cfg(test)] -mod tests { - use crate::pathop::PathOp; - use std::collections::HashSet; - use std::path::PathBuf; - - use super::collect_path_env_vars; - use super::get_longest_common_path; - - #[test] - #[cfg(unix)] - fn longest_common_path_single_unix() { - assert_eq!( - get_longest_common_path(&[PathBuf::from("/tmp/random/")]) - .expect("failed to get longest common path"), - "/tmp/random/" - ); - } - - #[test] - #[cfg(unix)] - fn longest_common_path_similar_unix() { - assert_eq!( - get_longest_common_path(&[ - PathBuf::from("/tmp/logs/hi"), - PathBuf::from("/tmp/logs/bye"), - PathBuf::from("/tmp/logs/bye"), - PathBuf::from("/tmp/logs/fly"), - ]) - .expect("failed to get longest common path"), - "/tmp/logs" - ); - } - - #[test] - #[cfg(unix)] - fn longest_common_path_divergent_unix() { - assert_eq!( - get_longest_common_path(&[ - PathBuf::from("/tmp/logs/hi"), - PathBuf::from("/var/logs/hi") - ]) - .expect("failed to get longest common path"), - "/" - ); - } - - #[test] - #[cfg(unix)] - fn longest_common_path_uneven_unix() { - assert_eq!( - get_longest_common_path(&[ - PathBuf::from("/tmp/logs/hi"), - PathBuf::from("/tmp/logs/"), - PathBuf::from("/tmp/logs/bye"), - ]) - .expect("failed to get longest common path"), - "/tmp/logs" - ); - } - - #[test] - #[cfg(windows)] - fn longest_common_path_single_windows() { - assert_eq!( - get_longest_common_path(&[PathBuf::from(r"C:\Temp\Random\")]) - .expect("failed to get longest common path"), - r"C:\Temp\Random\" - ); - } - - #[test] - #[cfg(windows)] - fn longest_common_path_similar_windows() { - assert_eq!( - get_longest_common_path(&[ - PathBuf::from(r"C:\Temp\Logs\hi"), - PathBuf::from(r"C:\Temp\Logs\bye"), - PathBuf::from(r"C:\Temp\Logs\bye"), - PathBuf::from(r"C:\Temp\Logs\fly"), - ]) - .expect("failed to get longest common path"), - r"C:\Temp\Logs" - ); - } - - #[test] - #[cfg(windows)] - fn longest_common_path_divergent_windows() { - assert_eq!( - get_longest_common_path(&[ - PathBuf::from(r"C:\Temp\Logs\hi"), - PathBuf::from(r"C:\Perm\Logs\hi") - ]) - .expect("failed to get longest common path"), - r"C:\" - ); - } - - #[test] - #[cfg(windows)] - fn longest_common_path_uneven_windows() { - assert_eq!( - get_longest_common_path(&[ - PathBuf::from(r"C:\Temp\Logs\hi"), - PathBuf::from(r"C:\Temp\Logs\"), - PathBuf::from(r"C:\Temp\Logs\bye"), - ]) - .expect("failed to get longest common path"), - r"C:\Temp\Logs" - ); - } - - #[test] - #[cfg(unix)] - fn pathops_collect_to_env_vars_unix() { - assert_eq!( - collect_path_env_vars(&[ - PathOp::new( - &PathBuf::from("/tmp/logs/hi"), - Some(notify::op::CREATE), - None, - ), - PathOp::new( - &PathBuf::from("/tmp/logs/hey/there"), - Some(notify::op::CREATE), - None, - ), - PathOp::new( - &PathBuf::from("/tmp/logs/bye"), - Some(notify::op::REMOVE), - None, - ), - ]) - .into_iter() - .collect::>(), - vec![ - ("WATCHEXEC_COMMON_PATH".to_string(), "/tmp/logs".to_string()), - ("WATCHEXEC_REMOVED_PATH".to_string(), "/bye".to_string()), - ( - "WATCHEXEC_CREATED_PATH".to_string(), - "/hi:/hey/there".to_string(), - ), - ] - .into_iter() - .collect::>() - ); - } - - #[test] - #[cfg(windows)] - fn pathops_collect_to_env_vars_windows() { - assert_eq!( - collect_path_env_vars(&[ - PathOp::new( - &PathBuf::from(r"C:\Temp\Logs\hi"), - Some(notify::op::CREATE), - None, - ), - PathOp::new( - &PathBuf::from(r"C:\Temp\Logs\hey\there"), - Some(notify::op::CREATE), - None, - ), - PathOp::new( - &PathBuf::from(r"C:\Temp\Logs\bye"), - Some(notify::op::REMOVE), - None, - ), - ]) - .into_iter() - .collect::>(), - vec![ - ( - "WATCHEXEC_COMMON_PATH".to_string(), - r"C:\Temp\Logs".to_string() - ), - ("WATCHEXEC_REMOVED_PATH".to_string(), r"\bye".to_string()), - ( - "WATCHEXEC_CREATED_PATH".to_string(), - r"\hi;\hey\there".to_string(), - ), - ] - .into_iter() - .collect::>() - ); - } -} diff --git a/lib/src/run.rs b/lib/src/run.rs deleted file mode 100644 index 253b89c..0000000 --- a/lib/src/run.rs +++ /dev/null @@ -1,437 +0,0 @@ -#[cfg(unix)] -use command_group::UnixChildExt; -use command_group::{CommandGroup, GroupChild}; -use log::{debug, info, warn}; - -use std::{ - collections::HashMap, - fs::canonicalize, - process::Child, - sync::{ - mpsc::{channel, Receiver}, - Arc, Mutex, - }, - time::Duration, -}; - -use crate::config::Config; -use crate::error::{Error, Result}; -use crate::gitignore; -use crate::ignore; -use crate::notification_filter::NotificationFilter; -use crate::pathop::PathOp; -use crate::signal::{self, Signal}; -use crate::watcher::{Event, Watcher}; - -/// Behaviour to use when handling updates while the command is running. -#[derive(Clone, Copy, Debug)] -pub enum OnBusyUpdate { - /// ignore updates while busy - DoNothing, - - /// wait for the command to exit, then start a new one - Queue, - - /// restart the command immediately - Restart, - - /// send a signal only - Signal, -} - -impl Default for OnBusyUpdate { - fn default() -> Self { - Self::DoNothing - } -} - -pub trait Handler { - /// Called through a manual request, such as an initial run. - /// - /// # Returns - /// - /// A `Result` which means: - /// - /// - `Err`: an error has occurred while processing, quit. - /// - `Ok(true)`: everything is fine and the loop can continue. - /// - `Ok(false)`: everything is fine but we should gracefully stop. - fn on_manual(&self) -> Result; - - /// Called through a file-update request. - /// - /// # Parameters - /// - /// - `ops`: The list of events that triggered this update. - /// - /// # Returns - /// - /// A `Result` which means: - /// - /// - `Err`: an error has occurred while processing, quit. - /// - `Ok(true)`: everything is fine and the loop can continue. - /// - `Ok(false)`: everything is fine but we should gracefully stop. - fn on_update(&self, ops: &[PathOp]) -> Result; - - /// Called once by `watch` at the very start. - /// - /// Not called again; any changes will never be picked up. - /// - /// The `Config` instance should be created using `ConfigBuilder` rather than direct initialisation - /// to resist potential breaking changes (see semver policy on crate root). - fn args(&self) -> Config; -} - -/// Starts watching, and calls a handler when something happens. -/// -/// Given an argument structure and a `Handler` type, starts the watcher loop, blocking until done. -pub fn watch(handler: &H) -> Result<()> -where - H: Handler, -{ - let args = handler.args(); - - let mut paths = vec![]; - for path in &args.paths { - paths.push( - canonicalize(&path) - .map_err(|e| Error::Canonicalization(path.to_string_lossy().into_owned(), e))?, - ); - } - - 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(); - - #[cfg_attr(not(target_os = "linux"), allow(clippy::redundant_clone, unused_mut))] - let mut maybe_watcher = Watcher::new(tx.clone(), &paths, args.poll, args.poll_interval); - - #[cfg(target_os = "linux")] - if !args.poll { - if let Err(notify::Error::Io(ref e)) = maybe_watcher { - if e.raw_os_error() == Some(nix::libc::ENOSPC) { - warn!("System notification limit is too small, falling back to polling mode. For better performance increase system limit:\n\tsysctl fs.inotify.max_user_watches=524288"); - maybe_watcher = Watcher::new(tx, &paths, true, args.poll_interval); - } - } - } - - let watcher = maybe_watcher?; - if watcher.is_polling() { - warn!("Polling for changes every {:?}", args.poll_interval); - } - - // Call handler initially, if necessary - if args.run_initially && !handler.on_manual()? { - return Ok(()); - } - - loop { - debug!("Waiting for filesystem activity"); - let paths = wait_fs(&rx, &filter, args.debounce, args.no_meta); - info!("Paths updated: {:?}", paths); - - if !handler.on_update(&paths)? { - break; - } - } - - Ok(()) -} - -#[derive(Debug)] -pub enum ChildProcess { - None, - Grouped(GroupChild), - Ungrouped(Child), -} - -impl Default for ChildProcess { - fn default() -> Self { - ChildProcess::None - } -} - -impl ChildProcess { - #[cfg(unix)] - fn signal(&mut self, sig: Signal) -> Result<()> { - match self { - Self::None => Ok(()), - Self::Grouped(c) => { - debug!("Sending signal {} to process group id={}", sig, c.id()); - c.signal(sig) - } - Self::Ungrouped(c) => { - debug!("Sending signal {} to process id={}", sig, c.id()); - c.signal(sig) - } - } - .map_err(|e| e.into()) - } - - fn kill(&mut self) -> Result<()> { - match self { - Self::None => Ok(()), - Self::Grouped(c) => { - debug!("Killing process group id={}", c.id()); - c.kill() - } - Self::Ungrouped(c) => { - debug!("Killing process id={}", c.id()); - c.kill() - } - } - .map_err(|e| e.into()) - } - - fn is_running(&mut self) -> Result { - match self { - Self::None => Ok(false), - Self::Grouped(c) => c.try_wait().map(|w| w.is_none()), - Self::Ungrouped(c) => c.try_wait().map(|w| w.is_none()), - } - .map_err(|e| e.into()) - } - - fn wait(&mut self) -> Result<()> { - match self { - Self::None => Ok(()), - Self::Grouped(c) => c.wait().map(drop), - Self::Ungrouped(c) => c.wait().map(drop), - } - .map_err(|e| e.into()) - } -} - -pub struct ExecHandler { - args: Config, - signal: Option, - child_process: Arc>, -} - -impl ExecHandler { - pub fn new(args: Config) -> Result { - let child_process: Arc> = Arc::default(); - let weak_child = Arc::downgrade(&child_process); - - // Convert signal string to the corresponding integer - let signal = signal::new(args.signal.clone()); - - signal::install_handler(move |sig: Signal| { - if let Some(lock) = weak_child.upgrade() { - let mut child = lock.lock().expect("poisoned lock in install_handler"); - match sig { - Signal::SIGCHLD => { - child.is_running().ok(); - } - _ => { - #[cfg(unix)] - child.signal(sig).unwrap_or_else(|err| { - warn!("Could not pass on signal to command: {}", err) - }); - - #[cfg(not(unix))] - child.kill().unwrap_or_else(|err| { - warn!("Could not pass on termination to command: {}", err) - }); - } - } - } - }); - - Ok(Self { - args, - signal, - child_process, - }) - } - - fn spawn(&self, ops: &[PathOp]) -> Result<()> { - if self.args.clear_screen { - clearscreen::clear()?; - } - - let mut child = self.child_process.lock()?; - child.kill().ok(); - - let mut command = self.args.shell.to_command(&self.args.cmd); - debug!("Assembled command: {:?}", command); - - if !self.args.no_environment { - for (name, val) in crate::paths::collect_path_env_vars(ops) { - debug!("Command environment: {}={:?}", name, val); - command.env(name, val); - } - } - - debug!("Launching command"); - *child = if self.args.use_process_group { - ChildProcess::Grouped(command.group_spawn()?) - } else { - ChildProcess::Ungrouped(command.spawn()?) - }; - - Ok(()) - } - - pub fn has_running_process(&self) -> Result { - self.child_process - .lock() - .expect("poisoned lock in has_running_process") - .is_running() - } -} - -impl Handler for ExecHandler { - fn args(&self) -> Config { - self.args.clone() - } - - // Only returns Err() on lock poisoning. - fn on_manual(&self) -> Result { - if self.args.once { - return Ok(true); - } - - self.spawn(&[])?; - Ok(true) - } - - fn on_update(&self, ops: &[PathOp]) -> Result { - log::debug!("ON UPDATE: called"); - - let signal = self.signal.unwrap_or(Signal::SIGTERM); - let has_running_processes = self.has_running_process()?; - - log::debug!( - "ON UPDATE: has_running_processes: {} --- on_busy_update: {:?}", - has_running_processes, - self.args.on_busy_update - ); - - match (has_running_processes, self.args.on_busy_update) { - // If nothing is running, start the command - (false, _) => { - self.spawn(ops)?; - } - - // Just send a signal to the command, do nothing more - (true, OnBusyUpdate::Signal) => signal_process(&self.child_process, signal)?, - - // Send a signal to the command, wait for it to exit, then run the command again - (true, OnBusyUpdate::Restart) => { - signal_process(&self.child_process, signal)?; - wait_on_process(&self.child_process)?; - self.spawn(ops)?; - } - - // Wait for the command to end, then run it again - (true, OnBusyUpdate::Queue) => { - wait_on_process(&self.child_process)?; - self.spawn(ops)?; - } - - (true, OnBusyUpdate::DoNothing) => {} - } - - // Handle once option for integration testing - if self.args.once { - if let Some(signal) = self.signal { - signal_process(&self.child_process, signal)?; - } - - wait_on_process(&self.child_process)?; - - return Ok(false); - } - - Ok(true) - } -} - -pub fn run(args: Config) -> Result<()> { - watch(&ExecHandler::new(args)?) -} - -fn wait_fs( - rx: &Receiver, - filter: &NotificationFilter, - debounce: Duration, - no_meta: bool, -) -> Vec { - let mut paths = Vec::new(); - let mut cache = HashMap::new(); - - loop { - let e = rx.recv().expect("error when reading event"); - - if let Some(ref path) = e.path { - let pathop = PathOp::new(path, e.op.ok(), e.cookie); - if let Some(op) = pathop.op { - if no_meta && PathOp::is_meta(op) { - continue; - } - } - - // Ignore cache for the initial file. Otherwise, in - // debug mode it's hard to track what's going on - let excluded = filter.is_excluded(path); - if !cache.contains_key(&pathop) { - cache.insert(pathop.clone(), excluded); - } - - if !excluded { - paths.push(pathop); - break; - } - } - } - - // Wait for filesystem activity to cool off - while let Ok(e) = rx.recv_timeout(debounce) { - if let Some(ref path) = e.path { - let pathop = PathOp::new(path, e.op.ok(), e.cookie); - if cache.contains_key(&pathop) { - continue; - } - - let excluded = filter.is_excluded(path); - - cache.insert(pathop.clone(), excluded); - - if !excluded { - paths.push(pathop); - } - } - } - - paths -} - -fn signal_process(process: &Mutex, signal: Signal) -> Result<()> { - let mut child = process.lock().expect("poisoned lock in signal_process"); - - #[cfg(unix)] - child.signal(signal)?; - - #[cfg(not(unix))] - if matches!(signal, Signal::SIGTERM | Signal::SIGKILL) { - child.kill()?; - } else { - debug!("Ignoring signal to send to process"); - } - - Ok(()) -} - -fn wait_on_process(process: &Mutex) -> Result<()> { - process - .lock() - .expect("poisoned lock in wait_on_process") - .wait() -} diff --git a/lib/src/shell.rs b/lib/src/shell.rs deleted file mode 100644 index dbdaa3e..0000000 --- a/lib/src/shell.rs +++ /dev/null @@ -1,197 +0,0 @@ -use std::process::Command; - -/// Shell to use to run commands. -/// -/// `Cmd` and `Powershell` are special-cased because they have different calling -/// conventions. Also `Cmd` is only available in Windows, while `Powershell` is -/// also available on unices (provided the end-user has it installed, of course). -/// -/// See [`Config.cmd`][crate::config::Config] for the semantics of `None` vs the -/// other options. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Shell { - /// Use no shell, and execute the command directly. - None, - - /// Use the given string as a unix shell invocation. - /// - /// This means two things: - /// - the program is invoked with `-c` followed by the command, and - /// - the string will be split on space, and the resulting vec used as - /// execvp(3) arguments: first is the shell program, rest are additional - /// arguments (which come before the `-c` mentioned above). This is a very - /// simplistic approach deliberately: it will not support quoted - /// arguments, for example. Use [`Shell::None`] with a custom command vec - /// if you want that. - Unix(String), - - /// Use the Windows CMD.EXE shell. - /// - /// This is invoked with `/C` followed by the command. - #[cfg(windows)] - Cmd, - - /// Use Powershell, on Windows or elsewhere. - /// - /// This is invoked with `-Command` followed by the command. - /// - /// This is preferred over `Unix("pwsh")`, though that will also work - /// on unices due to Powershell supporting the `-c` short option. - Powershell, -} - -impl Default for Shell { - #[cfg(windows)] - fn default() -> Self { - Self::Powershell - } - - #[cfg(not(windows))] - fn default() -> Self { - Self::Unix("sh".into()) - } -} - -impl Shell { - /// Obtain a [`Command`] given the cmd vec from [`Config`][crate::config::Config]. - /// - /// Behaves as described in the enum documentation. - /// - /// # Panics - /// - /// - Panics if `cmd` is empty. - /// - Panics if the string in the `Unix` variant is empty or only whitespace. - pub fn to_command(&self, cmd: &[String]) -> Command { - assert!(!cmd.is_empty(), "cmd was empty"); - - match self { - Shell::None => { - // UNWRAP: checked by assert - #[allow(clippy::unwrap_used)] - let (first, rest) = cmd.split_first().unwrap(); - let mut c = Command::new(first); - c.args(rest); - c - } - - #[cfg(windows)] - Shell::Cmd => { - let mut c = Command::new("cmd.exe"); - c.arg("/C").arg(cmd.join(" ")); - c - } - - Shell::Powershell if cfg!(windows) => { - let mut c = Command::new("powershell.exe"); - c.arg("-Command").arg(cmd.join(" ")); - c - } - - Shell::Powershell => { - let mut c = Command::new("pwsh"); - c.arg("-Command").arg(cmd.join(" ")); - c - } - - Shell::Unix(name) => { - assert!(!name.is_empty(), "shell program was empty"); - let sh = name.split_ascii_whitespace().collect::>(); - - // UNWRAP: checked by assert - #[allow(clippy::unwrap_used)] - let (shprog, shopts) = sh.split_first().unwrap(); - - let mut c = Command::new(shprog); - c.args(shopts); - c.arg("-c").arg(cmd.join(" ")); - c - } - } - } -} - -#[cfg(test)] -mod test { - use super::Shell; - use command_group::CommandGroup; - - #[test] - #[cfg(unix)] - fn unix_shell_default() -> Result<(), std::io::Error> { - assert!(Shell::default() - .to_command(&["echo".into(), "hi".into()]) - .group_status()? - .success()); - Ok(()) - } - - #[test] - #[cfg(unix)] - fn unix_shell_none() -> Result<(), std::io::Error> { - assert!(Shell::None - .to_command(&["echo".into(), "hi".into()]) - .group_status()? - .success()); - Ok(()) - } - - #[test] - #[cfg(unix)] - fn unix_shell_alternate() -> Result<(), std::io::Error> { - assert!(Shell::Unix("bash".into()) - .to_command(&["echo".into(), "hi".into()]) - .group_status()? - .success()); - Ok(()) - } - - #[test] - #[cfg(unix)] - fn unix_shell_alternate_shopts() -> Result<(), std::io::Error> { - assert!(Shell::Unix("bash -o errexit".into()) - .to_command(&["echo".into(), "hi".into()]) - .group_status()? - .success()); - Ok(()) - } - - #[test] - #[cfg(windows)] - fn windows_shell_default() -> Result<(), std::io::Error> { - assert!(Shell::default() - .to_command(&["echo".into(), "hi".into()]) - .group_status()? - .success()); - Ok(()) - } - - #[test] - #[cfg(windows)] - fn windows_shell_cmd() -> Result<(), std::io::Error> { - assert!(Shell::Cmd - .to_command(&["echo".into(), "hi".into()]) - .group_status()? - .success()); - Ok(()) - } - - #[test] - #[cfg(windows)] - fn windows_shell_powershell() -> Result<(), std::io::Error> { - assert!(Shell::Powershell - .to_command(&["echo".into(), "hi".into()]) - .group_status()? - .success()); - Ok(()) - } - - #[test] - #[cfg(windows)] - fn windows_shell_unix_style_powershell() -> Result<(), std::io::Error> { - assert!(Shell::Unix("powershell.exe".into()) - .to_command(&["echo".into(), "hi".into()]) - .group_status()? - .success()); - Ok(()) - } -} diff --git a/lib/src/signal.rs b/lib/src/signal.rs deleted file mode 100644 index 6b9bae9..0000000 --- a/lib/src/signal.rs +++ /dev/null @@ -1,207 +0,0 @@ -use std::sync::Mutex; - -type CleanupFn = Box; -lazy_static::lazy_static! { - static ref CLEANUP: Mutex> = Mutex::new(None); -} - -// Indicate interest in SIGCHLD by setting a dummy handler -#[cfg(unix)] -#[allow(clippy::missing_const_for_fn)] -pub extern "C" fn sigchld_handler(_: c_int) {} - -#[cfg(unix)] -pub use nix::sys::signal::Signal; - -// This is a dummy enum for Windows -#[cfg(windows)] -#[allow(clippy::upper_case_acronyms)] -#[derive(Debug, Copy, Clone)] -pub enum Signal { - SIGKILL, - SIGTERM, - SIGINT, - SIGHUP, - SIGSTOP, - SIGCONT, - SIGCHLD, - SIGUSR1, - SIGUSR2, -} - -#[cfg(windows)] -use std::fmt; - -#[cfg(windows)] -impl fmt::Display for Signal { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "{}", - match self { - Self::SIGKILL => "SIGKILL", - Self::SIGTERM => "SIGTERM", - Self::SIGINT => "SIGINT", - Self::SIGHUP => "SIGHUP", - Self::SIGSTOP => "SIGSTOP", - Self::SIGCONT => "SIGCONT", - Self::SIGCHLD => "SIGCHLD", - Self::SIGUSR1 => "SIGUSR1", - Self::SIGUSR2 => "SIGUSR2", - } - ) - } -} - -#[cfg(unix)] -use nix::libc::*; - -#[cfg(unix)] -pub trait ConvertToLibc { - fn convert_to_libc(self) -> c_int; -} - -#[cfg(unix)] -impl ConvertToLibc for Signal { - fn convert_to_libc(self) -> c_int { - // Convert from signal::Signal enum to libc::* c_int constants - match self { - Self::SIGKILL => SIGKILL, - Self::SIGTERM => SIGTERM, - Self::SIGINT => SIGINT, - Self::SIGHUP => SIGHUP, - Self::SIGSTOP => SIGSTOP, - Self::SIGCONT => SIGCONT, - Self::SIGCHLD => SIGCHLD, - Self::SIGUSR1 => SIGUSR1, - Self::SIGUSR2 => SIGUSR2, - _ => panic!("unsupported signal: {:?}", self), - } - } -} - -pub fn new(signal_name: Option) -> Option { - if let Some(signame) = signal_name { - let signal = match signame.as_ref() { - "SIGKILL" | "KILL" => Signal::SIGKILL, - "SIGTERM" | "TERM" => Signal::SIGTERM, - "SIGINT" | "INT" => Signal::SIGINT, - "SIGHUP" | "HUP" => Signal::SIGHUP, - "SIGSTOP" | "STOP" => Signal::SIGSTOP, - "SIGCONT" | "CONT" => Signal::SIGCONT, - "SIGCHLD" | "CHLD" => Signal::SIGCHLD, - "SIGUSR1" | "USR1" => Signal::SIGUSR1, - "SIGUSR2" | "USR2" => Signal::SIGUSR2, - _ => panic!("unsupported signal: {}", signame), - }; - - Some(signal) - } else { - None - } -} - -#[cfg(unix)] -pub fn install_handler(handler: F) -where - F: Fn(self::Signal) + 'static + Send + Sync, -{ - use log::debug; - use nix::sys::signal::*; - use std::thread; - - // Mask all signals interesting to us. The mask propagates - // to all threads started after this point. - let mut mask = SigSet::empty(); - mask.add(SIGKILL); - mask.add(SIGTERM); - mask.add(SIGINT); - mask.add(SIGHUP); - mask.add(SIGSTOP); - mask.add(SIGCONT); - mask.add(SIGCHLD); - mask.add(SIGUSR1); - mask.add(SIGUSR2); - mask.thread_set_mask().expect("unable to set signal mask"); - - set_handler(handler); - - #[allow(unsafe_code)] - unsafe { - let _ = sigaction( - SIGCHLD, - &SigAction::new( - SigHandler::Handler(sigchld_handler), - SaFlags::empty(), - SigSet::empty(), - ), - ); - } - - // Spawn a thread to catch these signals - thread::spawn(move || { - loop { - let signal = mask.wait().expect("Unable to sigwait"); - debug!("Received {:?}", signal); - - // Invoke closure - invoke(signal); - - // Restore default behavior for received signal and unmask it - if signal != SIGCHLD { - let default_action = - SigAction::new(SigHandler::SigDfl, SaFlags::empty(), SigSet::empty()); - - #[allow(unsafe_code)] - unsafe { - let _ = sigaction(signal, &default_action); - } - } - - let mut new_mask = SigSet::empty(); - new_mask.add(signal); - - // Re-raise with signal unmasked - let _ = new_mask.thread_unblock(); - let _ = raise(signal); - let _ = new_mask.thread_block(); - } - }); -} - -#[cfg(windows)] -#[allow(unsafe_code)] -pub fn install_handler(handler: F) -where - F: Fn(self::Signal) + 'static + Send + Sync, -{ - use winapi::shared::minwindef::{BOOL, DWORD, FALSE, TRUE}; - use winapi::um::consoleapi::SetConsoleCtrlHandler; - - pub unsafe extern "system" fn ctrl_handler(_: DWORD) -> BOOL { - invoke(self::Signal::SIGTERM); - - FALSE - } - - set_handler(handler); - - unsafe { - SetConsoleCtrlHandler(Some(ctrl_handler), TRUE); - } -} - -fn invoke(sig: self::Signal) { - if let Some(ref handler) = *CLEANUP.lock().expect("poisoned lock in signal::invoke") { - handler(sig) - } -} - -fn set_handler(handler: F) -where - F: Fn(self::Signal) + 'static + Send + Sync, -{ - *CLEANUP - .lock() - .expect("poisoned lock in signal::set_handler") = Some(Box::new(handler)); -} diff --git a/lib/src/watcher.rs b/lib/src/watcher.rs deleted file mode 100644 index 350dd71..0000000 --- a/lib/src/watcher.rs +++ /dev/null @@ -1,62 +0,0 @@ -use log::debug; -use notify::{raw_watcher, PollWatcher, RecommendedWatcher, RecursiveMode}; -use std::convert::TryFrom; -use std::path::PathBuf; -use std::sync::mpsc::Sender; -use std::time::Duration; - -/// Thin wrapper over the notify crate -/// -/// `PollWatcher` and `RecommendedWatcher` are distinct types, but watchexec -/// really just wants to handle them without regard to the exact type -/// (e.g. polymorphically). This has the nice side effect of separating out -/// all coupling to the notify crate into this module. -pub struct Watcher { - watcher_impl: WatcherImpl, -} - -pub use notify::Error; -pub use notify::RawEvent as Event; - -enum WatcherImpl { - Recommended(RecommendedWatcher), - Poll(PollWatcher), -} - -impl Watcher { - pub fn new( - tx: Sender, - paths: &[PathBuf], - poll: bool, - interval: Duration, - ) -> Result { - use notify::Watcher; - - let imp = if poll { - let mut watcher = PollWatcher::with_delay_ms( - tx, - u32::try_from(interval.as_millis()).unwrap_or(u32::MAX), - )?; - for path in paths { - watcher.watch(path, RecursiveMode::Recursive)?; - debug!("Watching {:?}", path); - } - - WatcherImpl::Poll(watcher) - } else { - let mut watcher = raw_watcher(tx)?; - for path in paths { - watcher.watch(path, RecursiveMode::Recursive)?; - debug!("Watching {:?}", path); - } - - WatcherImpl::Recommended(watcher) - }; - - Ok(Self { watcher_impl: imp }) - } - - pub fn is_polling(&self) -> bool { - matches!(self.watcher_impl, WatcherImpl::Poll(_)) - } -}