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::{eco_format, EcoString};
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, Clone, Default, 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    /// Ensure that this manifest is indeed for the specified package.
147    pub fn validate(&self, spec: &PackageSpec) -> Result<(), EcoString> {
148        if self.package.name != spec.name {
149            return Err(eco_format!(
150                "package manifest contains mismatched name `{}`",
151                self.package.name
152            ));
153        }
154
155        if self.package.version != spec.version {
156            return Err(eco_format!(
157                "package manifest contains mismatched version {}",
158                self.package.version
159            ));
160        }
161
162        if let Some(required) = self.package.compiler {
163            let current = PackageVersion::compiler();
164            if !current.matches_ge(&required) {
165                return Err(eco_format!(
166                    "package requires typst {required} or newer \
167                     (current version is {current})"
168                ));
169            }
170        }
171
172        Ok(())
173    }
174}
175
176/// Identifies a package.
177#[derive(Clone, Eq, PartialEq, Hash)]
178pub struct PackageSpec {
179    /// The namespace the package lives in.
180    pub namespace: EcoString,
181    /// The name of the package within its namespace.
182    pub name: EcoString,
183    /// The package's version.
184    pub version: PackageVersion,
185}
186
187impl PackageSpec {
188    pub fn versionless(&self) -> VersionlessPackageSpec {
189        VersionlessPackageSpec {
190            namespace: self.namespace.clone(),
191            name: self.name.clone(),
192        }
193    }
194}
195
196impl FromStr for PackageSpec {
197    type Err = EcoString;
198
199    fn from_str(s: &str) -> Result<Self, Self::Err> {
200        let mut s = unscanny::Scanner::new(s);
201        let namespace = parse_namespace(&mut s)?.into();
202        let name = parse_name(&mut s)?.into();
203        let version = parse_version(&mut s)?;
204        Ok(Self { namespace, name, version })
205    }
206}
207
208impl Debug for PackageSpec {
209    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
210        Display::fmt(self, f)
211    }
212}
213
214impl Display for PackageSpec {
215    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
216        write!(f, "@{}/{}:{}", self.namespace, self.name, self.version)
217    }
218}
219
220/// Identifies a package, but not a specific version of it.
221#[derive(Clone, Eq, PartialEq, Hash)]
222pub struct VersionlessPackageSpec {
223    /// The namespace the package lives in.
224    pub namespace: EcoString,
225    /// The name of the package within its namespace.
226    pub name: EcoString,
227}
228
229impl VersionlessPackageSpec {
230    /// Fill in the `version` to get a complete [`PackageSpec`].
231    pub fn at(self, version: PackageVersion) -> PackageSpec {
232        PackageSpec {
233            namespace: self.namespace,
234            name: self.name,
235            version,
236        }
237    }
238}
239
240impl FromStr for VersionlessPackageSpec {
241    type Err = EcoString;
242
243    fn from_str(s: &str) -> Result<Self, Self::Err> {
244        let mut s = unscanny::Scanner::new(s);
245        let namespace = parse_namespace(&mut s)?.into();
246        let name = parse_name(&mut s)?.into();
247        if !s.done() {
248            Err("unexpected version in versionless package specification")?;
249        }
250        Ok(Self { namespace, name })
251    }
252}
253
254impl Debug for VersionlessPackageSpec {
255    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
256        Display::fmt(self, f)
257    }
258}
259
260impl Display for VersionlessPackageSpec {
261    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
262        write!(f, "@{}/{}", self.namespace, self.name)
263    }
264}
265
266fn parse_namespace<'s>(s: &mut Scanner<'s>) -> Result<&'s str, EcoString> {
267    if !s.eat_if('@') {
268        Err("package specification must start with '@'")?;
269    }
270
271    let namespace = s.eat_until('/');
272    if namespace.is_empty() {
273        Err("package specification is missing namespace")?;
274    } else if !is_ident(namespace) {
275        Err(eco_format!("`{namespace}` is not a valid package namespace"))?;
276    }
277
278    Ok(namespace)
279}
280
281fn parse_name<'s>(s: &mut Scanner<'s>) -> Result<&'s str, EcoString> {
282    s.eat_if('/');
283
284    let name = s.eat_until(':');
285    if name.is_empty() {
286        Err("package specification is missing name")?;
287    } else if !is_ident(name) {
288        Err(eco_format!("`{name}` is not a valid package name"))?;
289    }
290
291    Ok(name)
292}
293
294fn parse_version(s: &mut Scanner) -> Result<PackageVersion, EcoString> {
295    s.eat_if(':');
296
297    let version = s.after();
298    if version.is_empty() {
299        Err("package specification is missing version")?;
300    }
301
302    version.parse()
303}
304
305/// A package's version.
306#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
307pub struct PackageVersion {
308    /// The package's major version.
309    pub major: u32,
310    /// The package's minor version.
311    pub minor: u32,
312    /// The package's patch version.
313    pub patch: u32,
314}
315
316impl PackageVersion {
317    /// The current compiler version.
318    pub fn compiler() -> Self {
319        Self {
320            major: env!("CARGO_PKG_VERSION_MAJOR").parse().unwrap(),
321            minor: env!("CARGO_PKG_VERSION_MINOR").parse().unwrap(),
322            patch: env!("CARGO_PKG_VERSION_PATCH").parse().unwrap(),
323        }
324    }
325
326    /// Performs an `==` match with the given version bound. Version elements
327    /// missing in the bound are ignored.
328    pub fn matches_eq(&self, bound: &VersionBound) -> bool {
329        self.major == bound.major
330            && bound.minor.map_or(true, |minor| self.minor == minor)
331            && bound.patch.map_or(true, |patch| self.patch == patch)
332    }
333
334    /// Performs a `>` match with the given version bound. The match only
335    /// succeeds if some version element in the bound is actually greater than
336    /// that of the version.
337    pub fn matches_gt(&self, bound: &VersionBound) -> bool {
338        if self.major != bound.major {
339            return self.major > bound.major;
340        }
341        let Some(minor) = bound.minor else { return false };
342        if self.minor != minor {
343            return self.minor > minor;
344        }
345        let Some(patch) = bound.patch else { return false };
346        if self.patch != patch {
347            return self.patch > patch;
348        }
349        false
350    }
351
352    /// Performs a `<` match with the given version bound. The match only
353    /// succeeds if some version element in the bound is actually less than that
354    /// of the version.
355    pub fn matches_lt(&self, bound: &VersionBound) -> bool {
356        if self.major != bound.major {
357            return self.major < bound.major;
358        }
359        let Some(minor) = bound.minor else { return false };
360        if self.minor != minor {
361            return self.minor < minor;
362        }
363        let Some(patch) = bound.patch else { return false };
364        if self.patch != patch {
365            return self.patch < patch;
366        }
367        false
368    }
369
370    /// Performs a `>=` match with the given versions. The match succeeds when
371    /// either a `==` or `>` match does.
372    pub fn matches_ge(&self, bound: &VersionBound) -> bool {
373        self.matches_eq(bound) || self.matches_gt(bound)
374    }
375
376    /// Performs a `<=` match with the given versions. The match succeeds when
377    /// either a `==` or `<` match does.
378    pub fn matches_le(&self, bound: &VersionBound) -> bool {
379        self.matches_eq(bound) || self.matches_lt(bound)
380    }
381}
382
383impl FromStr for PackageVersion {
384    type Err = EcoString;
385
386    fn from_str(s: &str) -> Result<Self, Self::Err> {
387        let mut parts = s.split('.');
388        let mut next = |kind| {
389            let part = parts
390                .next()
391                .filter(|s| !s.is_empty())
392                .ok_or_else(|| eco_format!("version number is missing {kind} version"))?;
393            part.parse::<u32>()
394                .map_err(|_| eco_format!("`{part}` is not a valid {kind} version"))
395        };
396
397        let major = next("major")?;
398        let minor = next("minor")?;
399        let patch = next("patch")?;
400        if let Some(rest) = parts.next() {
401            Err(eco_format!("version number has unexpected fourth component: `{rest}`"))?;
402        }
403
404        Ok(Self { major, minor, patch })
405    }
406}
407
408impl Debug for PackageVersion {
409    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
410        Display::fmt(self, f)
411    }
412}
413
414impl Display for PackageVersion {
415    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
416        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
417    }
418}
419
420impl Serialize for PackageVersion {
421    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
422        s.collect_str(self)
423    }
424}
425
426impl<'de> Deserialize<'de> for PackageVersion {
427    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
428        let string = EcoString::deserialize(d)?;
429        string.parse().map_err(serde::de::Error::custom)
430    }
431}
432
433/// A version bound for compatibility specification.
434#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
435pub struct VersionBound {
436    /// The bounds's major version.
437    pub major: u32,
438    /// The bounds's minor version.
439    pub minor: Option<u32>,
440    /// The bounds's patch version. Can only be present if minor is too.
441    pub patch: Option<u32>,
442}
443
444impl FromStr for VersionBound {
445    type Err = EcoString;
446
447    fn from_str(s: &str) -> Result<Self, Self::Err> {
448        let mut parts = s.split('.');
449        let mut next = |kind| {
450            if let Some(part) = parts.next() {
451                part.parse::<u32>().map(Some).map_err(|_| {
452                    eco_format!("`{part}` is not a valid {kind} version bound")
453                })
454            } else {
455                Ok(None)
456            }
457        };
458
459        let major = next("major")?
460            .ok_or_else(|| eco_format!("version bound is missing major version"))?;
461        let minor = next("minor")?;
462        let patch = next("patch")?;
463        if let Some(rest) = parts.next() {
464            Err(eco_format!("version bound has unexpected fourth component: `{rest}`"))?;
465        }
466
467        Ok(Self { major, minor, patch })
468    }
469}
470
471impl Debug for VersionBound {
472    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
473        Display::fmt(self, f)
474    }
475}
476
477impl Display for VersionBound {
478    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
479        write!(f, "{}", self.major)?;
480        if let Some(minor) = self.minor {
481            write!(f, ".{minor}")?;
482        }
483        if let Some(patch) = self.patch {
484            write!(f, ".{patch}")?;
485        }
486        Ok(())
487    }
488}
489
490impl Serialize for VersionBound {
491    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
492        s.collect_str(self)
493    }
494}
495
496impl<'de> Deserialize<'de> for VersionBound {
497    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
498        let string = EcoString::deserialize(d)?;
499        string.parse().map_err(serde::de::Error::custom)
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use std::str::FromStr;
506
507    use super::*;
508
509    #[test]
510    fn version_version_match() {
511        let v1_1_1 = PackageVersion::from_str("1.1.1").unwrap();
512
513        assert!(v1_1_1.matches_eq(&VersionBound::from_str("1").unwrap()));
514        assert!(v1_1_1.matches_eq(&VersionBound::from_str("1.1").unwrap()));
515        assert!(!v1_1_1.matches_eq(&VersionBound::from_str("1.2").unwrap()));
516
517        assert!(!v1_1_1.matches_gt(&VersionBound::from_str("1").unwrap()));
518        assert!(v1_1_1.matches_gt(&VersionBound::from_str("1.0").unwrap()));
519        assert!(!v1_1_1.matches_gt(&VersionBound::from_str("1.1").unwrap()));
520
521        assert!(!v1_1_1.matches_lt(&VersionBound::from_str("1").unwrap()));
522        assert!(!v1_1_1.matches_lt(&VersionBound::from_str("1.1").unwrap()));
523        assert!(v1_1_1.matches_lt(&VersionBound::from_str("1.2").unwrap()));
524    }
525
526    #[test]
527    fn minimal_manifest() {
528        assert_eq!(
529            toml::from_str::<PackageManifest>(
530                r#"
531                [package]
532                name = "package"
533                version = "0.1.0"
534                entrypoint = "src/lib.typ"
535            "#
536            ),
537            Ok(PackageManifest {
538                package: PackageInfo {
539                    name: "package".into(),
540                    version: PackageVersion { major: 0, minor: 1, patch: 0 },
541                    entrypoint: "src/lib.typ".into(),
542                    authors: vec![],
543                    license: None,
544                    description: None,
545                    homepage: None,
546                    repository: None,
547                    keywords: vec![],
548                    categories: vec![],
549                    disciplines: vec![],
550                    compiler: None,
551                    exclude: vec![],
552                    unknown_fields: BTreeMap::new(),
553                },
554                template: None,
555                tool: ToolInfo { sections: BTreeMap::new() },
556                unknown_fields: BTreeMap::new(),
557            })
558        );
559    }
560
561    #[test]
562    fn tool_section() {
563        // NOTE: tool section must be table of tables, but we can't easily
564        // compare the error structurally
565        assert!(toml::from_str::<PackageManifest>(
566            r#"
567                [package]
568                name = "package"
569                version = "0.1.0"
570                entrypoint = "src/lib.typ"
571
572                [tool]
573                not-table = "str"
574            "#
575        )
576        .is_err());
577
578        #[derive(Debug, PartialEq, Serialize, Deserialize)]
579        struct MyTool {
580            key: EcoString,
581        }
582
583        let mut manifest: PackageManifest = toml::from_str(
584            r#"
585            [package]
586            name = "package"
587            version = "0.1.0"
588            entrypoint = "src/lib.typ"
589
590            [tool.my-tool]
591            key = "value"
592        "#,
593        )
594        .unwrap();
595
596        let my_tool = manifest.tool.sections.remove("my-tool").unwrap();
597        let my_tool = MyTool::deserialize(my_tool).unwrap();
598
599        assert_eq!(my_tool, MyTool { key: "value".into() });
600    }
601
602    #[test]
603    fn unknown_keys() {
604        let manifest: PackageManifest = toml::from_str(
605            r#"
606            [package]
607            name = "package"
608            version = "0.1.0"
609            entrypoint = "src/lib.typ"
610
611            [unknown]
612        "#,
613        )
614        .unwrap();
615
616        assert!(manifest.unknown_fields.contains_key("unknown"));
617    }
618}