1#![doc = include_str!("../README.md")]
2#![deny(missing_docs)]
3
4use anyhow::{Context, Result};
5use clap::Parser;
6use glob::Pattern;
7use lazy_static::lazy_static;
8use notify::Watcher as _;
9use std::{
10 env, io,
11 path::{Path, PathBuf},
12 process::{Child, Command, ExitStatus},
13 sync::{Arc, Mutex, RwLock, RwLockReadGuard, RwLockWriteGuard, mpsc},
14 thread,
15 time::{Duration, Instant},
16};
17
18pub use anyhow;
19pub use cargo_metadata;
20pub use cargo_metadata::camino;
21pub use clap;
22
23pub fn metadata() -> &'static cargo_metadata::Metadata {
25 lazy_static! {
26 static ref METADATA: cargo_metadata::Metadata = cargo_metadata::MetadataCommand::new()
27 .exec()
28 .expect("cannot get crate's metadata");
29 }
30
31 &METADATA
32}
33
34pub fn package(name: &str) -> Option<&cargo_metadata::Package> {
36 metadata().packages.iter().find(|x| x.name == name)
37}
38
39pub fn xtask_command() -> Command {
41 Command::new(env::args_os().next().unwrap())
42}
43
44#[non_exhaustive]
50#[derive(Clone, Debug, Default, Parser)]
51#[clap(about = "Watches over your project's source code.")]
52pub struct Watch {
53 #[clap(long = "shell", short = 's')]
55 pub shell_commands: Vec<String>,
56 #[clap(long = "exec", short = 'x')]
60 pub cargo_commands: Vec<String>,
61 #[clap(long = "watch", short = 'w')]
65 pub watch_paths: Vec<PathBuf>,
66 #[clap(long = "ignore", short = 'i')]
70 pub exclude_paths: Vec<PathBuf>,
71 #[clap(skip)]
73 pub workspace_exclude_paths: Vec<PathBuf>,
74 #[clap(skip = Duration::from_secs(2))]
79 pub debounce: Duration,
80 #[clap(skip)]
81 exclude_globs: Vec<Pattern>,
82 #[clap(skip)]
83 workspace_exclude_globs: Vec<Pattern>,
84 #[clap(skip)]
85 watch_lock: WatchLock,
86}
87
88impl Watch {
89 pub fn watch_path(mut self, path: impl AsRef<Path>) -> Self {
91 self.watch_paths.push(path.as_ref().to_path_buf());
92 self
93 }
94
95 pub fn watch_paths(mut self, paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
97 for path in paths {
98 self.watch_paths.push(path.as_ref().to_path_buf())
99 }
100 self
101 }
102
103 pub fn exclude_path(mut self, path: impl AsRef<Path>) -> Self {
105 self.exclude_paths.push(path.as_ref().to_path_buf());
106 self
107 }
108
109 pub fn exclude_paths(mut self, paths: impl IntoIterator<Item = impl AsRef<Path>>) -> Self {
111 for path in paths {
112 self.exclude_paths.push(path.as_ref().to_path_buf());
113 }
114 self
115 }
116
117 pub fn exclude_workspace_path(mut self, path: impl AsRef<Path>) -> Self {
120 self.workspace_exclude_paths
121 .push(path.as_ref().to_path_buf());
122 self
123 }
124
125 pub fn exclude_workspace_paths(
128 mut self,
129 paths: impl IntoIterator<Item = impl AsRef<Path>>,
130 ) -> Self {
131 for path in paths {
132 self.workspace_exclude_paths
133 .push(path.as_ref().to_path_buf());
134 }
135 self
136 }
137
138 #[must_use = "store and share the lock with readers that must coordinate with rebuilds"]
152 pub fn lock(&self) -> WatchLock {
153 self.watch_lock.clone()
154 }
155
156 pub fn debounce(mut self, duration: Duration) -> Self {
158 self.debounce = duration;
159 self
160 }
161
162 pub fn run(mut self, commands: impl Into<CommandList>) -> Result<()> {
167 let metadata = metadata();
168 let list = commands.into();
169
170 {
171 let mut commands = list
172 .commands
173 .lock()
174 .expect("no panic-prone code runs while this lock is held");
175
176 commands.extend(self.shell_commands.iter().map(|x| {
177 let mut command =
178 Command::new(env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()));
179 command.arg("-c");
180 command.arg(x);
181
182 command
183 }));
184
185 commands.extend(self.cargo_commands.iter().map(|x| {
186 let mut command =
187 Command::new(env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()));
188 command.arg("-c");
189 command.arg(format!("cargo {x}"));
190
191 command
192 }));
193 }
194
195 self.prepare_excludes()?;
196
197 if self.watch_paths.is_empty() {
198 self.watch_paths
199 .push(metadata.workspace_root.clone().into_std_path_buf());
200 }
201
202 self.watch_paths = self
203 .watch_paths
204 .into_iter()
205 .map(|x| {
206 x.canonicalize()
207 .with_context(|| format!("can't find {}", x.display()))
208 })
209 .collect::<Result<Vec<_>, _>>()?;
210
211 let (tx, rx) = mpsc::channel();
212
213 let handler = WatchEventHandler {
214 watch: self.clone(),
215 tx: tx.clone(),
216 command_start: Instant::now(),
217 };
218
219 let mut watcher =
220 notify::recommended_watcher(handler).context("could not initialize watcher")?;
221
222 for path in &self.watch_paths {
223 match watcher.watch(path, notify::RecursiveMode::Recursive) {
224 Ok(()) => log::trace!("Watching {}", path.display()),
225 Err(err) => log::error!("cannot watch {}: {err}", path.display()),
226 }
227 }
228
229 let mut current_child = SharedChild::new();
230 let mut lock_guard = Some(self.watch_lock.write());
231 let mut generation: u64 = 0;
232 loop {
233 if lock_guard.is_some() {
234 log::info!("Running command");
235 let mut current_child = current_child.clone();
236 let mut list = list.clone();
237 let tx = tx.clone();
238 let build_id = generation;
239 thread::spawn(move || {
240 let mut status = ExitStatus::default();
241
242 list.spawn(|res| match res {
243 Err(err) => {
244 log::error!("Could not execute command: {err}");
245 false
246 }
247 Ok(child) => {
248 log::trace!("Child spawned PID: {}", child.id());
249 current_child.replace(child);
250 status = current_child.wait();
251 status.success()
252 }
253 });
254
255 if status.success() {
256 log::info!("Command succeeded.");
257 tx.send(Event::CommandSucceeded(build_id))
258 .expect("can send");
259 } else if let Some(code) = status.code() {
260 log::error!("Command failed (exit code: {code})");
261 } else {
262 log::error!("Command failed.");
263 }
264 });
265 }
266
267 match rx.recv() {
268 Ok(Event::ChangeDetected) => {
269 log::trace!("Changes detected, re-generating");
270 if lock_guard.is_none() {
271 lock_guard = Some(self.watch_lock.write());
272 }
273 generation += 1;
274 current_child.terminate();
275 }
276 Ok(Event::CommandSucceeded(build_id)) if build_id == generation => {
277 lock_guard.take();
278 }
279 Ok(Event::CommandSucceeded(build_id)) => {
280 log::trace!(
281 "Ignoring stale success from build {build_id} (current: {generation})"
282 );
283 }
284 Err(_) => {
285 current_child.terminate();
286 break;
287 }
288 }
289 }
290
291 Ok(())
292 }
293
294 fn is_excluded_path(&self, path: &Path) -> bool {
295 if self.exclude_paths.iter().any(|x| path.starts_with(x)) {
296 return true;
297 }
298
299 if self.exclude_globs.iter().any(|p| p.matches_path(path)) {
300 return true;
301 }
302
303 if let Ok(stripped_path) = path.strip_prefix(metadata().workspace_root.as_std_path()) {
304 if self
305 .workspace_exclude_paths
306 .iter()
307 .any(|x| stripped_path.starts_with(x))
308 {
309 return true;
310 }
311
312 if self
313 .workspace_exclude_globs
314 .iter()
315 .any(|p| p.matches_path(stripped_path))
316 {
317 return true;
318 }
319 }
320
321 false
322 }
323
324 fn is_hidden_path(&self, path: &Path) -> bool {
325 self.watch_paths.iter().any(|x| {
326 path.strip_prefix(x)
327 .iter()
328 .any(|x| x.to_string_lossy().starts_with('.'))
329 })
330 }
331
332 fn is_backup_file(&self, path: &Path) -> bool {
333 self.watch_paths.iter().any(|x| {
334 path.strip_prefix(x)
335 .iter()
336 .any(|x| x.to_string_lossy().ends_with('~'))
337 })
338 }
339
340 fn is_glob_pattern(path: &Path) -> bool {
341 let s = path.as_os_str().to_string_lossy();
342 s.contains('*') || s.contains('?') || (!cfg!(windows) && s.contains('['))
343 }
344
345 fn compile_glob(path: &Path) -> Result<Pattern> {
346 let pattern = path
347 .to_str()
348 .with_context(|| format!("glob pattern must be valid UTF-8: {}", path.display()))?;
349
350 Pattern::new(pattern).with_context(|| format!("invalid glob pattern: `{}`", path.display()))
351 }
352
353 fn prepare_excludes(&mut self) -> Result<()> {
354 let metadata = metadata();
355 self.exclude_paths
356 .push(metadata.target_directory.clone().into_std_path_buf());
357
358 let current_dir = env::current_dir().context("failed to get current directory")?;
359 let mut exclude_paths = Vec::new();
360 for path in self.exclude_paths.iter() {
361 if Self::is_glob_pattern(path) {
362 let absolute = if path.is_absolute() {
363 path.to_path_buf()
364 } else {
365 current_dir.join(path)
366 };
367 self.exclude_globs.push(Self::compile_glob(&absolute)?);
368 } else {
369 let canonical = path
370 .canonicalize()
371 .with_context(|| format!("can't find `{}`", path.display()))?;
372 exclude_paths.push(canonical);
373 }
374 }
375 self.exclude_paths = exclude_paths;
376
377 let workspace_root = metadata.workspace_root.as_std_path();
378 let mut workspace_exclude_paths = Vec::new();
379 for path in self.workspace_exclude_paths.iter() {
380 let path = if path.is_absolute() {
381 path.strip_prefix(workspace_root)
382 .with_context(|| {
383 format!(
384 "workspace exclude path must be inside workspace root: `{}`",
385 path.display()
386 )
387 })?
388 .to_path_buf()
389 } else {
390 path.to_path_buf()
391 };
392
393 if Self::is_glob_pattern(&path) {
394 self.workspace_exclude_globs
395 .push(Self::compile_glob(&path)?);
396 } else {
397 workspace_exclude_paths.push(path);
398 }
399 }
400 self.workspace_exclude_paths = workspace_exclude_paths;
401
402 Ok(())
403 }
404}
405
406struct WatchEventHandler {
407 watch: Watch,
408 tx: mpsc::Sender<Event>,
409 command_start: Instant,
410}
411
412impl notify::EventHandler for WatchEventHandler {
413 fn handle_event(&mut self, event: Result<notify::Event, notify::Error>) {
414 match event {
415 Ok(event) => {
416 if (event.kind.is_modify() || event.kind.is_create())
417 && event.paths.iter().any(|x| {
418 !self.watch.is_excluded_path(x)
419 && x.exists()
420 && !self.watch.is_hidden_path(x)
421 && !self.watch.is_backup_file(x)
422 && self.command_start.elapsed() >= self.watch.debounce
423 })
424 {
425 log::trace!("Changes detected in {event:?}");
426 self.command_start = Instant::now();
427
428 self.tx.send(Event::ChangeDetected).expect("can send");
429 } else {
430 log::trace!("Ignoring changes in {event:?}");
431 }
432 }
433 Err(err) => log::error!("watch error: {err}"),
434 }
435 }
436}
437
438#[derive(Debug, Clone)]
439struct SharedChild {
440 child: Arc<Mutex<Option<Child>>>,
441}
442
443impl SharedChild {
444 fn new() -> Self {
445 Self {
446 child: Default::default(),
447 }
448 }
449
450 fn replace(&mut self, child: impl Into<Option<Child>>) {
451 *self
452 .child
453 .lock()
454 .expect("no panic-prone code runs while this lock is held") = child.into();
455 }
456
457 fn wait(&mut self) -> ExitStatus {
458 loop {
459 let mut child = self
460 .child
461 .lock()
462 .expect("no panic-prone code runs while this lock is held");
463 match child.as_mut().map(|child| child.try_wait()) {
464 Some(Ok(Some(status))) => {
465 break status;
466 }
467 Some(Ok(None)) => {
468 drop(child);
469 thread::sleep(Duration::from_millis(10));
470 }
471 Some(Err(err)) => {
472 log::error!("could not wait for child process: {err}");
473 break Default::default();
474 }
475 None => {
476 break Default::default();
477 }
478 }
479 }
480 }
481
482 fn terminate(&mut self) {
483 if let Some(child) = self
484 .child
485 .lock()
486 .expect("no panic-prone code runs while this lock is held")
487 .as_mut()
488 {
489 #[cfg(unix)]
490 {
491 let killing_start = Instant::now();
492
493 unsafe {
494 log::trace!("sending SIGTERM to {}", child.id());
495 libc::kill(child.id() as _, libc::SIGTERM);
496 }
497
498 while killing_start.elapsed().as_secs() < 2 {
499 std::thread::sleep(Duration::from_millis(200));
500 if let Ok(Some(_)) = child.try_wait() {
501 break;
502 }
503 }
504 }
505
506 match child.try_wait() {
507 Ok(Some(_)) => {}
508 _ => {
509 log::trace!("killing {}", child.id());
510 let _ = child.kill();
511 let _ = child.wait();
512 }
513 }
514 } else {
515 log::trace!("nothing to terminate");
516 }
517 }
518}
519
520#[derive(Debug, Clone)]
522pub struct CommandList {
523 commands: Arc<Mutex<Vec<Command>>>,
524}
525
526impl From<Command> for CommandList {
527 fn from(command: Command) -> Self {
528 Self {
529 commands: Arc::new(Mutex::new(vec![command])),
530 }
531 }
532}
533
534impl From<Vec<Command>> for CommandList {
535 fn from(commands: Vec<Command>) -> Self {
536 Self {
537 commands: Arc::new(Mutex::new(commands)),
538 }
539 }
540}
541
542impl<const SIZE: usize> From<[Command; SIZE]> for CommandList {
543 fn from(commands: [Command; SIZE]) -> Self {
544 Self {
545 commands: Arc::new(Mutex::new(Vec::from(commands))),
546 }
547 }
548}
549
550impl CommandList {
551 pub fn is_empty(&self) -> bool {
553 self.commands
554 .lock()
555 .expect("no panic-prone code runs while this lock is held")
556 .is_empty()
557 }
558
559 pub fn spawn(&mut self, mut callback: impl FnMut(io::Result<Child>) -> bool) {
563 for process in self
564 .commands
565 .lock()
566 .expect("no panic-prone code runs while this lock is held")
567 .iter_mut()
568 {
569 if !callback(process.spawn()) {
570 break;
571 }
572 }
573 }
574
575 pub fn status(&mut self) -> io::Result<ExitStatus> {
578 for process in self
579 .commands
580 .lock()
581 .expect("no panic-prone code runs while this lock is held")
582 .iter_mut()
583 {
584 let exit_status = process.status()?;
585 if !exit_status.success() {
586 return Ok(exit_status);
587 }
588 }
589 Ok(Default::default())
590 }
591}
592
593pub struct WatchLockGuard<'a> {
598 _guard: RwLockReadGuard<'a, ()>,
599}
600
601#[derive(Clone, Debug, Default)]
606pub struct WatchLock(Arc<RwLock<()>>);
607
608impl WatchLock {
609 pub fn acquire(&self) -> WatchLockGuard<'_> {
613 WatchLockGuard {
614 _guard: self.0.read().unwrap_or_else(|e| e.into_inner()),
617 }
618 }
619
620 fn write(&self) -> RwLockWriteGuard<'_, ()> {
621 self.0.write().unwrap_or_else(|e| e.into_inner())
624 }
625}
626
627#[derive(Debug)]
628enum Event {
629 CommandSucceeded(u64),
630 ChangeDetected,
631}
632
633#[cfg(test)]
634mod test {
635 use super::*;
636
637 #[test]
638 fn exclude_relative_path() {
639 let watch = Watch::default().exclude_workspace_path("src/watch.rs");
640
641 assert!(
642 watch.is_excluded_path(
643 metadata()
644 .workspace_root
645 .join("src")
646 .join("watch.rs")
647 .as_std_path()
648 )
649 );
650 assert!(!watch.is_excluded_path(metadata().workspace_root.join("src").as_std_path()));
651 }
652
653 #[test]
654 fn exclude_absolute_glob_path() {
655 let absolute = metadata()
656 .workspace_root
657 .join("src")
658 .join("**")
659 .join("*.rs");
660
661 let mut watch = Watch::default().exclude_path(absolute);
662 watch
663 .prepare_excludes()
664 .expect("exclude parsing should succeed");
665 assert_eq!(watch.exclude_globs.len(), 1);
666
667 assert!(
668 watch.is_excluded_path(
669 metadata()
670 .workspace_root
671 .join("src")
672 .join("lib.rs")
673 .as_std_path()
674 )
675 );
676 }
677
678 #[test]
679 fn exclude_workspace_glob_path() {
680 let mut watch = Watch::default().exclude_workspace_path("src/**/*.rs");
681 watch
682 .prepare_excludes()
683 .expect("exclude parsing should succeed");
684 assert_eq!(watch.workspace_exclude_globs.len(), 1);
685
686 assert!(
687 watch.is_excluded_path(
688 metadata()
689 .workspace_root
690 .join("src")
691 .join("lib.rs")
692 .as_std_path()
693 )
694 );
695 }
696
697 #[test]
698 fn exclude_workspace_absolute_glob_path() {
699 let absolute = metadata()
700 .workspace_root
701 .join("src")
702 .join("**")
703 .join("*.rs");
704 let mut watch = Watch::default().exclude_workspace_path(absolute);
705 watch
706 .prepare_excludes()
707 .expect("exclude parsing should succeed");
708
709 assert_eq!(watch.workspace_exclude_globs.len(), 1);
710 assert!(
711 watch.is_excluded_path(
712 metadata()
713 .workspace_root
714 .join("src")
715 .join("lib.rs")
716 .as_std_path()
717 )
718 );
719 }
720
721 #[test]
722 fn exclude_workspace_glob_non_match() {
723 let mut watch = Watch::default().exclude_workspace_path("tests/**/*.rs");
724 watch
725 .prepare_excludes()
726 .expect("exclude parsing should succeed");
727
728 assert!(
729 !watch.is_excluded_path(
730 metadata()
731 .workspace_root
732 .join("src")
733 .join("lib.rs")
734 .as_std_path()
735 )
736 );
737 }
738
739 #[test]
740 fn glob_detection() {
741 assert!(Watch::is_glob_pattern(Path::new("src/**/*.rs")));
742 assert!(Watch::is_glob_pattern(Path::new("foo?.rs")));
743
744 #[cfg(not(windows))]
745 assert!(Watch::is_glob_pattern(Path::new("[ab].rs")));
746 #[cfg(windows)]
747 assert!(!Watch::is_glob_pattern(Path::new("[ab].rs")));
748
749 assert!(!Watch::is_glob_pattern(Path::new("src/lib.rs")));
750 }
751
752 #[test]
753 fn invalid_glob_pattern() {
754 let err = Watch::compile_glob(Path::new("[abc")).expect_err("should fail");
755 assert!(
756 err.to_string().contains("invalid glob pattern"),
757 "unexpected error: {err}"
758 );
759 }
760
761 #[test]
762 fn command_list_froms() {
763 let _: CommandList = Command::new("foo").into();
764 let _: CommandList = vec![Command::new("foo")].into();
765 let _: CommandList = [Command::new("foo")].into();
766 }
767}