diff --git a/Cargo.lock b/Cargo.lock index 2f651e200..f30056cfa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1468,7 +1468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d16814a067484415fda653868c9be0ac5f2abd2ef5d951082a5f2fe1b3662944" dependencies = [ "is-wsl", - "pathdiff", + "pathdiff 0.2.1", ] [[package]] @@ -1523,6 +1523,12 @@ version = "1.0.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f746c4065a8fa3fe23974dd82f15431cc8d40779821001404d10d2e79ca7d79" +[[package]] +name = "pathdiff" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3bf70094d203e07844da868b634207e71bfab254fe713171fae9a6e751ccf31" + [[package]] name = "pathdiff" version = "0.2.1" @@ -2631,6 +2637,7 @@ dependencies = [ "notify", "once_cell", "open", + "pathdiff 0.1.0", "same-file", "serde", "serde_json", diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml index 52439bebb..c5b38be63 100644 --- a/crates/typst-cli/Cargo.toml +++ b/crates/typst-cli/Cargo.toml @@ -33,6 +33,7 @@ memmap2 = "0.5" notify = "5" once_cell = "1" open = "4.0.2" +pathdiff = "0.1" same-file = "1" serde = "1.0.184" serde_json = "1" diff --git a/crates/typst-cli/src/compile.rs b/crates/typst-cli/src/compile.rs index 0fa66d62c..5b51f4223 100644 --- a/crates/typst-cli/src/compile.rs +++ b/crates/typst-cli/src/compile.rs @@ -231,11 +231,23 @@ pub fn print_diagnostics( impl<'a> codespan_reporting::files::Files<'a> for SystemWorld { type FileId = FileId; - type Name = FileId; + type Name = String; type Source = Source; fn name(&'a self, id: FileId) -> CodespanResult<Self::Name> { - Ok(id) + let vpath = id.vpath(); + Ok(if let Some(package) = id.package() { + format!("{package}{}", vpath.as_rooted_path().display()) + } else { + // Try to express the path relative to the working directory. + vpath + .resolve(self.root()) + .and_then(|abs| pathdiff::diff_paths(&abs, self.workdir())) + .as_deref() + .unwrap_or_else(|| vpath.as_rootless_path()) + .to_string_lossy() + .into() + }) } fn source(&'a self, id: FileId) -> CodespanResult<Self::Source> { diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index fb8fc0c73..cfbe37910 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -11,8 +11,7 @@ use siphasher::sip128::{Hasher128, SipHasher13}; use typst::diag::{FileError, FileResult, StrResult}; use typst::eval::{eco_format, Bytes, Datetime, Library}; use typst::font::{Font, FontBook}; -use typst::syntax::{FileId, Source}; -use typst::util::PathExt; +use typst::syntax::{FileId, Source, VirtualPath}; use typst::World; use crate::args::SharedArgs; @@ -21,6 +20,8 @@ use crate::package::prepare_package; /// A world that provides access to the operating system. pub struct SystemWorld { + /// The working directory. + workdir: Option<PathBuf>, /// The root relative to which absolute paths are resolved. root: PathBuf, /// The input path. @@ -49,7 +50,7 @@ impl SystemWorld { searcher.search(&command.font_paths); // Resolve the system-global input path. - let system_input = command.input.canonicalize().map_err(|_| { + let input = command.input.canonicalize().map_err(|_| { eco_format!("input file not found (searched at {})", command.input.display()) })?; @@ -58,22 +59,21 @@ impl SystemWorld { let path = command .root .as_deref() - .or_else(|| system_input.parent()) + .or_else(|| input.parent()) .unwrap_or(Path::new(".")); path.canonicalize().map_err(|_| { eco_format!("root directory not found (searched at {})", path.display()) })? }; - // Resolve the input path within the project. - let project_input = system_input - .strip_prefix(&root) - .map(|path| Path::new("/").join(path)) - .map_err(|_| "input file must be contained in project root")?; + // Resolve the virtual path of the main file within the project root. + let main_path = VirtualPath::within_root(&input, &root) + .ok_or("input file must be contained in project root")?; Ok(Self { + workdir: std::env::current_dir().ok(), root, - main: FileId::new(None, &project_input), + main: FileId::new(None, main_path), library: Prehashed::new(typst_library::build()), book: Prehashed::new(searcher.book), fonts: searcher.fonts, @@ -88,6 +88,16 @@ impl SystemWorld { self.main } + /// The root relative to which absolute paths are resolved. + pub fn root(&self) -> &Path { + &self.root + } + + /// The current working directory. + pub fn workdir(&self) -> &Path { + self.workdir.as_deref().unwrap_or(Path::new(".")) + } + /// Return all paths the last compilation depended on. pub fn dependencies(&mut self) -> impl Iterator<Item = &Path> { self.paths.get_mut().values().map(|slot| slot.system_path.as_path()) @@ -160,15 +170,16 @@ impl SystemWorld { .or_insert_with(|| { // Determine the root path relative to which the file path // will be resolved. - let root = match id.package() { - Some(spec) => prepare_package(spec)?, - None => self.root.clone(), - }; + 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 = - root.join_rooted(id.path()).ok_or(FileError::AccessDenied)?; + system_path = id.vpath().resolve(root).ok_or(FileError::AccessDenied)?; PathHash::new(&system_path) }) diff --git a/crates/typst-docs/src/html.rs b/crates/typst-docs/src/html.rs index 38e56c093..4bb1def15 100644 --- a/crates/typst-docs/src/html.rs +++ b/crates/typst-docs/src/html.rs @@ -7,7 +7,7 @@ use typst::diag::FileResult; use typst::eval::{Bytes, Datetime, Tracer}; use typst::font::{Font, FontBook}; use typst::geom::{Point, Size}; -use typst::syntax::{FileId, Source}; +use typst::syntax::{FileId, Source, VirtualPath}; use typst::World; use yaml_front_matter::YamlFrontMatter; @@ -424,7 +424,7 @@ fn code_block(resolver: &dyn Resolver, lang: &str, text: &str) -> Html { return Html::new(format!("<pre>{}</pre>", highlighted.as_str())); } - let id = FileId::new(None, Path::new("/main.typ")); + let id = FileId::new(None, VirtualPath::new("main.typ")); let source = Source::new(id, compile); let world = DocWorld(source); let mut tracer = Tracer::default(); @@ -498,8 +498,8 @@ impl World for DocWorld { fn file(&self, id: FileId) -> FileResult<Bytes> { assert!(id.package().is_none()); Ok(FILES - .get_file(id.path().strip_prefix("/").unwrap()) - .unwrap_or_else(|| panic!("failed to load {:?}", id.path().display())) + .get_file(id.vpath().as_rootless_path()) + .unwrap_or_else(|| panic!("failed to load {:?}", id.vpath())) .contents() .into()) } diff --git a/crates/typst-syntax/src/file.rs b/crates/typst-syntax/src/file.rs index fc1bed212..6b3117cfb 100644 --- a/crates/typst-syntax/src/file.rs +++ b/crates/typst-syntax/src/file.rs @@ -16,6 +16,9 @@ use super::is_ident; static INTERNER: Lazy<RwLock<Interner>> = Lazy::new(|| RwLock::new(Interner { to_id: HashMap::new(), from_id: Vec::new() })); +/// The path that we use for detached file ids. +static DETACHED_PATH: Lazy<VirtualPath> = Lazy::new(|| VirtualPath::new("/unknown")); + /// A package-path interner. struct Interner { to_id: HashMap<Pair, FileId>, @@ -23,7 +26,7 @@ struct Interner { } /// An interned pair of a package specification and a path. -type Pair = &'static (Option<PackageSpec>, PathBuf); +type Pair = &'static (Option<PackageSpec>, VirtualPath); /// Identifies a file in a project or package. /// @@ -37,16 +40,9 @@ impl FileId { /// The path must start with a `/` or this function will panic. /// Note that the path is normalized before interning. #[track_caller] - pub fn new(package: Option<PackageSpec>, path: &Path) -> Self { - assert_eq!( - path.components().next(), - Some(std::path::Component::RootDir), - "file path must be absolute within project or package: {}", - path.display(), - ); - + pub fn new(package: Option<PackageSpec>, path: VirtualPath) -> Self { // Try to find an existing entry that we can reuse. - let pair = (package, normalize_path(path)); + let pair = (package, path); if let Some(&id) = INTERNER.read().unwrap().to_id.get(&pair) { return id; } @@ -88,9 +84,9 @@ impl FileId { /// The absolute and normalized path to the file _within_ the project or /// package. - pub fn path(&self) -> &'static Path { + pub fn vpath(&self) -> &'static VirtualPath { if self.is_detached() { - Path::new("/detached.typ") + &DETACHED_PATH } else { &self.pair().1 } @@ -102,13 +98,7 @@ impl FileId { Err("cannot access file system from here")?; } - let package = self.package().cloned(); - let base = self.path(); - Ok(if let Some(parent) = base.parent() { - Self::new(package, &parent.join(path)) - } else { - Self::new(package, Path::new(path)) - }) + Ok(Self::new(self.package().cloned(), self.vpath().join(path))) } /// Construct from a raw number. @@ -127,47 +117,110 @@ impl FileId { } } -impl Display for FileId { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - let path = self.path().display(); +impl Debug for FileId { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + let vpath = self.vpath(); match self.package() { - Some(package) => write!(f, "{package}{path}"), - None => write!(f, "{path}"), + Some(package) => write!(f, "{package:?}{vpath:?}"), + None => write!(f, "{vpath:?}"), } } } -impl Debug for FileId { - fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { - Display::fmt(self, f) - } -} +/// An absolute path in the virtual file system of a project or package. +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct VirtualPath(PathBuf); -/// Lexically normalize a path. -fn normalize_path(path: &Path) -> PathBuf { - let mut out = PathBuf::new(); - for component in path.components() { - match component { - Component::CurDir => {} - Component::ParentDir => match out.components().next_back() { - Some(Component::Normal(_)) => { - out.pop(); - } - _ => out.push(component), - }, - Component::Prefix(_) | Component::RootDir | Component::Normal(_) => { - out.push(component) +impl VirtualPath { + /// Create a new virtual path. + /// + /// Even if it doesn't start with `/` or `\`, it is still interpreted as + /// starting from the root. + pub fn new(path: impl AsRef<Path>) -> Self { + Self::new_impl(path.as_ref()) + } + + /// Non generic new implementation. + fn new_impl(path: &Path) -> Self { + let mut out = Path::new(&Component::RootDir).to_path_buf(); + for component in path.components() { + match component { + Component::Prefix(_) | Component::RootDir => {} + Component::CurDir => {} + Component::ParentDir => match out.components().next_back() { + Some(Component::Normal(_)) => { + out.pop(); + } + _ => out.push(component), + }, + Component::Normal(_) => out.push(component), } } + Self(out) } - if out.as_os_str().is_empty() { - out.push(Component::CurDir); + + /// Create a virtual path from a real path and a real root. + /// + /// Returns `None` if the file path is not contained in the root (i.e. if + /// `root` is not a lexical prefix of `path`). No file system operations are + /// performed. + pub fn within_root(path: &Path, root: &Path) -> Option<Self> { + path.strip_prefix(root).ok().map(Self::new) + } + + /// Get the underlying path with a leading `/` or `\`. + pub fn as_rooted_path(&self) -> &Path { + &self.0 + } + + /// Get the underlying path without a leading `/` or `\`. + pub fn as_rootless_path(&self) -> &Path { + self.0.strip_prefix(Component::RootDir).unwrap_or(&self.0) + } + + /// Resolve the virtual path relative to an actual file system root + /// (where the project or package resides). + /// + /// Returns `None` if the path lexically escapes the root. The path might + /// still escape through symlinks. + pub fn resolve(&self, root: &Path) -> Option<PathBuf> { + let root_len = root.as_os_str().len(); + let mut out = root.to_path_buf(); + for component in self.0.components() { + match component { + Component::Prefix(_) => {} + Component::RootDir => {} + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + if out.as_os_str().len() < root_len { + return None; + } + } + Component::Normal(_) => out.push(component), + } + } + Some(out) + } + + /// Resolve a path relative to this virtual path. + pub fn join(&self, path: impl AsRef<Path>) -> Self { + if let Some(parent) = self.0.parent() { + Self::new(parent.join(path)) + } else { + Self::new(path) + } + } +} + +impl Debug for VirtualPath { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + Display::fmt(&self.0.display(), f) } - out } /// Identifies a package. -#[derive(Debug, Clone, Eq, PartialEq, Hash)] +#[derive(Clone, Eq, PartialEq, Hash)] pub struct PackageSpec { /// The namespace the package lives in. pub namespace: EcoString, @@ -217,6 +270,12 @@ impl FromStr for PackageSpec { } } +impl Debug for PackageSpec { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + Display::fmt(self, f) + } +} + impl Display for PackageSpec { fn fmt(&self, f: &mut Formatter) -> fmt::Result { write!(f, "@{}/{}:{}", self.namespace, self.name, self.version) @@ -224,7 +283,7 @@ impl Display for PackageSpec { } /// A package's version. -#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct PackageVersion { /// The package's major version. pub major: u32, @@ -259,6 +318,12 @@ impl FromStr for PackageVersion { } } +impl Debug for PackageVersion { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + Display::fmt(self, f) + } +} + impl Display for PackageVersion { fn fmt(&self, f: &mut Formatter) -> fmt::Result { write!(f, "{}.{}.{}", self.major, self.minor, self.patch) diff --git a/crates/typst-syntax/src/lib.rs b/crates/typst-syntax/src/lib.rs index 8562bb19b..dec9a751e 100644 --- a/crates/typst-syntax/src/lib.rs +++ b/crates/typst-syntax/src/lib.rs @@ -11,7 +11,7 @@ mod reparser; mod source; mod span; -pub use self::file::{FileId, PackageSpec, PackageVersion}; +pub use self::file::{FileId, PackageSpec, PackageVersion, VirtualPath}; pub use self::kind::SyntaxKind; pub use self::lexer::{is_id_continue, is_id_start, is_ident, is_newline}; pub use self::node::{LinkedChildren, LinkedNode, SyntaxError, SyntaxNode}; diff --git a/crates/typst-syntax/src/source.rs b/crates/typst-syntax/src/source.rs index 25b3b86c6..036499aba 100644 --- a/crates/typst-syntax/src/source.rs +++ b/crates/typst-syntax/src/source.rs @@ -241,7 +241,7 @@ impl Source { impl Debug for Source { fn fmt(&self, f: &mut Formatter) -> fmt::Result { - write!(f, "Source({})", self.id().path().display()) + write!(f, "Source({:?})", self.id().vpath()) } } diff --git a/crates/typst/src/eval/mod.rs b/crates/typst/src/eval/mod.rs index 72945ab87..c446f7b1e 100644 --- a/crates/typst/src/eval/mod.rs +++ b/crates/typst/src/eval/mod.rs @@ -63,7 +63,6 @@ pub use self::value::{Dynamic, Type, Value}; use std::collections::HashSet; use std::mem; -use std::path::Path; use comemo::{Track, Tracked, TrackedMut, Validate}; use ecow::{EcoString, EcoVec}; @@ -82,7 +81,7 @@ use crate::model::{ use crate::syntax::ast::{self, AstNode}; use crate::syntax::{ parse, parse_code, parse_math, FileId, PackageSpec, PackageVersion, Source, Span, - Spanned, SyntaxKind, SyntaxNode, + Spanned, SyntaxKind, SyntaxNode, VirtualPath, }; use crate::World; @@ -101,7 +100,7 @@ pub fn eval( // Prevent cyclic evaluation. let id = source.id(); if route.contains(id) { - panic!("Tried to cyclicly evaluate {}", id.path().display()); + panic!("Tried to cyclicly evaluate {:?}", id.vpath()); } // Hook up the lang items. @@ -141,7 +140,13 @@ pub fn eval( } // Assemble the module. - let name = id.path().file_stem().unwrap_or_default().to_string_lossy(); + let name = id + .vpath() + .as_rootless_path() + .file_stem() + .unwrap_or_default() + .to_string_lossy(); + Ok(Module::new(name).with_scope(vm.scopes.top).with_content(output)) } @@ -1798,7 +1803,7 @@ fn import( /// Import an external package. fn import_package(vm: &mut Vm, spec: PackageSpec, span: Span) -> SourceResult<Module> { // Evaluate the manifest. - let manifest_id = FileId::new(Some(spec.clone()), Path::new("/typst.toml")); + let manifest_id = FileId::new(Some(spec.clone()), VirtualPath::new("typst.toml")); let bytes = vm.world().file(manifest_id).at(span)?; let manifest = PackageManifest::parse(&bytes).at(span)?; manifest.validate(&spec).at(span)?; diff --git a/crates/typst/src/util/mod.rs b/crates/typst/src/util/mod.rs index a6e2d5ea7..1ba85bbb9 100644 --- a/crates/typst/src/util/mod.rs +++ b/crates/typst/src/util/mod.rs @@ -5,7 +5,6 @@ pub mod fat; use std::fmt::{self, Debug, Formatter}; use std::hash::Hash; use std::num::NonZeroUsize; -use std::path::{Component, Path, PathBuf}; use std::sync::Arc; use siphasher::sip128::{Hasher128, SipHasher13}; @@ -104,40 +103,6 @@ where } } -/// Extra methods for [`Path`]. -pub trait PathExt { - /// Treat `self` as a virtual root relative to which the `path` is resolved. - /// - /// Returns `None` if the path lexically escapes the root. The path - /// might still escape through symlinks. - fn join_rooted(&self, path: &Path) -> Option<PathBuf>; -} - -impl PathExt for Path { - fn join_rooted(&self, path: &Path) -> Option<PathBuf> { - let mut parts: Vec<_> = self.components().collect(); - let root = parts.len(); - for component in path.components() { - match component { - Component::Prefix(_) => return None, - Component::RootDir => parts.truncate(root), - Component::CurDir => {} - Component::ParentDir => { - if parts.len() <= root { - return None; - } - parts.pop(); - } - Component::Normal(_) => parts.push(component), - } - } - if parts.len() < root { - return None; - } - Some(parts.into_iter().collect()) - } -} - /// Format pieces separated with commas and a final "and" or "or". pub fn separated_list(pieces: &[impl AsRef<str>], last: &str) -> String { let mut buf = String::new(); diff --git a/tests/src/tests.rs b/tests/src/tests.rs index 16292ef2d..66c779008 100644 --- a/tests/src/tests.rs +++ b/tests/src/tests.rs @@ -25,8 +25,7 @@ use typst::doc::{Document, Frame, FrameItem, Meta}; use typst::eval::{eco_format, func, Bytes, Datetime, Library, NoneValue, Tracer, Value}; use typst::font::{Font, FontBook}; use typst::geom::{Abs, Color, RgbaColor, Smart}; -use typst::syntax::{FileId, Source, Span, SyntaxNode}; -use typst::util::PathExt; +use typst::syntax::{FileId, Source, Span, SyntaxNode, VirtualPath}; use typst::World; use typst_library::layout::{Margin, PageElem}; use typst_library::text::{TextElem, TextSize}; @@ -289,7 +288,7 @@ impl World for TestWorld { impl TestWorld { fn set(&mut self, path: &Path, text: String) -> Source { - self.main = FileId::new(None, &Path::new("/").join(path)); + self.main = FileId::new(None, VirtualPath::new(path)); let mut slot = self.slot(self.main).unwrap(); let source = Source::new(self.main, text); slot.source = OnceCell::from(Ok(source.clone())); @@ -302,7 +301,7 @@ impl TestWorld { None => PathBuf::new(), }; - let system_path = root.join_rooted(id.path()).ok_or(FileError::AccessDenied)?; + 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 {