Skip to main content

typst_syntax/
package.rs

1//! Package manifest parsing.
2
3use std::collections::BTreeMap;
4use std::fmt::{self, Debug, Display, Formatter};
5use std::str::FromStr;
6
7use ecow::{EcoString, eco_format};
8use serde::de::IgnoredAny;
9use serde::{Deserialize, Deserializer, Serialize, Serializer};
10use unscanny::Scanner;
11
12use crate::is_ident;
13
14/// A type alias for a map of key-value pairs used to collect unknown fields
15/// where values are completely discarded.
16pub type UnknownFields = BTreeMap<EcoString, IgnoredAny>;
17
18/// A parsed package manifest.
19///
20/// The `unknown_fields` contains fields which were found but not expected.
21#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
22pub struct PackageManifest {
23    /// Details about the package itself.
24    pub package: PackageInfo,
25    /// Details about the template, if the package is one.
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub template: Option<TemplateInfo>,
28    /// The tools section for third-party configuration.
29    #[serde(default)]
30    pub tool: ToolInfo,
31    /// All parsed but unknown fields, this can be used for validation.
32    #[serde(flatten, skip_serializing)]
33    pub unknown_fields: UnknownFields,
34}
35
36/// The `[tool]` key in the manifest. This field can be used to retrieve
37/// 3rd-party tool configuration.
38///
39/// # Examples
40/// ```
41/// # use serde::{Deserialize, Serialize};
42/// # use ecow::EcoString;
43/// # use typst_syntax::package::PackageManifest;
44/// #[derive(Debug, PartialEq, Serialize, Deserialize)]
45/// struct MyTool {
46///     key: EcoString,
47/// }
48///
49/// let mut manifest: PackageManifest = toml::from_str(r#"
50///     [package]
51///     name = "package"
52///     version = "0.1.0"
53///     entrypoint = "src/lib.typ"
54///
55///     [tool.my-tool]
56///     key = "value"
57/// "#)?;
58///
59/// let my_tool = manifest
60///     .tool
61///     .sections
62///     .remove("my-tool")
63///     .ok_or("tool.my-tool section missing")?;
64/// let my_tool = MyTool::deserialize(my_tool)?;
65///
66/// assert_eq!(my_tool, MyTool { key: "value".into() });
67/// # Ok::<_, Box<dyn std::error::Error>>(())
68/// ```
69#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
70pub struct ToolInfo {
71    /// Any fields parsed in the tool section.
72    #[serde(flatten)]
73    pub sections: BTreeMap<EcoString, toml::Table>,
74}
75
76/// The `[template]` key in the manifest.
77///
78/// The `unknown_fields` contains fields which were found but not expected.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
80pub struct TemplateInfo {
81    /// The directory within the package that contains the files that should be
82    /// copied into the user's new project directory.
83    pub path: EcoString,
84    /// A path relative to the template's path that points to the file serving
85    /// as the compilation target.
86    pub entrypoint: EcoString,
87    /// A path relative to the package's root that points to a PNG or lossless
88    /// WebP thumbnail for the template.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub thumbnail: Option<EcoString>,
91    /// All parsed but unknown fields, this can be used for validation.
92    #[serde(flatten, skip_serializing)]
93    pub unknown_fields: UnknownFields,
94}
95
96/// The `[package]` key in the manifest.
97///
98/// The `unknown_fields` contains fields which were found but not expected.
99#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
100pub struct PackageInfo {
101    /// The name of the package within its namespace.
102    pub name: EcoString,
103    /// The package's version.
104    pub version: PackageVersion,
105    /// The path of the entrypoint into the package.
106    pub entrypoint: EcoString,
107    /// A list of the package's authors.
108    #[serde(default, skip_serializing_if = "Vec::is_empty")]
109    pub authors: Vec<EcoString>,
110    ///  The package's license.
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub license: Option<EcoString>,
113    /// A short description of the package.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub description: Option<EcoString>,
116    /// A link to the package's web presence.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub homepage: Option<EcoString>,
119    /// A link to the repository where this package is developed.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub repository: Option<EcoString>,
122    /// An array of search keywords for the package.
123    #[serde(default, skip_serializing_if = "Vec::is_empty")]
124    pub keywords: Vec<EcoString>,
125    /// An array with up to three of the predefined categories to help users
126    /// discover the package.
127    #[serde(default, skip_serializing_if = "Vec::is_empty")]
128    pub categories: Vec<EcoString>,
129    /// An array of disciplines defining the target audience for which the
130    /// package is useful.
131    #[serde(default, skip_serializing_if = "Vec::is_empty")]
132    pub disciplines: Vec<EcoString>,
133    /// The minimum required compiler version for the package.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub compiler: Option<VersionBound>,
136    /// An array of globs specifying files that should not be part of the
137    /// published bundle.
138    #[serde(default, skip_serializing_if = "Vec::is_empty")]
139    pub exclude: Vec<EcoString>,
140    /// All parsed but unknown fields, this can be used for validation.
141    #[serde(flatten, skip_serializing)]
142    pub unknown_fields: UnknownFields,
143}
144
145impl PackageManifest {
146    /// Create a new package manifest with the given package info.
147    pub fn new(package: PackageInfo) -> Self {
148        PackageManifest {
149            package,
150            template: None,
151            tool: ToolInfo::default(),
152            unknown_fields: UnknownFields::new(),
153        }
154    }
155
156    /// Ensure that this manifest is indeed for the specified package.
157    pub fn validate(&self, spec: &PackageSpec) -> Result<(), EcoString> {
158        if self.package.name != spec.name {
159            return Err(eco_format!(
160                "package manifest contains mismatched name `{}`",
161                self.package.name
162            ));
163        }
164
165        if self.package.version != spec.version {
166            return Err(eco_format!(
167                "package manifest contains mismatched version {}",
168                self.package.version
169            ));
170        }
171
172        if let Some(required) = self.package.compiler {
173            let current = PackageVersion::compiler();
174            if !current.matches_ge(&required) {
175                return Err(eco_format!(
176                    "package requires Typst {required} or newer \
177                     (current version is {current})"
178                ));
179            }
180        }
181
182        Ok(())
183    }
184}
185
186impl TemplateInfo {
187    /// Create a new template info with only required fields.
188    pub fn new(path: impl Into<EcoString>, entrypoint: impl Into<EcoString>) -> Self {
189        TemplateInfo {
190            path: path.into(),
191            entrypoint: entrypoint.into(),
192            thumbnail: None,
193            unknown_fields: UnknownFields::new(),
194        }
195    }
196}
197
198impl PackageInfo {
199    /// Create a new package info with only required fields.
200    pub fn new(
201        name: impl Into<EcoString>,
202        version: PackageVersion,
203        entrypoint: impl Into<EcoString>,
204    ) -> Self {
205        PackageInfo {
206            name: name.into(),
207            version,
208            entrypoint: entrypoint.into(),
209            authors: vec![],
210            categories: vec![],
211            compiler: None,
212            description: None,
213            disciplines: vec![],
214            exclude: vec![],
215            homepage: None,
216            keywords: vec![],
217            license: None,
218            repository: None,
219            unknown_fields: BTreeMap::new(),
220        }
221    }
222}
223
224/// Identifies a package.
225#[derive(Clone, Eq, PartialEq, Hash)]
226pub struct PackageSpec {
227    /// The namespace the package lives in.
228    pub namespace: EcoString,
229    /// The name of the package within its namespace.
230    pub name: EcoString,
231    /// The package's version.
232    pub version: PackageVersion,
233}
234
235impl PackageSpec {
236    pub fn versionless(&self) -> VersionlessPackageSpec {
237        VersionlessPackageSpec {
238            namespace: self.namespace.clone(),
239            name: self.name.clone(),
240        }
241    }
242}
243
244impl FromStr for PackageSpec {
245    type Err = EcoString;
246
247    fn from_str(s: &str) -> Result<Self, Self::Err> {
248        let mut s = unscanny::Scanner::new(s);
249        let namespace = parse_namespace(&mut s)?.into();
250        let name = parse_name(&mut s)?.into();
251        let version = parse_version(&mut s)?;
252        Ok(Self { namespace, name, version })
253    }
254}
255
256impl Debug for PackageSpec {
257    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
258        Display::fmt(self, f)
259    }
260}
261
262impl Display for PackageSpec {
263    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
264        write!(f, "@{}/{}:{}", self.namespace, self.name, self.version)
265    }
266}
267
268/// Identifies a package, but not a specific version of it.
269#[derive(Clone, Eq, PartialEq, Hash)]
270pub struct VersionlessPackageSpec {
271    /// The namespace the package lives in.
272    pub namespace: EcoString,
273    /// The name of the package within its namespace.
274    pub name: EcoString,
275}
276
277impl VersionlessPackageSpec {
278    /// Fill in the `version` to get a complete [`PackageSpec`].
279    pub fn at(self, version: PackageVersion) -> PackageSpec {
280        PackageSpec {
281            namespace: self.namespace,
282            name: self.name,
283            version,
284        }
285    }
286}
287
288impl FromStr for VersionlessPackageSpec {
289    type Err = EcoString;
290
291    fn from_str(s: &str) -> Result<Self, Self::Err> {
292        let mut s = unscanny::Scanner::new(s);
293        let namespace = parse_namespace(&mut s)?.into();
294        let name = parse_name(&mut s)?.into();
295        if !s.done() {
296            Err("unexpected version in versionless package specification")?;
297        }
298        Ok(Self { namespace, name })
299    }
300}
301
302impl Debug for VersionlessPackageSpec {
303    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
304        Display::fmt(self, f)
305    }
306}
307
308impl Display for VersionlessPackageSpec {
309    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
310        write!(f, "@{}/{}", self.namespace, self.name)
311    }
312}
313
314fn parse_namespace<'s>(s: &mut Scanner<'s>) -> Result<&'s str, EcoString> {
315    if !s.eat_if('@') {
316        Err("package specification must start with '@'")?;
317    }
318
319    let namespace = s.eat_until('/');
320    if namespace.is_empty() {
321        Err("package specification is missing namespace")?;
322    } else if !is_ident(namespace) {
323        Err(eco_format!("`{namespace}` is not a valid package namespace"))?;
324    }
325
326    Ok(namespace)
327}
328
329fn parse_name<'s>(s: &mut Scanner<'s>) -> Result<&'s str, EcoString> {
330    s.eat_if('/');
331
332    let name = s.eat_until(':');
333    if name.is_empty() {
334        Err("package specification is missing name")?;
335    } else if !is_ident(name) {
336        Err(eco_format!("`{name}` is not a valid package name"))?;
337    }
338
339    Ok(name)
340}
341
342fn parse_version(s: &mut Scanner) -> Result<PackageVersion, EcoString> {
343    s.eat_if(':');
344
345    let version = s.after();
346    if version.is_empty() {
347        Err("package specification is missing version")?;
348    }
349
350    version.parse()
351}
352
353/// A package's version.
354#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
355pub struct PackageVersion {
356    /// The package's major version.
357    pub major: u32,
358    /// The package's minor version.
359    pub minor: u32,
360    /// The package's patch version.
361    pub patch: u32,
362}
363
364impl PackageVersion {
365    /// The current compiler version.
366    pub fn compiler() -> Self {
367        let typst_version = typst_utils::version();
368        Self {
369            major: typst_version.major(),
370            minor: typst_version.minor(),
371            patch: typst_version.patch(),
372        }
373    }
374
375    /// Performs an `==` match with the given version bound. Version elements
376    /// missing in the bound are ignored.
377    pub fn matches_eq(&self, bound: &VersionBound) -> bool {
378        self.major == bound.major
379            && bound.minor.is_none_or(|minor| self.minor == minor)
380            && bound.patch.is_none_or(|patch| self.patch == patch)
381    }
382
383    /// Performs a `>` match with the given version bound. The match only
384    /// succeeds if some version element in the bound is actually greater than
385    /// that of the version.
386    pub fn matches_gt(&self, bound: &VersionBound) -> bool {
387        if self.major != bound.major {
388            return self.major > bound.major;
389        }
390        let Some(minor) = bound.minor else { return false };
391        if self.minor != minor {
392            return self.minor > minor;
393        }
394        let Some(patch) = bound.patch else { return false };
395        if self.patch != patch {
396            return self.patch > patch;
397        }
398        false
399    }
400
401    /// Performs a `<` match with the given version bound. The match only
402    /// succeeds if some version element in the bound is actually less than that
403    /// of the version.
404    pub fn matches_lt(&self, bound: &VersionBound) -> bool {
405        if self.major != bound.major {
406            return self.major < bound.major;
407        }
408        let Some(minor) = bound.minor else { return false };
409        if self.minor != minor {
410            return self.minor < minor;
411        }
412        let Some(patch) = bound.patch else { return false };
413        if self.patch != patch {
414            return self.patch < patch;
415        }
416        false
417    }
418
419    /// Performs a `>=` match with the given versions. The match succeeds when
420    /// either a `==` or `>` match does.
421    pub fn matches_ge(&self, bound: &VersionBound) -> bool {
422        self.matches_eq(bound) || self.matches_gt(bound)
423    }
424
425    /// Performs a `<=` match with the given versions. The match succeeds when
426    /// either a `==` or `<` match does.
427    pub fn matches_le(&self, bound: &VersionBound) -> bool {
428        self.matches_eq(bound) || self.matches_lt(bound)
429    }
430}
431
432impl FromStr for PackageVersion {
433    type Err = EcoString;
434
435    fn from_str(s: &str) -> Result<Self, Self::Err> {
436        let mut parts = s.split('.');
437        let mut next = |kind| {
438            let part = parts
439                .next()
440                .filter(|s| !s.is_empty())
441                .ok_or_else(|| eco_format!("version number is missing {kind} version"))?;
442            part.parse::<u32>()
443                .map_err(|_| eco_format!("`{part}` is not a valid {kind} version"))
444        };
445
446        let major = next("major")?;
447        let minor = next("minor")?;
448        let patch = next("patch")?;
449        if let Some(rest) = parts.next() {
450            Err(eco_format!("version number has unexpected fourth component: `{rest}`"))?;
451        }
452
453        Ok(Self { major, minor, patch })
454    }
455}
456
457impl Debug for PackageVersion {
458    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
459        Display::fmt(self, f)
460    }
461}
462
463impl Display for PackageVersion {
464    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
465        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
466    }
467}
468
469impl Serialize for PackageVersion {
470    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
471        s.collect_str(self)
472    }
473}
474
475impl<'de> Deserialize<'de> for PackageVersion {
476    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
477        let string = EcoString::deserialize(d)?;
478        string.parse().map_err(serde::de::Error::custom)
479    }
480}
481
482/// A version bound for compatibility specification.
483#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
484pub struct VersionBound {
485    /// The bounds's major version.
486    pub major: u32,
487    /// The bounds's minor version.
488    pub minor: Option<u32>,
489    /// The bounds's patch version. Can only be present if minor is too.
490    pub patch: Option<u32>,
491}
492
493impl FromStr for VersionBound {
494    type Err = EcoString;
495
496    fn from_str(s: &str) -> Result<Self, Self::Err> {
497        let mut parts = s.split('.');
498        let mut next = |kind| {
499            if let Some(part) = parts.next() {
500                part.parse::<u32>().map(Some).map_err(|_| {
501                    eco_format!("`{part}` is not a valid {kind} version bound")
502                })
503            } else {
504                Ok(None)
505            }
506        };
507
508        let major = next("major")?
509            .ok_or_else(|| eco_format!("version bound is missing major version"))?;
510        let minor = next("minor")?;
511        let patch = next("patch")?;
512        if let Some(rest) = parts.next() {
513            Err(eco_format!("version bound has unexpected fourth component: `{rest}`"))?;
514        }
515
516        Ok(Self { major, minor, patch })
517    }
518}
519
520impl Debug for VersionBound {
521    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
522        Display::fmt(self, f)
523    }
524}
525
526impl Display for VersionBound {
527    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
528        write!(f, "{}", self.major)?;
529        if let Some(minor) = self.minor {
530            write!(f, ".{minor}")?;
531        }
532        if let Some(patch) = self.patch {
533            write!(f, ".{patch}")?;
534        }
535        Ok(())
536    }
537}
538
539impl Serialize for VersionBound {
540    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
541        s.collect_str(self)
542    }
543}
544
545impl<'de> Deserialize<'de> for VersionBound {
546    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
547        let string = EcoString::deserialize(d)?;
548        string.parse().map_err(serde::de::Error::custom)
549    }
550}
551
552#[cfg(test)]
553mod tests {
554    use std::str::FromStr;
555
556    use super::*;
557
558    #[test]
559    fn version_version_match() {
560        let v1_1_1 = PackageVersion::from_str("1.1.1").unwrap();
561
562        assert!(v1_1_1.matches_eq(&VersionBound::from_str("1").unwrap()));
563        assert!(v1_1_1.matches_eq(&VersionBound::from_str("1.1").unwrap()));
564        assert!(!v1_1_1.matches_eq(&VersionBound::from_str("1.2").unwrap()));
565
566        assert!(!v1_1_1.matches_gt(&VersionBound::from_str("1").unwrap()));
567        assert!(v1_1_1.matches_gt(&VersionBound::from_str("1.0").unwrap()));
568        assert!(!v1_1_1.matches_gt(&VersionBound::from_str("1.1").unwrap()));
569
570        assert!(!v1_1_1.matches_lt(&VersionBound::from_str("1").unwrap()));
571        assert!(!v1_1_1.matches_lt(&VersionBound::from_str("1.1").unwrap()));
572        assert!(v1_1_1.matches_lt(&VersionBound::from_str("1.2").unwrap()));
573    }
574
575    #[test]
576    fn minimal_manifest() {
577        assert_eq!(
578            toml::from_str::<PackageManifest>(
579                r#"
580                [package]
581                name = "package"
582                version = "0.1.0"
583                entrypoint = "src/lib.typ"
584            "#
585            ),
586            Ok(PackageManifest {
587                package: PackageInfo::new(
588                    "package",
589                    PackageVersion { major: 0, minor: 1, patch: 0 },
590                    "src/lib.typ"
591                ),
592                template: None,
593                tool: ToolInfo { sections: BTreeMap::new() },
594                unknown_fields: BTreeMap::new(),
595            })
596        );
597    }
598
599    #[test]
600    fn tool_section() {
601        // NOTE: tool section must be table of tables, but we can't easily
602        // compare the error structurally
603        assert!(
604            toml::from_str::<PackageManifest>(
605                r#"
606                [package]
607                name = "package"
608                version = "0.1.0"
609                entrypoint = "src/lib.typ"
610
611                [tool]
612                not-table = "str"
613            "#
614            )
615            .is_err()
616        );
617
618        #[derive(Debug, PartialEq, Serialize, Deserialize)]
619        struct MyTool {
620            key: EcoString,
621        }
622
623        let mut manifest: PackageManifest = toml::from_str(
624            r#"
625            [package]
626            name = "package"
627            version = "0.1.0"
628            entrypoint = "src/lib.typ"
629
630            [tool.my-tool]
631            key = "value"
632        "#,
633        )
634        .unwrap();
635
636        let my_tool = manifest.tool.sections.remove("my-tool").unwrap();
637        let my_tool = MyTool::deserialize(my_tool).unwrap();
638
639        assert_eq!(my_tool, MyTool { key: "value".into() });
640    }
641
642    #[test]
643    fn unknown_keys() {
644        let manifest: PackageManifest = toml::from_str(
645            r#"
646            [package]
647            name = "package"
648            version = "0.1.0"
649            entrypoint = "src/lib.typ"
650
651            [unknown]
652        "#,
653        )
654        .unwrap();
655
656        assert!(manifest.unknown_fields.contains_key("unknown"));
657    }
658}