summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock8
-rw-r--r--Cargo.toml3
-rw-r--r--crates/typst-cli/Cargo.toml4
-rw-r--r--crates/typst-cli/src/args.rs18
-rw-r--r--crates/typst-cli/src/download.rs4
-rw-r--r--crates/typst-cli/src/init.rs114
-rw-r--r--crates/typst-cli/src/main.rs2
-rw-r--r--crates/typst-cli/src/package.rs81
-rw-r--r--crates/typst-cli/src/update.rs6
-rw-r--r--crates/typst-cli/src/watch.rs30
-rw-r--r--crates/typst-cli/src/world.rs3
-rw-r--r--crates/typst-syntax/src/file.rs236
-rw-r--r--crates/typst-syntax/src/lib.rs5
-rw-r--r--crates/typst-syntax/src/package.rs267
-rw-r--r--crates/typst-syntax/src/path.rs94
-rw-r--r--crates/typst/src/diag.rs5
-rw-r--r--crates/typst/src/eval/import.rs71
-rw-r--r--crates/typst/src/lib.rs3
-rw-r--r--tests/src/metadata.rs3
19 files changed, 614 insertions, 343 deletions
diff --git a/Cargo.lock b/Cargo.lock
index c7b1285a..48a96ac5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -742,6 +742,12 @@ dependencies = [
]
[[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"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -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 6736fee7..178dd14b 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 ab9ed6f2..e7005a71 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 973eea8b..71cbf515 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<Option<PathBuf>>,
}
+/// 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<String>,
+}
+
/// 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 67411812..38b16008 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 00000000..01fdb02f
--- /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<PackageManifest> {
+ 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 71b3dd38..3f5ca2aa 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 8141ad19..7d3f2264 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<PathBuf> {
let subdir =
@@ -25,30 +29,59 @@ pub fn prepare_package(spec: &PackageSpec) -> PackageResult<PathBuf> {
if let Some(cache_dir) = dirs::cache_dir() {
let dir = cache_dir.join(&subdir);
+ if dir.exists() {
+ return Ok(dir);
+ }
// Download from network if it doesn't exist yet.
- if spec.namespace == "preview" && !dir.exists() {
+ if spec.namespace == "preview" {
download_package(spec, &dir)?;
- }
-
- if dir.exists() {
- return Ok(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<PackageVersion> {
+ 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<Vec<PackageInfo>> {
+ 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 cfb93454..b33e0519 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 be2fa867..14ab2efd 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, " <stdin>"),
- Input::Path(path) => writeln!(term_out, " {}", path.display()),
+ Input::Stdin => writeln!(out, " <stdin>"),
+ 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 26bca325..3c4b8be6 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<PathBuf> {
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 6699f05d..b76cb9e3 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<RwLock<Interner>> =
@@ -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<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)
- }
-
- /// 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)
- }
-}
-
-/// 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<Self, Self::Err> {
- 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<Self, Self::Err> {
- 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::<u32>()
- .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<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
- s.collect_str(self)
- }
-}
-
-impl<'de> Deserialize<'de> for PackageVersion {
- fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
- 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 d93a8264..0ddb1460 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 00000000..138b39ca
--- /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<TemplateInfo>,
+}
+
+/// 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<PackageVersion>,
+}
+
+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<Self, Self::Err> {
+ 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<Self, Self::Err> {
+ 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<PackageVersion, EcoString> {
+ 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<Self, Self::Err> {
+ 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::<u32>()
+ .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<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
+ s.collect_str(self)
+ }
+}
+
+impl<'de> Deserialize<'de> for PackageVersion {
+ fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
+ 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 00000000..b561128c
--- /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<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)
+ }
+
+ /// 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)
+ }
+}
diff --git a/crates/typst/src/diag.rs b/crates/typst/src/diag.rs
index c70dd761..c6cd6acb 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 63bb469c..58857889 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<Mo
// Evaluate the manifest.
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)?;
+ let string = std::str::from_utf8(&bytes).map_err(FileError::from).at(span)?;
+ let manifest: PackageManifest = toml::from_str(string)
+ .map_err(|err| eco_format!("package manifest is malformed ({})", err.message()))
+ .at(span)?;
manifest.validate(&spec).at(span)?;
// Evaluate the entry point.
@@ -175,61 +176,3 @@ fn import_file(vm: &mut Vm, path: &str, span: Span) -> SourceResult<Module> {
)
.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<PackageVersion>,
-}
-
-impl PackageManifest {
- /// Parse the manifest from raw bytes.
- fn parse(bytes: &[u8]) -> StrResult<Self> {
- 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 bf900d94..3b09cb49 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 72a627f0..53cbbdff 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.