diff --git a/Cargo.lock b/Cargo.lock index c7b1285a9..48a96ac5b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -741,6 +741,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -2607,6 +2613,7 @@ dependencies = [ "env_proxy", "flate2", "fontdb", + "fs_extra", "native-tls", "notify", "once_cell", @@ -2624,6 +2631,7 @@ dependencies = [ "siphasher 1.0.0", "tar", "tempfile", + "toml", "typst", "typst-assets", "typst-macros", diff --git a/Cargo.toml b/Cargo.toml index 6736fee76..178dd14b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,6 +46,7 @@ ecow = { version = "0.2", features = ["serde"] } env_proxy = "0.4" flate2 = "1" fontdb = { version = "0.16", default-features = false } +fs_extra = "1.3" hayagriva = "0.5.1" heck = "0.4" hypher = "0.1.4" @@ -115,7 +116,7 @@ unicode-properties = "0.1" unicode-script = "0.5" unicode-segmentation = "1" unscanny = "0.1" -ureq = { version = "2", default-features = false, features = ["native-tls", "gzip"] } +ureq = { version = "2", default-features = false, features = ["native-tls", "gzip", "json"] } usvg = { version = "0.38.0", default-features = false, features = ["text"] } walkdir = "2" wasmi = "0.31.0" diff --git a/crates/typst-cli/Cargo.toml b/crates/typst-cli/Cargo.toml index ab9ed6f2e..e7005a718 100644 --- a/crates/typst-cli/Cargo.toml +++ b/crates/typst-cli/Cargo.toml @@ -37,6 +37,7 @@ ecow = { workspace = true } env_proxy = { workspace = true } flate2 = { workspace = true } fontdb = { workspace = true, features = ["memmap", "fontconfig"] } +fs_extra = { workspace = true } native-tls = { workspace = true } notify = { workspace = true } once_cell = { workspace = true } @@ -53,6 +54,7 @@ serde_yaml = { workspace = true } siphasher = { workspace = true } tar = { workspace = true } tempfile = { workspace = true } +toml = { workspace = true } ureq = { workspace = true } xz2 = { workspace = true, optional = true } zip = { workspace = true, optional = true } @@ -78,7 +80,7 @@ default = ["embed-fonts"] embed-fonts = [] # Permits the CLI to update itself without a package manager. -self-update = ["dep:self-replace", "dep:xz2", "dep:zip", "ureq/json"] +self-update = ["dep:self-replace", "dep:xz2", "dep:zip"] # Whether to vendor OpenSSL. Not applicable to Windows and macOS builds. vendor-openssl = ["openssl/vendored"] diff --git a/crates/typst-cli/src/args.rs b/crates/typst-cli/src/args.rs index 973eea8be..71cbf5157 100644 --- a/crates/typst-cli/src/args.rs +++ b/crates/typst-cli/src/args.rs @@ -46,6 +46,9 @@ pub enum Command { #[command(visible_alias = "w")] Watch(CompileCommand), + /// Initializes a new project from a template + Init(InitCommand), + /// Processes an input file to extract provided metadata Query(QueryCommand), @@ -89,6 +92,21 @@ pub struct CompileCommand { pub timings: Option>, } +/// Initializes a new project from a template +#[derive(Debug, Clone, Parser)] +pub struct InitCommand { + /// The template to use, e.g. `@preview/charged-ieee` + /// + /// You can specify the version by appending e.g. `:0.1.0`. If no version is + /// specified, Typst will default to the latest version. + /// + /// Supports both local and published templates. + pub template: String, + + /// The project directory, defaults to the template's name + pub dir: Option, +} + /// Processes an input file to extract provided metadata #[derive(Debug, Clone, Parser)] pub struct QueryCommand { diff --git a/crates/typst-cli/src/download.rs b/crates/typst-cli/src/download.rs index 674118121..38b160081 100644 --- a/crates/typst-cli/src/download.rs +++ b/crates/typst-cli/src/download.rs @@ -180,12 +180,12 @@ impl RemoteReader { (remaining / speed) as u64 })); writeln!( - &mut terminal::out(), + terminal::out(), "{total_downloaded} / {download_size} ({percent:3.0} %) {speed_h} in {elapsed} ETA: {eta}", )?; } None => writeln!( - &mut terminal::out(), + terminal::out(), "Total downloaded: {total_downloaded} Speed: {speed_h} Elapsed: {elapsed}", )?, }; diff --git a/crates/typst-cli/src/init.rs b/crates/typst-cli/src/init.rs new file mode 100644 index 000000000..01fdb02f0 --- /dev/null +++ b/crates/typst-cli/src/init.rs @@ -0,0 +1,114 @@ +use std::io::Write; +use std::path::Path; + +use codespan_reporting::term::termcolor::{Color, ColorSpec, WriteColor}; +use ecow::eco_format; +use fs_extra::dir::CopyOptions; +use typst::diag::{bail, FileError, StrResult}; +use typst::syntax::package::{ + PackageManifest, PackageSpec, TemplateInfo, VersionlessPackageSpec, +}; + +use crate::args::InitCommand; + +/// Execute an initialization command. +pub fn init(command: &InitCommand) -> StrResult<()> { + // Parse the package specification. If the user didn't specify the version, + // we try to figure it out automatically by downloading the package index + // or searching the disk. + let spec: PackageSpec = command.template.parse().or_else(|err| { + // Try to parse without version, but prefer the error message of the + // normal package spec parsing if it fails. + let spec: VersionlessPackageSpec = command.template.parse().map_err(|_| err)?; + let version = crate::package::determine_latest_version(&spec)?; + StrResult::Ok(spec.at(version)) + })?; + + // Find or download the package. + let package_path = crate::package::prepare_package(&spec)?; + + // Parse the manifest. + let manifest = parse_manifest(&package_path)?; + manifest.validate(&spec)?; + + // Ensure that it is indeed a template. + let Some(template) = &manifest.template else { + bail!("package {spec} is not a template"); + }; + + // Determine the directory at which we will create the project. + let project_dir = Path::new(command.dir.as_deref().unwrap_or(&manifest.package.name)); + + // Set up the project. + scaffold_project(project_dir, &package_path, template)?; + + // Print the summary. + print_summary(spec, project_dir, template).unwrap(); + + Ok(()) +} + +/// Parses the manifest of the package located at `package_path`. +fn parse_manifest(package_path: &Path) -> StrResult { + let toml_path = package_path.join("typst.toml"); + let string = std::fs::read_to_string(&toml_path).map_err(|err| { + eco_format!( + "failed to read package manifest ({})", + FileError::from_io(err, &toml_path) + ) + })?; + + toml::from_str(&string) + .map_err(|err| eco_format!("package manifest is malformed ({})", err.message())) +} + +/// Creates the project directory with the template's contents and returns the +/// path at which it was created. +fn scaffold_project( + project_dir: &Path, + package_path: &Path, + template: &TemplateInfo, +) -> StrResult<()> { + if project_dir.exists() { + bail!("project directory already exists (at {})", project_dir.display()); + } + + let template_dir = package_path.join(template.path.as_str()); + if !template_dir.exists() { + bail!("template directory does not exist (at {})", template_dir.display()); + } + + fs_extra::dir::copy( + &template_dir, + project_dir, + &CopyOptions::new().content_only(true), + ) + .map_err(|err| eco_format!("failed to create project directory ({err})"))?; + + Ok(()) +} + +/// Prints a summary after successful initialization. +fn print_summary( + spec: PackageSpec, + project_dir: &Path, + template: &TemplateInfo, +) -> std::io::Result<()> { + let mut gray = ColorSpec::new(); + gray.set_fg(Some(Color::White)); + gray.set_dimmed(true); + + let mut out = crate::terminal::out(); + writeln!(out, "Successfully created new project from {spec} 🎉")?; + writeln!(out, "To start writing, run:")?; + out.set_color(&gray)?; + write!(out, "> ")?; + out.reset()?; + writeln!(out, "cd {}", project_dir.display())?; + out.set_color(&gray)?; + write!(out, "> ")?; + out.reset()?; + writeln!(out, "typst watch {}", template.entrypoint)?; + writeln!(out)?; + Ok(()) +} diff --git a/crates/typst-cli/src/main.rs b/crates/typst-cli/src/main.rs index 71b3dd385..3f5ca2aaa 100644 --- a/crates/typst-cli/src/main.rs +++ b/crates/typst-cli/src/main.rs @@ -2,6 +2,7 @@ mod args; mod compile; mod download; mod fonts; +mod init; mod package; mod query; mod terminal; @@ -39,6 +40,7 @@ fn main() -> ExitCode { let res = match &ARGS.command { Command::Compile(command) => crate::compile::compile(timer, command.clone()), Command::Watch(command) => crate::watch::watch(timer, command.clone()), + Command::Init(command) => crate::init::init(command), Command::Query(command) => crate::query::query(command), Command::Fonts(command) => crate::fonts::fonts(command), Command::Update(command) => crate::update::update(command), diff --git a/crates/typst-cli/src/package.rs b/crates/typst-cli/src/package.rs index 8141ad191..7d3f2264e 100644 --- a/crates/typst-cli/src/package.rs +++ b/crates/typst-cli/src/package.rs @@ -5,12 +5,16 @@ use std::path::{Path, PathBuf}; use codespan_reporting::term::{self, termcolor}; use ecow::eco_format; use termcolor::WriteColor; -use typst::diag::{PackageError, PackageResult}; -use typst::syntax::PackageSpec; +use typst::diag::{bail, PackageError, PackageResult, StrResult}; +use typst::syntax::package::{ + PackageInfo, PackageSpec, PackageVersion, VersionlessPackageSpec, +}; -use crate::download::download_with_progress; +use crate::download::{download, download_with_progress}; use crate::terminal; +const HOST: &str = "https://packages.typst.org"; + /// Make a package available in the on-disk cache. pub fn prepare_package(spec: &PackageSpec) -> PackageResult { let subdir = @@ -25,30 +29,59 @@ pub fn prepare_package(spec: &PackageSpec) -> PackageResult { if let Some(cache_dir) = dirs::cache_dir() { let dir = cache_dir.join(&subdir); - - // Download from network if it doesn't exist yet. - if spec.namespace == "preview" && !dir.exists() { - download_package(spec, &dir)?; - } - if dir.exists() { return Ok(dir); } + + // Download from network if it doesn't exist yet. + if spec.namespace == "preview" { + download_package(spec, &dir)?; + if dir.exists() { + return Ok(dir); + } + } } Err(PackageError::NotFound(spec.clone())) } +/// Try to determine the latest version of a package. +pub fn determine_latest_version( + spec: &VersionlessPackageSpec, +) -> StrResult { + if spec.namespace == "preview" { + // For `@preview`, download the package index and find the latest + // version. + download_index()? + .iter() + .filter(|package| package.name == spec.name) + .map(|package| package.version) + .max() + .ok_or_else(|| eco_format!("failed to find package {spec}")) + } else { + // For other namespaces, search locally. We only search in the data + // directory and not the cache directory, because the latter is not + // intended for storage of local packages. + let subdir = format!("typst/packages/{}/{}", spec.namespace, spec.name); + dirs::data_dir() + .into_iter() + .flat_map(|dir| std::fs::read_dir(dir.join(&subdir)).ok()) + .flatten() + .filter_map(|entry| entry.ok()) + .map(|entry| entry.path()) + .filter_map(|path| path.file_name()?.to_string_lossy().parse().ok()) + .max() + .ok_or_else(|| eco_format!("please specify the desired version")) + } +} + /// Download a package over the network. fn download_package(spec: &PackageSpec, package_dir: &Path) -> PackageResult<()> { // The `@preview` namespace is the only namespace that supports on-demand // fetching. assert_eq!(spec.namespace, "preview"); - let url = format!( - "https://packages.typst.org/preview/{}-{}.tar.gz", - spec.name, spec.version - ); + let url = format!("{HOST}/preview/{}-{}.tar.gz", spec.name, spec.version); print_downloading(spec).unwrap(); @@ -67,14 +100,28 @@ fn download_package(spec: &PackageSpec, package_dir: &Path) -> PackageResult<()> }) } +/// Download the `@preview` package index. +fn download_index() -> StrResult> { + let url = format!("{HOST}/preview/index.json"); + match download(&url) { + Ok(response) => response + .into_json() + .map_err(|err| eco_format!("failed to parse package index: {err}")), + Err(ureq::Error::Status(404, _)) => { + bail!("failed to fetch package index (not found)") + } + Err(err) => bail!("failed to fetch package index ({err})"), + } +} + /// Print that a package downloading is happening. fn print_downloading(spec: &PackageSpec) -> io::Result<()> { let styles = term::Styles::default(); - let mut term_out = terminal::out(); - term_out.set_color(&styles.header_help)?; - write!(term_out, "downloading")?; + let mut out = terminal::out(); + out.set_color(&styles.header_help)?; + write!(out, "downloading")?; - term_out.reset()?; - writeln!(term_out, " {spec}") + out.reset()?; + writeln!(out, " {spec}") } diff --git a/crates/typst-cli/src/update.rs b/crates/typst-cli/src/update.rs index cfb93454b..b33e05194 100644 --- a/crates/typst-cli/src/update.rs +++ b/crates/typst-cli/src/update.rs @@ -110,9 +110,9 @@ impl Release { }; match download(&url) { - Ok(response) => response - .into_json() - .map_err(|err| eco_format!("unable to parse JSON response: {err}")), + Ok(response) => response.into_json().map_err(|err| { + eco_format!("failed to parse release information ({err})") + }), Err(ureq::Error::Status(404, _)) => { bail!("release not found (searched at {url})") } diff --git a/crates/typst-cli/src/watch.rs b/crates/typst-cli/src/watch.rs index be2fa8674..14ab2efda 100644 --- a/crates/typst-cli/src/watch.rs +++ b/crates/typst-cli/src/watch.rs @@ -273,27 +273,27 @@ impl Status { let timestamp = chrono::offset::Local::now().format("%H:%M:%S"); let color = self.color(); - let mut term_out = terminal::out(); - term_out.clear_screen()?; + let mut out = terminal::out(); + out.clear_screen()?; - term_out.set_color(&color)?; - write!(term_out, "watching")?; - term_out.reset()?; + out.set_color(&color)?; + write!(out, "watching")?; + out.reset()?; match &command.common.input { - Input::Stdin => writeln!(term_out, " "), - Input::Path(path) => writeln!(term_out, " {}", path.display()), + Input::Stdin => writeln!(out, " "), + Input::Path(path) => writeln!(out, " {}", path.display()), }?; - term_out.set_color(&color)?; - write!(term_out, "writing to")?; - term_out.reset()?; - writeln!(term_out, " {}", output.display())?; + out.set_color(&color)?; + write!(out, "writing to")?; + out.reset()?; + writeln!(out, " {}", output.display())?; - writeln!(term_out)?; - writeln!(term_out, "[{timestamp}] {}", self.message())?; - writeln!(term_out)?; + writeln!(out)?; + writeln!(out, "[{timestamp}] {}", self.message())?; + writeln!(out)?; - term_out.flush() + out.flush() } fn message(&self) -> String { diff --git a/crates/typst-cli/src/world.rs b/crates/typst-cli/src/world.rs index 26bca3251..3c4b8be67 100644 --- a/crates/typst-cli/src/world.rs +++ b/crates/typst-cli/src/world.rs @@ -19,7 +19,6 @@ use typst_timing::{timed, TimingScope}; use crate::args::{Input, SharedArgs}; use crate::compile::ExportCache; use crate::fonts::{FontSearcher, FontSlot}; -use crate::package::prepare_package; /// Static `FileId` allocated for stdin. /// This is to ensure that a file is read in the correct way. @@ -344,7 +343,7 @@ fn system_path(project_root: &Path, id: FileId) -> FileResult { let buf; let mut root = project_root; if let Some(spec) = id.package() { - buf = prepare_package(spec)?; + buf = crate::package::prepare_package(spec)?; root = &buf; } diff --git a/crates/typst-syntax/src/file.rs b/crates/typst-syntax/src/file.rs index 6699f05d7..b76cb9e39 100644 --- a/crates/typst-syntax/src/file.rs +++ b/crates/typst-syntax/src/file.rs @@ -1,16 +1,13 @@ //! File and package management. use std::collections::HashMap; -use std::fmt::{self, Debug, Display, Formatter}; -use std::path::{Component, Path, PathBuf}; -use std::str::FromStr; +use std::fmt::{self, Debug, Formatter}; use std::sync::RwLock; -use ecow::{eco_format, EcoString}; use once_cell::sync::Lazy; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use crate::is_ident; +use crate::package::PackageSpec; +use crate::VirtualPath; /// The global package-path interner. static INTERNER: Lazy> = @@ -116,230 +113,3 @@ impl Debug for FileId { } } } - -/// An absolute path in the virtual file system of a project or package. -#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] -pub struct VirtualPath(PathBuf); - -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) -> 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) - } - - /// 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 { - 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 { - 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) -> 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) - } -} - -/// Identifies a package. -#[derive(Clone, Eq, PartialEq, Hash)] -pub struct PackageSpec { - /// The namespace the package lives in. - pub namespace: EcoString, - /// The name of the package within its namespace. - pub name: EcoString, - /// The package's version. - pub version: PackageVersion, -} - -impl FromStr for PackageSpec { - type Err = EcoString; - - fn from_str(s: &str) -> Result { - let mut s = unscanny::Scanner::new(s); - if !s.eat_if('@') { - Err("package specification must start with '@'")?; - } - - let namespace = s.eat_until('/'); - if namespace.is_empty() { - Err("package specification is missing namespace")?; - } else if !is_ident(namespace) { - Err(eco_format!("`{namespace}` is not a valid package namespace"))?; - } - - s.eat_if('/'); - - let name = s.eat_until(':'); - if name.is_empty() { - Err("package specification is missing name")?; - } else if !is_ident(name) { - Err(eco_format!("`{name}` is not a valid package name"))?; - } - - s.eat_if(':'); - - let version = s.after(); - if version.is_empty() { - Err("package specification is missing version")?; - } - - Ok(Self { - namespace: namespace.into(), - name: name.into(), - version: version.parse()?, - }) - } -} - -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) - } -} - -/// A package's version. -#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] -pub struct PackageVersion { - /// The package's major version. - pub major: u32, - /// The package's minor version. - pub minor: u32, - /// The package's patch version. - pub patch: u32, -} - -impl PackageVersion { - /// The current compiler version. - pub fn compiler() -> Self { - Self { - major: env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap(), - minor: env!("CARGO_PKG_VERSION_MINOR").parse().unwrap(), - patch: env!("CARGO_PKG_VERSION_PATCH").parse().unwrap(), - } - } -} - -impl FromStr for PackageVersion { - type Err = EcoString; - - fn from_str(s: &str) -> Result { - let mut parts = s.split('.'); - let mut next = |kind| { - let part = parts - .next() - .filter(|s| !s.is_empty()) - .ok_or_else(|| eco_format!("version number is missing {kind} version"))?; - part.parse::() - .map_err(|_| eco_format!("`{part}` is not a valid {kind} version")) - }; - - let major = next("major")?; - let minor = next("minor")?; - let patch = next("patch")?; - if let Some(rest) = parts.next() { - Err(eco_format!("version number has unexpected fourth component: `{rest}`"))?; - } - - Ok(Self { major, minor, patch }) - } -} - -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) - } -} - -impl Serialize for PackageVersion { - fn serialize(&self, s: S) -> Result { - s.collect_str(self) - } -} - -impl<'de> Deserialize<'de> for PackageVersion { - fn deserialize>(d: D) -> Result { - let string = EcoString::deserialize(d)?; - string.parse().map_err(serde::de::Error::custom) - } -} diff --git a/crates/typst-syntax/src/lib.rs b/crates/typst-syntax/src/lib.rs index d93a82641..0ddb14608 100644 --- a/crates/typst-syntax/src/lib.rs +++ b/crates/typst-syntax/src/lib.rs @@ -1,6 +1,7 @@ //! Parser and syntax tree for Typst. pub mod ast; +pub mod package; mod file; mod highlight; @@ -8,12 +9,13 @@ mod kind; mod lexer; mod node; mod parser; +mod path; mod reparser; mod set; mod source; mod span; -pub use self::file::{FileId, PackageSpec, PackageVersion, VirtualPath}; +pub use self::file::FileId; pub use self::highlight::{highlight, highlight_html, Tag}; pub use self::kind::SyntaxKind; pub use self::lexer::{ @@ -21,6 +23,7 @@ pub use self::lexer::{ }; pub use self::node::{LinkedChildren, LinkedNode, SyntaxError, SyntaxNode}; pub use self::parser::{parse, parse_code, parse_math}; +pub use self::path::VirtualPath; pub use self::source::Source; pub use self::span::{Span, Spanned}; diff --git a/crates/typst-syntax/src/package.rs b/crates/typst-syntax/src/package.rs new file mode 100644 index 000000000..138b39ca1 --- /dev/null +++ b/crates/typst-syntax/src/package.rs @@ -0,0 +1,267 @@ +//! Package manifest parsing. + +use std::fmt::{self, Debug, Display, Formatter}; +use std::str::FromStr; + +use ecow::{eco_format, EcoString}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use unscanny::Scanner; + +use crate::is_ident; + +/// A parsed package manifest. +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct PackageManifest { + /// Details about the package itself. + pub package: PackageInfo, + /// Details about the template, if the package is one. + #[serde(skip_serializing_if = "Option::is_none")] + pub template: Option, +} + +/// The `[template]` key in the manifest. +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct TemplateInfo { + /// The path of the starting point within the package. + pub path: EcoString, + /// The path of the entrypoint relative to the starting point's `path`. + pub entrypoint: EcoString, +} + +/// The `[package]` key in the manifest. +/// +/// More fields are specified, but they are not relevant to the compiler. +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub struct PackageInfo { + /// The name of the package within its namespace. + pub name: EcoString, + /// The package's version. + pub version: PackageVersion, + /// The path of the entrypoint into the package. + pub entrypoint: EcoString, + /// The minimum required compiler version for the package. + pub compiler: Option, +} + +impl PackageManifest { + /// Ensure that this manifest is indeed for the specified package. + pub fn validate(&self, spec: &PackageSpec) -> Result<(), EcoString> { + if self.package.name != spec.name { + return Err(eco_format!( + "package manifest contains mismatched name `{}`", + self.package.name + )); + } + + if self.package.version != spec.version { + return Err(eco_format!( + "package manifest contains mismatched version {}", + self.package.version + )); + } + + if let Some(required) = self.package.compiler { + let current = PackageVersion::compiler(); + if current < required { + return Err(eco_format!( + "package requires typst {required} or newer \ + (current version is {current})" + )); + } + } + + Ok(()) + } +} + +/// Identifies a package. +#[derive(Clone, Eq, PartialEq, Hash)] +pub struct PackageSpec { + /// The namespace the package lives in. + pub namespace: EcoString, + /// The name of the package within its namespace. + pub name: EcoString, + /// The package's version. + pub version: PackageVersion, +} + +impl FromStr for PackageSpec { + type Err = EcoString; + + fn from_str(s: &str) -> Result { + let mut s = unscanny::Scanner::new(s); + let namespace = parse_namespace(&mut s)?.into(); + let name = parse_name(&mut s)?.into(); + let version = parse_version(&mut s)?; + Ok(Self { namespace, name, version }) + } +} + +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) + } +} + +/// Identifies a package, but not a specific version of it. +#[derive(Clone, Eq, PartialEq, Hash)] +pub struct VersionlessPackageSpec { + /// The namespace the package lives in. + pub namespace: EcoString, + /// The name of the package within its namespace. + pub name: EcoString, +} + +impl VersionlessPackageSpec { + /// Fill in the `version` to get a complete [`PackageSpec`]. + pub fn at(self, version: PackageVersion) -> PackageSpec { + PackageSpec { + namespace: self.namespace, + name: self.name, + version, + } + } +} + +impl FromStr for VersionlessPackageSpec { + type Err = EcoString; + + fn from_str(s: &str) -> Result { + let mut s = unscanny::Scanner::new(s); + let namespace = parse_namespace(&mut s)?.into(); + let name = parse_name(&mut s)?.into(); + if !s.done() { + Err("unexpected version in versionless package specification")?; + } + Ok(Self { namespace, name }) + } +} + +impl Debug for VersionlessPackageSpec { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + Display::fmt(self, f) + } +} + +impl Display for VersionlessPackageSpec { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "@{}/{}", self.namespace, self.name) + } +} + +fn parse_namespace<'s>(s: &mut Scanner<'s>) -> Result<&'s str, EcoString> { + if !s.eat_if('@') { + Err("package specification must start with '@'")?; + } + + let namespace = s.eat_until('/'); + if namespace.is_empty() { + Err("package specification is missing namespace")?; + } else if !is_ident(namespace) { + Err(eco_format!("`{namespace}` is not a valid package namespace"))?; + } + + Ok(namespace) +} + +fn parse_name<'s>(s: &mut Scanner<'s>) -> Result<&'s str, EcoString> { + s.eat_if('/'); + + let name = s.eat_until(':'); + if name.is_empty() { + Err("package specification is missing name")?; + } else if !is_ident(name) { + Err(eco_format!("`{name}` is not a valid package name"))?; + } + + Ok(name) +} + +fn parse_version(s: &mut Scanner) -> Result { + s.eat_if(':'); + + let version = s.after(); + if version.is_empty() { + Err("package specification is missing version")?; + } + + version.parse() +} + +/// A package's version. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct PackageVersion { + /// The package's major version. + pub major: u32, + /// The package's minor version. + pub minor: u32, + /// The package's patch version. + pub patch: u32, +} + +impl PackageVersion { + /// The current compiler version. + pub fn compiler() -> Self { + Self { + major: env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap(), + minor: env!("CARGO_PKG_VERSION_MINOR").parse().unwrap(), + patch: env!("CARGO_PKG_VERSION_PATCH").parse().unwrap(), + } + } +} + +impl FromStr for PackageVersion { + type Err = EcoString; + + fn from_str(s: &str) -> Result { + let mut parts = s.split('.'); + let mut next = |kind| { + let part = parts + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| eco_format!("version number is missing {kind} version"))?; + part.parse::() + .map_err(|_| eco_format!("`{part}` is not a valid {kind} version")) + }; + + let major = next("major")?; + let minor = next("minor")?; + let patch = next("patch")?; + if let Some(rest) = parts.next() { + Err(eco_format!("version number has unexpected fourth component: `{rest}`"))?; + } + + Ok(Self { major, minor, patch }) + } +} + +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) + } +} + +impl Serialize for PackageVersion { + fn serialize(&self, s: S) -> Result { + s.collect_str(self) + } +} + +impl<'de> Deserialize<'de> for PackageVersion { + fn deserialize>(d: D) -> Result { + let string = EcoString::deserialize(d)?; + string.parse().map_err(serde::de::Error::custom) + } +} diff --git a/crates/typst-syntax/src/path.rs b/crates/typst-syntax/src/path.rs new file mode 100644 index 000000000..b561128c1 --- /dev/null +++ b/crates/typst-syntax/src/path.rs @@ -0,0 +1,94 @@ +use std::fmt::{self, Debug, Display, Formatter}; +use std::path::{Component, Path, PathBuf}; + +/// An absolute path in the virtual file system of a project or package. +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct VirtualPath(PathBuf); + +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) -> 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) + } + + /// 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 { + 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 { + 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) -> 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) + } +} diff --git a/crates/typst/src/diag.rs b/crates/typst/src/diag.rs index c70dd761b..c6cd6acb4 100644 --- a/crates/typst/src/diag.rs +++ b/crates/typst/src/diag.rs @@ -9,7 +9,8 @@ use std::string::FromUtf8Error; use comemo::Tracked; use ecow::{eco_vec, EcoVec}; -use crate::syntax::{PackageSpec, Span, Spanned, SyntaxError}; +use crate::syntax::package::PackageSpec; +use crate::syntax::{Span, Spanned, SyntaxError}; use crate::{World, WorldExt}; /// Early-return with a [`StrResult`] or [`SourceResult`]. @@ -497,7 +498,7 @@ pub fn format_xml_like_error(format: &str, error: roxmltree::Error) -> EcoString } roxmltree::Error::DuplicatedAttribute(attr, pos) => { eco_format!( - "failed to parse {format}: (duplicate attribute '{attr}' in line {})", + "failed to parse {format} (duplicate attribute '{attr}' in line {})", pos.row ) } diff --git a/crates/typst/src/eval/import.rs b/crates/typst/src/eval/import.rs index 63bb469c3..588578891 100644 --- a/crates/typst/src/eval/import.rs +++ b/crates/typst/src/eval/import.rs @@ -1,14 +1,12 @@ use comemo::TrackedMut; use ecow::{eco_format, eco_vec, EcoString}; -use serde::{Deserialize, Serialize}; -use crate::diag::{ - bail, error, warning, At, FileError, SourceResult, StrResult, Trace, Tracepoint, -}; +use crate::diag::{bail, error, warning, At, FileError, SourceResult, Trace, Tracepoint}; use crate::eval::{eval, Eval, Vm}; use crate::foundations::{Content, Module, Value}; use crate::syntax::ast::{self, AstNode}; -use crate::syntax::{FileId, PackageSpec, PackageVersion, Span, VirtualPath}; +use crate::syntax::package::{PackageManifest, PackageSpec}; +use crate::syntax::{FileId, Span, VirtualPath}; use crate::World; impl Eval for ast::ModuleImport<'_> { @@ -136,7 +134,10 @@ fn import_package(vm: &mut Vm, spec: PackageSpec, span: Span) -> SourceResult SourceResult { ) .trace(world, point, span) } - -/// A parsed package manifest. -#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] -struct PackageManifest { - /// Details about the package itself. - package: PackageInfo, -} - -/// The `package` key in the manifest. -/// -/// More fields are specified, but they are not relevant to the compiler. -#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] -struct PackageInfo { - /// The name of the package within its namespace. - name: EcoString, - /// The package's version. - version: PackageVersion, - /// The path of the entrypoint into the package. - entrypoint: EcoString, - /// The minimum required compiler version for the package. - compiler: Option, -} - -impl PackageManifest { - /// Parse the manifest from raw bytes. - fn parse(bytes: &[u8]) -> StrResult { - let string = std::str::from_utf8(bytes).map_err(FileError::from)?; - toml::from_str(string).map_err(|err| { - eco_format!("package manifest is malformed: {}", err.message()) - }) - } - - /// Ensure that this manifest is indeed for the specified package. - fn validate(&self, spec: &PackageSpec) -> StrResult<()> { - if self.package.name != spec.name { - bail!("package manifest contains mismatched name `{}`", self.package.name); - } - - if self.package.version != spec.version { - bail!( - "package manifest contains mismatched version {}", - self.package.version - ); - } - - if let Some(compiler) = self.package.compiler { - let current = PackageVersion::compiler(); - if current < compiler { - bail!( - "package requires typst {compiler} or newer \ - (current version is {current})" - ); - } - } - - Ok(()) - } -} diff --git a/crates/typst/src/lib.rs b/crates/typst/src/lib.rs index bf900d94a..3b09cb494 100644 --- a/crates/typst/src/lib.rs +++ b/crates/typst/src/lib.rs @@ -72,7 +72,8 @@ use crate::foundations::{ use crate::introspection::{Introspector, Locator}; use crate::layout::{Alignment, Dir, LayoutRoot}; use crate::model::Document; -use crate::syntax::{FileId, PackageSpec, Source, Span}; +use crate::syntax::package::PackageSpec; +use crate::syntax::{FileId, Source, Span}; use crate::text::{Font, FontBook}; use crate::visualize::Color; diff --git a/tests/src/metadata.rs b/tests/src/metadata.rs index 72a627f05..53cbbdffb 100644 --- a/tests/src/metadata.rs +++ b/tests/src/metadata.rs @@ -4,7 +4,8 @@ use std::ops::Range; use std::str::FromStr; use ecow::EcoString; -use typst::syntax::{PackageVersion, Source}; +use typst::syntax::package::PackageVersion; +use typst::syntax::Source; use unscanny::Scanner; /// Each test and subset may contain metadata.