summaryrefslogtreecommitdiff
path: root/crates/typst-cli/src/watch.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/typst-cli/src/watch.rs')
-rw-r--r--crates/typst-cli/src/watch.rs317
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: &notify::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: &notify::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,
+ }
}
}