diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 337ec966f..8e628b4e6 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -63,7 +63,8 @@ impl CompileCommand { /// Execute a compilation command. pub fn compile(mut timer: Timer, mut command: CompileCommand) -> StrResult<()> { - let mut world = SystemWorld::new(&command.common)?; + let mut world = + SystemWorld::new(&command.common).map_err(|err| eco_format!("{err}"))?; timer.record(&mut world, |world| compile_once(world, &mut command, false))??; Ok(()) } diff --git a/crates/typst-cli/src/main.rs b/crates/typst-cli/src/main.rs index 5e1ef47c9..71b3dd385 100644 --- a/crates/typst-cli/src/main.rs +++ b/crates/typst-cli/src/main.rs @@ -77,7 +77,7 @@ fn print_error(msg: &str) -> io::Result<()> { write!(output, "error")?; output.reset()?; - writeln!(output, ": {msg}.") + writeln!(output, ": {msg}") } #[cfg(not(feature = "self-update"))] diff --git a/crates/typst-cli/src/query.rs b/crates/typst-cli/src/query.rs index f2257bdf3..f2e52666d 100644 --- a/crates/typst-cli/src/query.rs +++ b/crates/typst-cli/src/query.rs @@ -16,6 +16,7 @@ use crate::world::SystemWorld; /// Execute a query command. pub fn query(command: &QueryCommand) -> StrResult<()> { let mut world = SystemWorld::new(&command.common)?; + // Reset everything and ensure that the main file is present. world.reset(); world.source(world.main()).map_err(|err| err.to_string())?; diff --git a/crates/typst-cli/src/terminal.rs b/crates/typst-cli/src/terminal.rs index ed2b6fe07..da0eb9d70 100644 --- a/crates/typst-cli/src/terminal.rs +++ b/crates/typst-cli/src/terminal.rs @@ -1,6 +1,5 @@ use std::io::{self, IsTerminal, Write}; use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; use codespan_reporting::term::termcolor; use ecow::eco_format; @@ -18,7 +17,6 @@ pub fn out() -> TermOut { /// The stuff that has to be shared between instances of [`TermOut`]. struct TermOutInner { - active: AtomicBool, stream: termcolor::StandardStream, in_alternate_screen: AtomicBool, } @@ -35,7 +33,6 @@ impl TermOutInner { let stream = termcolor::StandardStream::stderr(color_choice); TermOutInner { - active: AtomicBool::new(true), stream, in_alternate_screen: AtomicBool::new(false), } @@ -54,25 +51,10 @@ impl TermOut { /// Initialize a handler that listens for Ctrl-C signals. /// This is used to exit the alternate screen that might have been opened. pub fn init_exit_handler(&mut self) -> StrResult<()> { - /// The duration the application may keep running after an exit signal was received. - const MAX_TIME_TO_EXIT: Duration = Duration::from_millis(750); - // We can safely ignore the error as the only thing this handler would do // is leave an alternate screen if none was opened; not very important. let mut term_out = self.clone(); ctrlc::set_handler(move || { - term_out.inner.active.store(false, Ordering::Release); - - // Wait for some time and if the application is still running, simply exit. - // Not exiting immediately potentially allows destructors to run and file writes - // to complete. - std::thread::sleep(MAX_TIME_TO_EXIT); - - // Leave alternate screen only after the timeout has expired. - // This prevents console output intended only for within the alternate screen - // from showing up outside it. - // Remember that the alternate screen is also closed if the timeout is not reached, - // just from a different location in code. let _ = term_out.leave_alternate_screen(); // Exit with the exit code standard for Ctrl-C exits[^1]. @@ -84,11 +66,6 @@ impl TermOut { .map_err(|err| eco_format!("failed to initialize exit handler ({err})")) } - /// Whether this program is still active and was not stopped by the Ctrl-C handler. - pub fn is_active(&self) -> bool { - self.inner.active.load(Ordering::Acquire) - } - /// Clears the entire screen. pub fn clear_screen(&mut self) -> io::Result<()> { // We don't want to clear anything that is not a TTY. diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index 35861242d..be2fa8674 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 dependencies of the initial compilation. + watcher.update(world.dependencies())?; - // 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)?; + // Recompile whenever something relevant happens. + loop { + // Wait until anything relevant happens. + watcher.wait()?; - // 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})"))?; + // Reset all dependencies. + world.reset(); - // 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(); + // Recompile. + timer.record(&mut world, |world| compile_once(world, &mut command, true))??; + + // Evict the cache. + comemo::evict(10); + + // Adjust the file watching. + watcher.update(world.dependencies())?; + } +} + +/// 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>, + /// 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, + /// A set of files that should be watched, but don't exist. We manually poll + /// for those. + missing: HashSet, +} + +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 { + // 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(), + }) + } + + /// 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) -> 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; + } + + // 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; } - recompile |= is_event_relevant(&event, &output); + // 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); } - if recompile { - // Reset all dependencies. - world.reset(); + // 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 + }); - // Recompile. - timer - .record(&mut world, |world| compile_once(world, &mut command, true))??; + Ok(()) + } - comemo::evict(10); + /// 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 + }); - // Adjust the file watching. - watch_dependencies(&mut world, &mut watcher, &mut watched)?; + // 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})"))?; + + // 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(()); + } } } - 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, -) -> 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; - } - - // 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})"))?; + /// 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; } - // Mark the file as "seen" so that we don't unwatch it. - watched.insert(path, true); - } - - // Unwatch old paths that don't need to be watched anymore. - watched.retain(|path, &mut seen| { - if !seen { - watcher.unwatch(path).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, } - seen - }); - - Ok(()) -} - -/// 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; - } - - 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, } } diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index 72efa7fa4..55e7183b7 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -2,14 +2,14 @@ use std::collections::HashMap; use std::io::Read; use std::path::{Path, PathBuf}; use std::sync::OnceLock; -use std::{fs, io, mem}; +use std::{fmt, fs, io, mem}; use chrono::{DateTime, Datelike, Local}; use comemo::Prehashed; -use ecow::eco_format; +use ecow::{eco_format, EcoString}; use once_cell::sync::Lazy; use parking_lot::Mutex; -use typst::diag::{FileError, FileResult, StrResult}; +use typst::diag::{FileError, FileResult}; use typst::foundations::{Bytes, Datetime, Dict, IntoValue}; use typst::syntax::{FileId, Source, VirtualPath}; use typst::text::{Font, FontBook}; @@ -54,16 +54,18 @@ pub struct SystemWorld { impl SystemWorld { /// Create a new system world. - pub fn new(command: &SharedArgs) -> StrResult { - let mut searcher = FontSearcher::new(); - searcher.search(&command.font_paths); - + pub fn new(command: &SharedArgs) -> Result { // Resolve the system-global input path. let input = match &command.input { Input::Stdin => None, - Input::Path(path) => Some(path.canonicalize().map_err(|_| { - eco_format!("input file not found (searched at {})", path.display()) - })?), + Input::Path(path) => { + Some(path.canonicalize().map_err(|err| match err.kind() { + io::ErrorKind::NotFound => { + WorldCreationError::InputNotFound(path.clone()) + } + _ => WorldCreationError::Io(err), + })?) + } }; // Resolve the system-global root directory. @@ -73,15 +75,18 @@ impl SystemWorld { .as_deref() .or_else(|| input.as_deref().and_then(|i| i.parent())) .unwrap_or(Path::new(".")); - path.canonicalize().map_err(|_| { - eco_format!("root directory not found (searched at {})", path.display()) + path.canonicalize().map_err(|err| match err.kind() { + io::ErrorKind::NotFound => { + WorldCreationError::RootNotFound(path.to_path_buf()) + } + _ => WorldCreationError::Io(err), })? }; let main = if let Some(path) = &input { // Resolve the virtual path of the main file within the project root. let main_path = VirtualPath::within_root(path, &root) - .ok_or("source file must be contained in project root")?; + .ok_or(WorldCreationError::InputOutsideRoot)?; FileId::new(None, main_path) } else { // Return the special id of STDIN otherwise @@ -99,6 +104,9 @@ impl SystemWorld { Library::builder().with_inputs(inputs).build() }; + let mut searcher = FontSearcher::new(); + searcher.search(&command.font_paths); + Ok(Self { workdir: std::env::current_dir().ok(), input, @@ -384,3 +392,39 @@ fn decode_utf8(buf: &[u8]) -> FileResult<&str> { // Remove UTF-8 BOM. Ok(std::str::from_utf8(buf.strip_prefix(b"\xef\xbb\xbf").unwrap_or(buf))?) } + +/// An error that occurs during world construction. +#[derive(Debug)] +pub enum WorldCreationError { + /// The input file does not appear to exist. + InputNotFound(PathBuf), + /// The input file is not contained withhin the root folder. + InputOutsideRoot, + /// The root directory does not appear to exist. + RootNotFound(PathBuf), + /// Another type of I/O error. + Io(io::Error), +} + +impl fmt::Display for WorldCreationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + WorldCreationError::InputNotFound(path) => { + write!(f, "input file not found (searched at {})", path.display()) + } + WorldCreationError::InputOutsideRoot => { + write!(f, "source file must be contained in project root") + } + WorldCreationError::RootNotFound(path) => { + write!(f, "root directory not found (searched at {})", path.display()) + } + WorldCreationError::Io(err) => write!(f, "{err}"), + } + } +} + +impl From for EcoString { + fn from(err: WorldCreationError) -> Self { + eco_format!("{err}") + } +}