diff --git a/Cargo.lock b/Cargo.lock index 39fea907e..2b25eee1c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2936,7 +2936,6 @@ dependencies = [ "dirs", "ecow", "env_proxy", - "filetime", "flate2", "fontdb", "inferno", diff --git a/Cargo.toml b/Cargo.toml index b4eaf9939..e99063e0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,7 +42,6 @@ csv = "1" dirs = "5" ecow = { version = "0.2", features = ["serde"] } env_proxy = "0.4" -filetime = "0.2" flate2 = "1" fontdb = { version = "0.15", default-features = false } hayagriva = "0.4" diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml index d797555e2..7ac4ee5e0 100644 --- a/crates/typst-cli/Cargo.toml +++ b/crates/typst-cli/Cargo.toml @@ -32,7 +32,6 @@ comemo = { workspace = true } dirs = { workspace = true } ecow = { workspace = true } env_proxy = { workspace = true } -filetime = { workspace = true } flate2 = { workspace = true } fontdb = { workspace = true, features = ["memmap", "fontconfig"] } inferno = { workspace = true } diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index 8ef1c5712..138f473aa 100644 --- a/crates/typst-cli/src/watch.rs +++ b/crates/typst-cli/src/watch.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::HashMap; use std::io::{self, IsTerminal, Write}; use std::path::{Path, PathBuf}; @@ -28,13 +28,13 @@ pub fn watch(mut command: CompileCommand) -> StrResult<()> { .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. - watch_dependencies(&mut world, &mut watcher, HashSet::new())?; + 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(); loop { - let mut removed = HashSet::new(); let mut recompile = false; for event in rx .recv() @@ -46,16 +46,16 @@ pub fn watch(mut command: CompileCommand) -> StrResult<()> { // 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 removed files, we can allow - // those we still depend on to be watched again later on. + // 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]; - removed.insert(path.clone()); - - // Remove the watch in case it still exists. + watched.remove(path); watcher.unwatch(path).ok(); } @@ -63,13 +63,6 @@ pub fn watch(mut command: CompileCommand) -> StrResult<()> { } if recompile { - // Retrieve the dependencies of the last compilation. - let previous: HashSet = world - .dependencies() - .filter(|path| !removed.contains(*path)) - .map(ToOwned::to_owned) - .collect(); - // Reset all dependencies. world.reset(); @@ -77,36 +70,49 @@ pub fn watch(mut command: CompileCommand) -> StrResult<()> { compile_once(&mut world, &mut command, true)?; comemo::evict(10); - // Adjust the watching. - watch_dependencies(&mut world, &mut watcher, previous)?; + // Adjust the file watching. + watch_dependencies(&mut world, &mut watcher, &mut watched)?; } } } /// Adjust the file watching. Watches all new dependencies and unwatches -/// all `previous` dependencies that are not relevant anymore. +/// all previously `watched` files that are no relevant anymore. #[tracing::instrument(skip_all)] fn watch_dependencies( world: &mut SystemWorld, watcher: &mut dyn Watcher, - mut previous: HashSet, + watched: &mut HashMap, ) -> StrResult<()> { - // Watch new paths that weren't watched yet. - for path in world.dependencies() { - let watched = previous.remove(path); - if path.exists() && !watched { + // 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) { tracing::info!("Watching {}", path.display()); watcher - .watch(path, RecursiveMode::NonRecursive) + .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. + watched.insert(path, true); } // Unwatch old paths that don't need to be watched anymore. - for path in previous { - tracing::info!("Unwatching {}", path.display()); - watcher.unwatch(&path).ok(); - } + watched.retain(|path, &mut seen| { + if !seen { + tracing::info!("Unwatching {}", path.display()); + watcher.unwatch(path).ok(); + } + seen + }); Ok(()) } diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index 81480e62a..3b774f1da 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -1,14 +1,10 @@ use std::cell::{Cell, OnceCell, RefCell, RefMut}; use std::collections::HashMap; use std::fs; -use std::hash::Hash; use std::path::{Path, PathBuf}; use chrono::{DateTime, Datelike, Local}; use comemo::Prehashed; -use filetime::FileTime; -use same_file::Handle; -use siphasher::sip128::{Hasher128, SipHasher13}; use typst::diag::{FileError, FileResult, StrResult}; use typst::doc::Frame; use typst::eval::{eco_format, Bytes, Datetime, Library}; @@ -37,12 +33,8 @@ pub struct SystemWorld { book: Prehashed, /// Locations of and storage for lazily loaded fonts. fonts: Vec, - /// Maps package-path combinations to canonical hashes. All package-path - /// combinations that point to the same file are mapped to the same hash. To - /// be used in conjunction with `paths`. - hashes: RefCell>>, - /// Maps canonical path hashes to source files and buffers. - slots: RefCell>, + /// Maps file ids to source files and buffers. + slots: RefCell>, /// The current datetime if requested. This is stored here to ensure it is /// always the same within one compilation. Reset between compilations. now: OnceCell>, @@ -86,7 +78,6 @@ impl SystemWorld { library: Prehashed::new(typst_library::build()), book: Prehashed::new(searcher.book), fonts: searcher.fonts, - hashes: RefCell::default(), slots: RefCell::default(), now: OnceCell::new(), export_cache: ExportCache::new(), @@ -109,17 +100,16 @@ impl SystemWorld { } /// Return all paths the last compilation depended on. - pub fn dependencies(&mut self) -> impl Iterator { + pub fn dependencies(&mut self) -> impl Iterator + '_ { self.slots .get_mut() .values() .filter(|slot| slot.accessed()) - .map(|slot| slot.path.as_path()) + .filter_map(|slot| slot.system_path(&self.root).ok()) } /// Reset the compilation state in preparation of a new compilation. pub fn reset(&mut self) { - self.hashes.borrow_mut().clear(); for slot in self.slots.borrow_mut().values_mut() { slot.reset(); } @@ -157,11 +147,11 @@ impl World for SystemWorld { } fn source(&self, id: FileId) -> FileResult { - self.slot(id)?.source() + self.slot(id)?.source(&self.root) } fn file(&self, id: FileId) -> FileResult { - self.slot(id)?.file() + self.slot(id)?.file(&self.root) } fn font(&self, index: usize) -> Option { @@ -187,59 +177,29 @@ impl World for SystemWorld { impl SystemWorld { /// Access the canonical slot for the given file id. #[tracing::instrument(skip_all)] - fn slot(&self, id: FileId) -> FileResult> { - let mut system_path = PathBuf::new(); - let hash = self - .hashes - .borrow_mut() - .entry(id) - .or_insert_with(|| { - // Determine the root path relative to which the file path - // will be resolved. - let buf; - let mut root = &self.root; - if let Some(spec) = id.package() { - buf = prepare_package(spec)?; - root = &buf; - } - - // Join the path to the root. If it tries to escape, deny - // access. Note: It can still escape via symlinks. - system_path = id.vpath().resolve(root).ok_or(FileError::AccessDenied)?; - - PathHash::new(&system_path) - }) - .clone()?; - - Ok(RefMut::map(self.slots.borrow_mut(), |paths| { - paths.entry(hash).or_insert_with(|| PathSlot::new(id, system_path)) + fn slot(&self, id: FileId) -> FileResult> { + Ok(RefMut::map(self.slots.borrow_mut(), |slots| { + slots.entry(id).or_insert_with(|| FileSlot::new(id)) })) } } -/// Holds canonical data for all paths pointing to the same entity. +/// Holds the processed data for a file ID. /// /// Both fields can be populated if the file is both imported and read(). -struct PathSlot { - /// The slot's canonical file id. +struct FileSlot { + /// The slot's file id. id: FileId, - /// The slot's path on the system. - path: PathBuf, /// The lazily loaded and incrementally updated source file. source: SlotCell, /// The lazily loaded raw byte buffer. file: SlotCell, } -impl PathSlot { +impl FileSlot { /// Create a new path slot. - fn new(id: FileId, path: PathBuf) -> Self { - Self { - id, - path, - file: SlotCell::new(), - source: SlotCell::new(), - } + fn new(id: FileId) -> Self { + Self { id, file: SlotCell::new(), source: SlotCell::new() } } /// Whether the file was accessed in the ongoing compilation. @@ -255,28 +215,51 @@ impl PathSlot { } /// Retrieve the source for this file. - fn source(&self) -> FileResult { - self.source.get_or_init(&self.path, |data, prev| { - let text = decode_utf8(&data)?; - if let Some(mut prev) = prev { - prev.replace(text); - Ok(prev) - } else { - Ok(Source::new(self.id, text.into())) - } - }) + fn source(&self, root: &Path) -> FileResult { + self.source.get_or_init( + || self.system_path(root), + |data, prev| { + let text = decode_utf8(&data)?; + if let Some(mut prev) = prev { + prev.replace(text); + Ok(prev) + } else { + Ok(Source::new(self.id, text.into())) + } + }, + ) } /// Retrieve the file's bytes. - fn file(&self) -> FileResult { - self.file.get_or_init(&self.path, |data, _| Ok(data.into())) + fn file(&self, root: &Path) -> FileResult { + self.file + .get_or_init(|| self.system_path(root), |data, _| Ok(data.into())) + } + + /// The path of the slot on the system. + fn system_path(&self, root: &Path) -> FileResult { + // Determine the root path relative to which the file path + // will be resolved. + let buf; + let mut root = root; + if let Some(spec) = self.id.package() { + buf = prepare_package(spec)?; + root = &buf; + } + + // Join the path to the root. If it tries to escape, deny + // access. Note: It can still escape via symlinks. + self.id.vpath().resolve(root).ok_or(FileError::AccessDenied) } } /// Lazily processes data for a file. struct SlotCell { + /// The processed data. data: RefCell>>, - refreshed: Cell, + /// A hash of the raw file contents / access error. + fingerprint: Cell, + /// Whether the slot has been accessed in the current compilation. accessed: Cell, } @@ -285,7 +268,7 @@ impl SlotCell { fn new() -> Self { Self { data: RefCell::new(None), - refreshed: Cell::new(FileTime::zero()), + fingerprint: Cell::new(0), accessed: Cell::new(false), } } @@ -304,45 +287,35 @@ impl SlotCell { /// Gets the contents of the cell or initialize them. fn get_or_init( &self, - path: &Path, + path: impl FnOnce() -> FileResult, f: impl FnOnce(Vec, Option) -> FileResult, ) -> FileResult { let mut borrow = self.data.borrow_mut(); - if let Some(data) = &*borrow { - if self.accessed.replace(true) || self.current(path) { + + // If we accessed the file already in this compilation, retrieve it. + if self.accessed.replace(true) { + if let Some(data) = &*borrow { + return data.clone(); + } + } + + // Read and hash the file. + let result = path().and_then(|p| read(&p)); + let fingerprint = typst::util::hash128(&result); + + // If the file contents didn't change, yield the old processed data. + if self.fingerprint.replace(fingerprint) == fingerprint { + if let Some(data) = &*borrow { return data.clone(); } } - self.accessed.set(true); - self.refreshed.set(FileTime::now()); let prev = borrow.take().and_then(Result::ok); - let value = read(path).and_then(|data| f(data, prev)); + let value = result.and_then(|data| f(data, prev)); *borrow = Some(value.clone()); + value } - - /// Whether the cell contents are still up to date with the file system. - fn current(&self, path: &Path) -> bool { - fs::metadata(path).map_or(false, |meta| { - let modified = FileTime::from_last_modification_time(&meta); - modified < self.refreshed.get() - }) - } -} - -/// A hash that is the same for all paths pointing to the same entity. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)] -struct PathHash(u128); - -impl PathHash { - fn new(path: &Path) -> FileResult { - let f = |e| FileError::from_io(e, path); - let handle = Handle::from_path(path).map_err(f)?; - let mut state = SipHasher13::new(); - handle.hash(&mut state); - Ok(Self(state.finish128().as_u128())) - } } /// Caches exported files so that we can avoid re-exporting them if they haven't diff --git a/tests/src/tests.rs b/tests/src/tests.rs index 3b33a729b..42663e6cc 100644 --- a/tests/src/tests.rs +++ b/tests/src/tests.rs @@ -218,12 +218,11 @@ struct TestWorld { library: Prehashed, book: Prehashed, fonts: Vec, - paths: RefCell>, + paths: RefCell>, } #[derive(Clone)] struct PathSlot { - system_path: PathBuf, source: OnceCell>, buffer: OnceCell>, } @@ -270,7 +269,7 @@ impl World for TestWorld { let slot = self.slot(id)?; slot.source .get_or_init(|| { - let buf = read(&slot.system_path)?; + let buf = read(&system_path(id)?)?; let text = String::from_utf8(buf)?; Ok(Source::new(id, text)) }) @@ -280,7 +279,7 @@ impl World for TestWorld { fn file(&self, id: FileId) -> FileResult { let slot = self.slot(id)?; slot.buffer - .get_or_init(|| read(&slot.system_path).map(Bytes::from)) + .get_or_init(|| read(&system_path(id)?).map(Bytes::from)) .clone() } @@ -303,16 +302,8 @@ impl TestWorld { } fn slot(&self, id: FileId) -> FileResult> { - let root: PathBuf = match id.package() { - Some(spec) => format!("packages/{}-{}", spec.name, spec.version).into(), - None => PathBuf::new(), - }; - - let system_path = id.vpath().resolve(&root).ok_or(FileError::AccessDenied)?; - Ok(RefMut::map(self.paths.borrow_mut(), |paths| { - paths.entry(system_path.clone()).or_insert_with(|| PathSlot { - system_path, + paths.entry(id).or_insert_with(|| PathSlot { source: OnceCell::new(), buffer: OnceCell::new(), }) @@ -320,7 +311,17 @@ impl TestWorld { } } -/// Read as file. +/// The file system path for a file ID. +fn system_path(id: FileId) -> FileResult { + let root: PathBuf = match id.package() { + Some(spec) => format!("packages/{}-{}", spec.name, spec.version).into(), + None => PathBuf::new(), + }; + + id.vpath().resolve(&root).ok_or(FileError::AccessDenied) +} + +/// Read a file. fn read(path: &Path) -> FileResult> { // Basically symlinks `assets/files` to `tests/files` so that the assets // are within the test project root.