mirror of
https://github.com/typst/typst
synced 2025-05-14 04:56:26 +08:00
319 lines
11 KiB
Rust
319 lines
11 KiB
Rust
use std::collections::{HashMap, HashSet};
|
|
use std::io::{self, Write};
|
|
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::{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::timings::Timer;
|
|
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<()> {
|
|
// Enter the alternate screen and handle Ctrl-C ourselves.
|
|
terminal::out().init_exit_handler()?;
|
|
terminal::out()
|
|
.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.
|
|
// 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))??;
|
|
|
|
// Watch all dependencies of the initial compilation.
|
|
watcher.update(world.dependencies())?;
|
|
|
|
// Recompile whenever something relevant happens.
|
|
loop {
|
|
// Wait until anything relevant happens.
|
|
watcher.wait()?;
|
|
|
|
// Reset all dependencies.
|
|
world.reset();
|
|
|
|
// 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<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(),
|
|
})
|
|
}
|
|
|
|
/// 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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// 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})"))?;
|
|
|
|
// 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(());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The status in which the watcher can be.
|
|
pub enum Status {
|
|
Compiling,
|
|
Success(std::time::Duration),
|
|
PartialSuccess(std::time::Duration),
|
|
Error,
|
|
}
|
|
|
|
impl Status {
|
|
/// Clear the terminal and render the status message.
|
|
pub fn print(&self, command: &CompileCommand) -> io::Result<()> {
|
|
let output = command.output();
|
|
let timestamp = chrono::offset::Local::now().format("%H:%M:%S");
|
|
let color = self.color();
|
|
|
|
let mut out = terminal::out();
|
|
out.clear_screen()?;
|
|
|
|
out.set_color(&color)?;
|
|
write!(out, "watching")?;
|
|
out.reset()?;
|
|
match &command.common.input {
|
|
Input::Stdin => writeln!(out, " <stdin>"),
|
|
Input::Path(path) => writeln!(out, " {}", path.display()),
|
|
}?;
|
|
|
|
out.set_color(&color)?;
|
|
write!(out, "writing to")?;
|
|
out.reset()?;
|
|
writeln!(out, " {}", output.display())?;
|
|
|
|
writeln!(out)?;
|
|
writeln!(out, "[{timestamp}] {}", self.message())?;
|
|
writeln!(out)?;
|
|
|
|
out.flush()
|
|
}
|
|
|
|
fn message(&self) -> String {
|
|
match self {
|
|
Self::Compiling => "compiling ...".into(),
|
|
Self::Success(duration) => format!("compiled successfully in {duration:.2?}"),
|
|
Self::PartialSuccess(duration) => {
|
|
format!("compiled with warnings in {duration:.2?}")
|
|
}
|
|
Self::Error => "compiled with errors".into(),
|
|
}
|
|
}
|
|
|
|
fn color(&self) -> termcolor::ColorSpec {
|
|
let styles = term::Styles::default();
|
|
match self {
|
|
Self::Error => styles.header_error,
|
|
Self::PartialSuccess(_) => styles.header_warning,
|
|
_ => styles.header_note,
|
|
}
|
|
}
|
|
}
|