diff options
Diffstat (limited to 'crates/typst-cli/src/watch.rs')
| -rw-r--r-- | crates/typst-cli/src/watch.rs | 317 |
1 files changed, 214 insertions, 103 deletions
diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index 35861242..be2fa867 100644 --- a/crates/typst-cli/src/watch.rs +++ b/crates/typst-cli/src/watch.rs @@ -1,19 +1,22 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::io::{self, Write}; -use std::path::{Path, PathBuf}; +use std::iter; +use std::path::PathBuf; +use std::sync::mpsc::Receiver; +use std::time::{Duration, Instant}; use codespan_reporting::term::termcolor::WriteColor; use codespan_reporting::term::{self, termcolor}; use ecow::eco_format; -use notify::{RecommendedWatcher, RecursiveMode, Watcher}; +use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher as _}; use same_file::is_same_file; use typst::diag::StrResult; use crate::args::{CompileCommand, Input}; use crate::compile::compile_once; -use crate::terminal; use crate::timings::Timer; -use crate::world::SystemWorld; +use crate::world::{SystemWorld, WorldCreationError}; +use crate::{print_error, terminal}; /// Execute a watching compilation command. pub fn watch(mut timer: Timer, mut command: CompileCommand) -> StrResult<()> { @@ -23,127 +26,235 @@ pub fn watch(mut timer: Timer, mut command: CompileCommand) -> StrResult<()> { .enter_alternate_screen() .map_err(|err| eco_format!("failed to enter alternate screen ({err})"))?; + // Create a file system watcher. + let mut watcher = Watcher::new(command.output())?; + // Create the world that serves sources, files, and fonts. - let mut world = SystemWorld::new(&command.common)?; + // Additionally, if any files do not exist, wait until they do. + let mut world = loop { + match SystemWorld::new(&command.common) { + Ok(world) => break world, + Err( + ref err @ (WorldCreationError::InputNotFound(ref path) + | WorldCreationError::RootNotFound(ref path)), + ) => { + watcher.update([path.clone()])?; + Status::Error.print(&command).unwrap(); + print_error(&err.to_string()).unwrap(); + watcher.wait()?; + } + Err(err) => return Err(err.into()), + } + }; // Perform initial compilation. timer.record(&mut world, |world| compile_once(world, &mut command, true))??; - // Setup file watching. - let (tx, rx) = std::sync::mpsc::channel(); - let mut watcher = RecommendedWatcher::new(tx, notify::Config::default()) - .map_err(|err| eco_format!("failed to setup file watching ({err})"))?; - - // Watch all the files that are used by the input file and its dependencies. - let mut watched = HashMap::new(); - watch_dependencies(&mut world, &mut watcher, &mut watched)?; - - // Handle events. - let timeout = std::time::Duration::from_millis(100); - let output = command.output(); - while terminal::out().is_active() { - let mut recompile = false; - if let Ok(event) = rx.recv_timeout(timeout) { - let event = - event.map_err(|err| eco_format!("failed to watch directory ({err})"))?; - - // Workaround for notify-rs' implicit unwatch on remove/rename - // (triggered by some editors when saving files) with the inotify - // backend. By keeping track of the potentially unwatched files, we - // can allow those we still depend on to be watched again later on. - if matches!( - event.kind, - notify::EventKind::Remove(notify::event::RemoveKind::File) - ) { - // Mark the file as unwatched and remove the watch in case it - // still exists. - let path = &event.paths[0]; - watched.remove(path); - watcher.unwatch(path).ok(); - } + // Watch all dependencies of the initial compilation. + watcher.update(world.dependencies())?; - recompile |= is_event_relevant(&event, &output); - } + // Recompile whenever something relevant happens. + loop { + // Wait until anything relevant happens. + watcher.wait()?; - if recompile { - // Reset all dependencies. - world.reset(); + // Reset all dependencies. + world.reset(); - // Recompile. - timer - .record(&mut world, |world| compile_once(world, &mut command, true))??; + // Recompile. + timer.record(&mut world, |world| compile_once(world, &mut command, true))??; - comemo::evict(10); + // Evict the cache. + comemo::evict(10); - // Adjust the file watching. - watch_dependencies(&mut world, &mut watcher, &mut watched)?; - } + // Adjust the file watching. + watcher.update(world.dependencies())?; } - Ok(()) } -/// Adjust the file watching. Watches all new dependencies and unwatches -/// all previously `watched` files that are no relevant anymore. -fn watch_dependencies( - world: &mut SystemWorld, - watcher: &mut dyn Watcher, - watched: &mut HashMap<PathBuf, bool>, -) -> StrResult<()> { - // Mark all files as not "seen" so that we may unwatch them if they aren't - // in the dependency list. - for seen in watched.values_mut() { - *seen = false; +/// Watches file system activity. +struct Watcher { + /// The output file. We ignore any events for it. + output: PathBuf, + /// The underlying watcher. + watcher: RecommendedWatcher, + /// Notify event receiver. + rx: Receiver<notify::Result<Event>>, + /// Keeps track of which paths are watched via `watcher`. The boolean is + /// used during updating for mark-and-sweep garbage collection of paths we + /// should unwatch. + watched: HashMap<PathBuf, bool>, + /// A set of files that should be watched, but don't exist. We manually poll + /// for those. + missing: HashSet<PathBuf>, +} + +impl Watcher { + /// How long to wait for a shortly following file system event when + /// watching. + const BATCH_TIMEOUT: Duration = Duration::from_millis(100); + + /// The maximum time we spend batching events before quitting wait(). + const STARVE_TIMEOUT: Duration = Duration::from_millis(500); + + /// The interval in which we poll when falling back to poll watching + /// due to missing files. + const POLL_INTERVAL: Duration = Duration::from_millis(300); + + /// Create a new, blank watcher. + fn new(output: PathBuf) -> StrResult<Self> { + // Setup file watching. + let (tx, rx) = std::sync::mpsc::channel(); + + // Set the poll interval to something more eager than the default. That + // default seems a bit excessive for our purposes at around 30s. + // Depending on feedback, some tuning might still be in order. Note that + // this only affects a tiny number of systems. Most do not use the + // [`notify::PollWatcher`]. + let config = notify::Config::default().with_poll_interval(Self::POLL_INTERVAL); + let watcher = RecommendedWatcher::new(tx, config) + .map_err(|err| eco_format!("failed to setup file watching ({err})"))?; + + Ok(Self { + output, + rx, + watcher, + watched: HashMap::new(), + missing: HashSet::new(), + }) } - // Retrieve the dependencies of the last compilation and watch new paths - // that weren't watched yet. We can't watch paths that don't exist yet - // unfortunately, so we filter those out. - for path in world.dependencies().filter(|path| path.exists()) { - if !watched.contains_key(&path) { - watcher - .watch(&path, RecursiveMode::NonRecursive) - .map_err(|err| eco_format!("failed to watch {path:?} ({err})"))?; + /// Update the watching to watch exactly the listed files. + /// + /// Files that are not yet watched will be watched. Files that are already + /// watched, but don't need to be watched anymore, will be unwatched. + fn update(&mut self, iter: impl IntoIterator<Item = PathBuf>) -> StrResult<()> { + // Mark all files as not "seen" so that we may unwatch them if they + // aren't in the dependency list. + for seen in self.watched.values_mut() { + *seen = false; } - // Mark the file as "seen" so that we don't unwatch it. - watched.insert(path, true); - } + // Reset which files are missing. + self.missing.clear(); + + // Retrieve the dependencies of the last compilation and watch new paths + // that weren't watched yet. + for path in iter { + // We can't watch paths that don't exist with notify-rs. Instead, we + // add those to a `missing` set and fall back to manual poll + // watching. + if !path.exists() { + self.missing.insert(path); + continue; + } - // Unwatch old paths that don't need to be watched anymore. - watched.retain(|path, &mut seen| { - if !seen { - watcher.unwatch(path).ok(); + // Watch the path if it's not already watched. + if !self.watched.contains_key(&path) { + self.watcher + .watch(&path, RecursiveMode::NonRecursive) + .map_err(|err| eco_format!("failed to watch {path:?} ({err})"))?; + } + + // Mark the file as "seen" so that we don't unwatch it. + self.watched.insert(path, true); } - seen - }); - Ok(()) -} + // Unwatch old paths that don't need to be watched anymore. + self.watched.retain(|path, &mut seen| { + if !seen { + self.watcher.unwatch(path).ok(); + } + seen + }); + + Ok(()) + } + + /// Wait until there is a change to a watched path. + fn wait(&mut self) -> StrResult<()> { + loop { + // Wait for an initial event. If there are missing files, we need to + // poll those regularly to check whether they are created, so we + // wait with a smaller timeout. + let first = self.rx.recv_timeout(if self.missing.is_empty() { + Duration::MAX + } else { + Self::POLL_INTERVAL + }); + + // Watch for file system events. If multiple events happen + // consecutively all within a certain duration, then they are + // bunched up without a recompile in-between. This helps against + // some editors' remove & move behavior. Events are also only + // watched until a certain point, to hinder a barrage of events from + // preventing recompilations. + let mut relevant = false; + let batch_start = Instant::now(); + for event in first + .into_iter() + .chain(iter::from_fn(|| self.rx.recv_timeout(Self::BATCH_TIMEOUT).ok())) + .take_while(|_| batch_start.elapsed() <= Self::STARVE_TIMEOUT) + { + let event = event + .map_err(|err| eco_format!("failed to watch dependencies ({err})"))?; -/// Whether a watch event is relevant for compilation. -fn is_event_relevant(event: ¬ify::Event, output: &Path) -> bool { - // Never recompile because the output file changed. - if event - .paths - .iter() - .all(|path| is_same_file(path, output).unwrap_or(false)) - { - return false; + // Workaround for notify-rs' implicit unwatch on remove/rename + // (triggered by some editors when saving files) with the + // inotify backend. By keeping track of the potentially + // unwatched files, we can allow those we still depend on to be + // watched again later on. + if matches!( + event.kind, + notify::EventKind::Remove(notify::event::RemoveKind::File) + | notify::EventKind::Modify(notify::event::ModifyKind::Name( + notify::event::RenameMode::From + )) + ) { + for path in &event.paths { + // Remove affected path from the watched map to restart + // watching on it later again. + self.watcher.unwatch(path).ok(); + self.watched.remove(path); + } + } + + relevant |= self.is_event_relevant(&event); + } + + // If we found a relevant event or if any of the missing files now + // exists, stop waiting. + if relevant || self.missing.iter().any(|path| path.exists()) { + return Ok(()); + } + } } - match &event.kind { - notify::EventKind::Any => true, - notify::EventKind::Access(_) => false, - notify::EventKind::Create(_) => true, - notify::EventKind::Modify(kind) => match kind { - notify::event::ModifyKind::Any => true, - notify::event::ModifyKind::Data(_) => true, - notify::event::ModifyKind::Metadata(_) => false, - notify::event::ModifyKind::Name(_) => true, - notify::event::ModifyKind::Other => false, - }, - notify::EventKind::Remove(_) => true, - notify::EventKind::Other => false, + /// Whether a watch event is relevant for compilation. + fn is_event_relevant(&self, event: ¬ify::Event) -> bool { + // Never recompile because the output file changed. + if event + .paths + .iter() + .all(|path| is_same_file(path, &self.output).unwrap_or(false)) + { + return false; + } + + match &event.kind { + notify::EventKind::Any => true, + notify::EventKind::Access(_) => false, + notify::EventKind::Create(_) => true, + notify::EventKind::Modify(kind) => match kind { + notify::event::ModifyKind::Any => true, + notify::event::ModifyKind::Data(_) => true, + notify::event::ModifyKind::Metadata(_) => false, + notify::event::ModifyKind::Name(_) => true, + notify::event::ModifyKind::Other => false, + }, + notify::EventKind::Remove(_) => true, + notify::EventKind::Other => false, + } } } |
