//! Package manifest parsing. use std::collections::BTreeMap; use std::fmt::{self, Debug, Display, Formatter}; use std::str::FromStr; use ecow::{eco_format, EcoString}; use serde::de::IgnoredAny; use serde::{Deserialize, Deserializer, Serialize, Serializer}; use unscanny::Scanner; use crate::is_ident; /// A type alias for a map of key-value pairs used to collect unknown fields /// where values are completely discarded. pub type UnknownFields = BTreeMap; /// A parsed package manifest. /// /// The `unknown_fields` contains fields which were found but not expected. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct PackageManifest { /// Details about the package itself. pub package: PackageInfo, /// Details about the template, if the package is one. #[serde(default, skip_serializing_if = "Option::is_none")] pub template: Option, /// The tools section for third-party configuration. #[serde(default)] pub tool: ToolInfo, /// All parsed but unknown fields, this can be used for validation. #[serde(flatten, skip_serializing)] pub unknown_fields: UnknownFields, } /// The `[tool]` key in the manifest. This field can be used to retrieve /// 3rd-party tool configuration. /// /// # Examples /// ``` /// # use serde::{Deserialize, Serialize}; /// # use ecow::EcoString; /// # use typst_syntax::package::PackageManifest; /// #[derive(Debug, PartialEq, Serialize, Deserialize)] /// struct MyTool { /// key: EcoString, /// } /// /// let mut manifest: PackageManifest = toml::from_str(r#" /// [package] /// name = "package" /// version = "0.1.0" /// entrypoint = "src/lib.typ" /// /// [tool.my-tool] /// key = "value" /// "#)?; /// /// let my_tool = manifest /// .tool /// .sections /// .remove("my-tool") /// .ok_or("tool.my-tool section missing")?; /// let my_tool = MyTool::deserialize(my_tool)?; /// /// assert_eq!(my_tool, MyTool { key: "value".into() }); /// # Ok::<_, Box>(()) /// ``` #[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] pub struct ToolInfo { /// Any fields parsed in the tool section. #[serde(flatten)] pub sections: BTreeMap, } /// The `[template]` key in the manifest. /// /// The `unknown_fields` contains fields which were found but not expected. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct TemplateInfo { /// The directory within the package that contains the files that should be /// copied into the user's new project directory. pub path: EcoString, /// A path relative to the template's path that points to the file serving /// as the compilation target. pub entrypoint: EcoString, /// A path relative to the package's root that points to a PNG or lossless /// WebP thumbnail for the template. #[serde(default, skip_serializing_if = "Option::is_none")] pub thumbnail: Option, /// All parsed but unknown fields, this can be used for validation. #[serde(flatten, skip_serializing)] pub unknown_fields: UnknownFields, } /// The `[package]` key in the manifest. /// /// The `unknown_fields` contains fields which were found but not expected. #[derive(Debug, Clone, PartialEq, 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, /// A list of the package's authors. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub authors: Vec, /// The package's license. #[serde(default, skip_serializing_if = "Option::is_none")] pub license: Option, /// A short description of the package. #[serde(default, skip_serializing_if = "Option::is_none")] pub description: Option, /// A link to the package's web presence. #[serde(default, skip_serializing_if = "Option::is_none")] pub homepage: Option, /// A link to the repository where this package is developed. #[serde(default, skip_serializing_if = "Option::is_none")] pub repository: Option, /// An array of search keywords for the package. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub keywords: Vec, /// An array with up to three of the predefined categories to help users /// discover the package. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub categories: Vec, /// An array of disciplines defining the target audience for which the /// package is useful. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub disciplines: Vec, /// The minimum required compiler version for the package. #[serde(default, skip_serializing_if = "Option::is_none")] pub compiler: Option, /// An array of globs specifying files that should not be part of the /// published bundle. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub exclude: Vec, /// All parsed but unknown fields, this can be used for validation. #[serde(flatten, skip_serializing)] pub unknown_fields: UnknownFields, } 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.matches_ge(&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 PackageSpec { pub fn versionless(&self) -> VersionlessPackageSpec { VersionlessPackageSpec { namespace: self.namespace.clone(), name: self.name.clone(), } } } 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(), } } /// Performs an `==` match with the given version bound. Version elements /// missing in the bound are ignored. pub fn matches_eq(&self, bound: &VersionBound) -> bool { self.major == bound.major && bound.minor.map_or(true, |minor| self.minor == minor) && bound.patch.map_or(true, |patch| self.patch == patch) } /// Performs a `>` match with the given version bound. The match only /// succeeds if some version element in the bound is actually greater than /// that of the version. pub fn matches_gt(&self, bound: &VersionBound) -> bool { if self.major != bound.major { return self.major > bound.major; } let Some(minor) = bound.minor else { return false }; if self.minor != minor { return self.minor > minor; } let Some(patch) = bound.patch else { return false }; if self.patch != patch { return self.patch > patch; } false } /// Performs a `<` match with the given version bound. The match only /// succeeds if some version element in the bound is actually less than that /// of the version. pub fn matches_lt(&self, bound: &VersionBound) -> bool { if self.major != bound.major { return self.major < bound.major; } let Some(minor) = bound.minor else { return false }; if self.minor != minor { return self.minor < minor; } let Some(patch) = bound.patch else { return false }; if self.patch != patch { return self.patch < patch; } false } /// Performs a `>=` match with the given versions. The match succeeds when /// either a `==` or `>` match does. pub fn matches_ge(&self, bound: &VersionBound) -> bool { self.matches_eq(bound) || self.matches_gt(bound) } /// Performs a `<=` match with the given versions. The match succeeds when /// either a `==` or `<` match does. pub fn matches_le(&self, bound: &VersionBound) -> bool { self.matches_eq(bound) || self.matches_lt(bound) } } 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) } } /// A version bound for compatibility specification. #[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct VersionBound { /// The bounds's major version. pub major: u32, /// The bounds's minor version. pub minor: Option, /// The bounds's patch version. Can only be present if minor is too. pub patch: Option, } impl FromStr for VersionBound { type Err = EcoString; fn from_str(s: &str) -> Result { let mut parts = s.split('.'); let mut next = |kind| { if let Some(part) = parts.next() { part.parse::().map(Some).map_err(|_| { eco_format!("`{part}` is not a valid {kind} version bound") }) } else { Ok(None) } }; let major = next("major")? .ok_or_else(|| eco_format!("version bound is missing major version"))?; let minor = next("minor")?; let patch = next("patch")?; if let Some(rest) = parts.next() { Err(eco_format!("version bound has unexpected fourth component: `{rest}`"))?; } Ok(Self { major, minor, patch }) } } impl Debug for VersionBound { fn fmt(&self, f: &mut Formatter) -> fmt::Result { Display::fmt(self, f) } } impl Display for VersionBound { fn fmt(&self, f: &mut Formatter) -> fmt::Result { write!(f, "{}", self.major)?; if let Some(minor) = self.minor { write!(f, ".{minor}")?; } if let Some(patch) = self.patch { write!(f, ".{patch}")?; } Ok(()) } } impl Serialize for VersionBound { fn serialize(&self, s: S) -> Result { s.collect_str(self) } } impl<'de> Deserialize<'de> for VersionBound { fn deserialize>(d: D) -> Result { let string = EcoString::deserialize(d)?; string.parse().map_err(serde::de::Error::custom) } } #[cfg(test)] mod tests { use std::str::FromStr; use super::*; #[test] fn version_version_match() { let v1_1_1 = PackageVersion::from_str("1.1.1").unwrap(); assert!(v1_1_1.matches_eq(&VersionBound::from_str("1").unwrap())); assert!(v1_1_1.matches_eq(&VersionBound::from_str("1.1").unwrap())); assert!(!v1_1_1.matches_eq(&VersionBound::from_str("1.2").unwrap())); assert!(!v1_1_1.matches_gt(&VersionBound::from_str("1").unwrap())); assert!(v1_1_1.matches_gt(&VersionBound::from_str("1.0").unwrap())); assert!(!v1_1_1.matches_gt(&VersionBound::from_str("1.1").unwrap())); assert!(!v1_1_1.matches_lt(&VersionBound::from_str("1").unwrap())); assert!(!v1_1_1.matches_lt(&VersionBound::from_str("1.1").unwrap())); assert!(v1_1_1.matches_lt(&VersionBound::from_str("1.2").unwrap())); } #[test] fn minimal_manifest() { assert_eq!( toml::from_str::( r#" [package] name = "package" version = "0.1.0" entrypoint = "src/lib.typ" "# ), Ok(PackageManifest { package: PackageInfo { name: "package".into(), version: PackageVersion { major: 0, minor: 1, patch: 0 }, entrypoint: "src/lib.typ".into(), authors: vec![], license: None, description: None, homepage: None, repository: None, keywords: vec![], categories: vec![], disciplines: vec![], compiler: None, exclude: vec![], unknown_fields: BTreeMap::new(), }, template: None, tool: ToolInfo { sections: BTreeMap::new() }, unknown_fields: BTreeMap::new(), }) ); } #[test] fn tool_section() { // NOTE: tool section must be table of tables, but we can't easily // compare the error structurally assert!(toml::from_str::( r#" [package] name = "package" version = "0.1.0" entrypoint = "src/lib.typ" [tool] not-table = "str" "# ) .is_err()); #[derive(Debug, PartialEq, Serialize, Deserialize)] struct MyTool { key: EcoString, } let mut manifest: PackageManifest = toml::from_str( r#" [package] name = "package" version = "0.1.0" entrypoint = "src/lib.typ" [tool.my-tool] key = "value" "#, ) .unwrap(); let my_tool = manifest.tool.sections.remove("my-tool").unwrap(); let my_tool = MyTool::deserialize(my_tool).unwrap(); assert_eq!(my_tool, MyTool { key: "value".into() }); } #[test] fn unknown_keys() { let manifest: PackageManifest = toml::from_str( r#" [package] name = "package" version = "0.1.0" entrypoint = "src/lib.typ" [unknown] "#, ) .unwrap(); assert!(manifest.unknown_fields.contains_key("unknown")); } }