Skip to main content

typst_kit/
packages.rs

1//! Package loading.
2
3use std::fmt::Debug;
4use std::path::{Path, PathBuf};
5
6use ecow::eco_format;
7use typst_syntax::package::{PackageSpec, PackageVersion, VersionlessPackageSpec};
8
9use crate::files::FsRoot;
10
11#[cfg(feature = "universe-packages")]
12use {
13    crate::downloader::Downloader,
14    once_cell::sync::OnceCell,
15    serde::Deserialize,
16    std::io::{Cursor, Read},
17    typst_library::diag::{PackageError, PackageResult, StrResult, bail},
18};
19
20/// Serves packages from standard locations.
21///
22/// In order of priority, this tries to obtain a package from
23///
24/// - a package data directory (that is intended for system-wide storage of user
25///   packages)
26/// - a package cache directory (that is intended for caching of automatically
27///   downloaded packages)
28/// - by downloading it from Typst Universe or a mirror of it (if it's namespace
29///   matches the one Typst Universe serves)
30///
31/// With default configuration, this loads packages from the same sources as the
32/// CLI.
33#[cfg(feature = "system-packages")]
34#[derive(Debug)]
35pub struct SystemPackages {
36    data: Option<FsPackages>,
37    cache: Option<FsPackages>,
38    universe: UniversePackages,
39}
40
41#[cfg(feature = "system-packages")]
42impl SystemPackages {
43    /// Creates a new handle that serves packages from standard
44    /// environment-defined directories and the official Typst Universe
45    /// registry.
46    ///
47    /// - See [`FsPackages`] for more details on the default directories.
48    /// - See [`UniversePackages`] for more details on the registry.
49    ///
50    /// This loads packages from the same sources as the CLI in its default
51    /// configuration.
52    pub fn new(downloader: impl Downloader) -> Self {
53        Self::from_parts(
54            FsPackages::system_data(),
55            FsPackages::system_cache(),
56            UniversePackages::new(downloader),
57        )
58    }
59
60    /// Creates a new system package loader from custom configured parts.
61    pub fn from_parts(
62        data: Option<FsPackages>,
63        cache: Option<FsPackages>,
64        universe: UniversePackages,
65    ) -> Self {
66        Self { data, cache, universe }
67    }
68
69    /// Returns a handle to the data package directory.
70    pub fn data(&self) -> Option<&FsPackages> {
71        self.data.as_ref()
72    }
73
74    /// Returns a handle to the cache package directory.
75    pub fn cache(&self) -> Option<&FsPackages> {
76        self.cache.as_ref()
77    }
78
79    /// Returns a handle to the Typst universe registry.
80    pub fn universe(&self) -> &UniversePackages {
81        &self.universe
82    }
83
84    /// Returns the file system root from which the given package's content can
85    /// be loaded.
86    ///
87    /// May download the package from the network if it's not already available.
88    /// Downloads are retained in the configured cache directory. As such, this
89    /// function can have a file system side effect.
90    ///
91    /// Concurrent downloads do not cause corruption, but for the purpose of
92    /// efficiency, it may be desirable to avoid them. If you use the
93    /// [`FileStore`](crate::files::FileStore), this is already the case since
94    /// it acquires a lock during file loading.
95    pub fn obtain(&self, spec: &PackageSpec) -> PackageResult<FsRoot> {
96        if let Some(packages) = &self.data
97            && let Some(root) = packages.obtain(spec)
98        {
99            return Ok(root);
100        }
101
102        if let Some(cache) = &self.cache {
103            if let Some(root) = cache.obtain(spec) {
104                return Ok(root);
105            }
106
107            // Download from network if it doesn't exist yet.
108            if spec.namespace == UniversePackages::NAMESPACE {
109                let mut archive = self.universe.package(spec)?;
110
111                cache.store(spec, |tempdir| {
112                    archive.unpack(tempdir).map_err(|err| {
113                        PackageError::MalformedArchive(Some(eco_format!("{err}")))
114                    })
115                })?;
116
117                if let Some(root) = cache.obtain(spec) {
118                    return Ok(root);
119                }
120            }
121        }
122
123        Err(PackageError::NotFound(spec.clone()))
124    }
125
126    /// Tries to determine the latest version of a package.
127    pub fn latest_version(
128        &self,
129        spec: &VersionlessPackageSpec,
130    ) -> StrResult<PackageVersion> {
131        if spec.namespace == UniversePackages::NAMESPACE {
132            self.universe.latest_version(spec)
133        } else {
134            // For other namespaces, search locally. We only search in the data
135            // directory and not the cache directory, because the latter is not
136            // intended for storage of local packages.
137            self.data
138                .as_ref()
139                .and_then(|pkgs| pkgs.latest_version(spec))
140                .ok_or_else(|| eco_format!("please specify the desired version"))
141        }
142    }
143}
144
145/// Serves packages from a well-structured directory on the file system.
146///
147/// This directory should be structured as follows:
148/// - Top-level directories denote namespaces
149/// - Second-level directories denote packages
150/// - Third-level directories denote package versions
151#[derive(Debug, Clone)]
152pub struct FsPackages(PathBuf);
153
154impl FsPackages {
155    /// Creates a new handle that serves packages from the given directory.
156    pub fn new(path: impl Into<PathBuf>) -> Self {
157        Self(path.into())
158    }
159
160    /// Tries to provide a handle to the environment-defined standard system
161    /// package data directory.
162    ///
163    /// This is:
164    /// - `$XDG_DATA_HOME/typst/packages` or `~/.local/share/typst/packages` on Linux
165    /// - `~/Library/Application Support/typst/packages` on macOS
166    /// - `%APPDATA%/typst/packages` on Windows
167    #[cfg(feature = "system-packages")]
168    pub fn system_data() -> Option<FsPackages> {
169        dirs::data_dir().map(|dir| FsPackages::new(dir.join("typst/packages")))
170    }
171
172    /// Tries to provide a handle to the environment-defined standard system
173    /// package cache directory.
174    ///
175    /// This is:
176    /// - `$XDG_CACHE_HOME/typst/packages` or `~/.cache/typst/packages` on Linux
177    /// - `~/Library/Caches/typst/packages` on macOS
178    /// - `%LOCALAPPDATA%/typst/packages` on Windows
179    #[cfg(feature = "system-packages")]
180    pub fn system_cache() -> Option<FsPackages> {
181        dirs::cache_dir().map(|dir| FsPackages::new(dir.join("typst/packages")))
182    }
183
184    /// Returns the path from which this serves packages.
185    pub fn path(&self) -> &Path {
186        &self.0
187    }
188
189    /// Returns the file system root from which the given package's content can
190    /// be loaded.
191    pub fn obtain(&self, spec: &PackageSpec) -> Option<FsRoot> {
192        let subdir = eco_format!("{}/{}/{}", spec.namespace, spec.name, spec.version);
193        let dir = self.path().join(subdir.as_str());
194        dir.exists().then_some(FsRoot::new(dir))
195    }
196
197    /// Tries to determine the latest version of a particular package in the
198    /// directory tree.
199    pub fn latest_version(
200        &self,
201        spec: &VersionlessPackageSpec,
202    ) -> Option<PackageVersion> {
203        // For other namespaces, search locally. We only search in the data
204        // directory and not the cache directory, because the latter is not
205        // intended for storage of local packages.
206        let subdir = format!("{}/{}", spec.namespace, spec.name);
207        std::fs::read_dir(self.path().join(&subdir))
208            .into_iter()
209            .flatten()
210            .filter_map(|entry| entry.ok())
211            .map(|entry| entry.path())
212            .filter_map(|path| path.file_name()?.to_string_lossy().parse().ok())
213            .max()
214    }
215
216    /// Stores data for the given package in the package directory by invoking
217    /// `write` on a target path. The package contents should be written
218    /// relative to the path passed to `write`.
219    ///
220    /// Internally, this function ensures that concurrent access to the package
221    /// directory is safe. From two concurrent stores, it is not specified which
222    /// one wins, but they will not infer or result in partial / corrupted
223    /// package contents.
224    #[cfg(feature = "universe-packages")]
225    pub fn store(
226        &self,
227        spec: &PackageSpec,
228        write: impl FnOnce(&Path) -> PackageResult<()>,
229    ) -> PackageResult<()> {
230        let error = |message: &str, err: std::io::Error| -> PackageError {
231            PackageError::Other(Some(eco_format!("{message}: {err}")))
232        };
233
234        // The directory in which the package's version lives.
235        let base_dir = self.path().join(format!("{}/{}", spec.namespace, spec.name));
236
237        // The place at which the specific package version will live in the end.
238        let package_dir = base_dir.join(format!("{}", spec.version));
239
240        // To prevent multiple Typst instances from interfering, we download
241        // into a temporary directory first and then move this directory to
242        // its final destination.
243        //
244        // In the `rename` function's documentation it is stated:
245        // > This will not work if the new name is on a different mount point.
246        //
247        // By locating the temporary directory directly next to where the
248        // package directory will live, we are (trying our best) making sure
249        // that `tempdir` and `package_dir` are on the same mount point.
250        let tempdir = Tempdir::create(base_dir.join(format!(
251            ".tmp-{}-{}",
252            spec.version,
253            fastrand::u32(..),
254        )))
255        .map_err(|err| error("failed to create temporary package directory", err))?;
256
257        // Non-atomically write the package contents into the temporary
258        // directory.
259        write(tempdir.as_ref())?;
260
261        // When trying to move (i.e., `rename`) the directory from one place to
262        // another and the target/destination directory is empty, then the
263        // operation will succeed (if it's atomic, or hardware doesn't fail, or
264        // power doesn't go off, etc.). If however the target directory is not
265        // empty, i.e., another instance already successfully moved the package,
266        // then we can safely ignore the `DirectoryNotEmpty` error.
267        //
268        // This means that we do not check the integrity of an existing moved
269        // package, just like we don't check the integrity if the package
270        // directory already existed in the first place. If situations with
271        // broken packages still occur even with the rename safeguard, we might
272        // consider more complex solutions like file locking or checksums.
273        match std::fs::rename(&tempdir, &package_dir) {
274            Ok(()) => Ok(()),
275            Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => Ok(()),
276            Err(err) => Err(error("failed to move downloaded package directory", err)),
277        }
278    }
279}
280
281/// A temporary directory that is automatically cleaned up.
282#[cfg(feature = "universe-packages")]
283#[derive(Debug)]
284struct Tempdir(PathBuf);
285
286#[cfg(feature = "universe-packages")]
287impl Tempdir {
288    /// Creates a directory at the path and auto-cleans it.
289    fn create(path: PathBuf) -> std::io::Result<Self> {
290        std::fs::create_dir_all(&path)?;
291        Ok(Self(path))
292    }
293}
294
295#[cfg(feature = "universe-packages")]
296impl Drop for Tempdir {
297    fn drop(&mut self) {
298        _ = std::fs::remove_dir_all(&self.0);
299    }
300}
301
302#[cfg(feature = "universe-packages")]
303impl AsRef<Path> for Tempdir {
304    fn as_ref(&self) -> &Path {
305        &self.0
306    }
307}
308
309/// Serves packages from the Typst Universe registry.
310///
311/// There is no standardized registry protocol. This is merely designed to work
312/// with the official Typst Universe package registry.
313#[cfg(feature = "universe-packages")]
314pub struct UniversePackages {
315    /// The URL of the registry.
316    url: String,
317    /// A downloader with which we can download from the registry.
318    downloader: Box<dyn Downloader>,
319    /// The package index.
320    index: OnceCell<Box<[serde_json::Value]>>,
321}
322
323#[cfg(feature = "universe-packages")]
324impl UniversePackages {
325    /// The namespace from which Typst Universe serves packages.
326    pub const NAMESPACE: &str = "preview";
327
328    /// Creates a new handle for interacting with the primary official registry
329    /// at `https://packages.typst.org`.
330    pub fn new(downloader: impl Downloader) -> Self {
331        Self::with_url(downloader, "https://packages.typst.org")
332    }
333
334    /// Creates a new handle which serves packages from an alternative mirror.
335    pub fn with_url(downloader: impl Downloader, url: impl Into<String>) -> Self {
336        Self {
337            url: url.into(),
338            downloader: Box::new(downloader),
339            index: OnceCell::new(),
340        }
341    }
342
343    /// Returns the registry's URL.
344    pub fn url(&self) -> &str {
345        &self.url
346    }
347
348    /// Attempts to download a package from the registry.
349    ///
350    /// Will invoke the downloader with the `spec` as the key.
351    pub fn package(
352        &self,
353        spec: &PackageSpec,
354    ) -> PackageResult<tar::Archive<impl Read + use<>>> {
355        if spec.namespace != Self::NAMESPACE {
356            return Err(PackageError::NotFound(spec.clone()));
357        }
358
359        let url = format!(
360            "{}/{}/{}-{}.tar.gz",
361            self.url,
362            Self::NAMESPACE,
363            spec.name,
364            spec.version,
365        );
366
367        match self.downloader.download(spec, &url) {
368            Ok(data) => {
369                let decompressed = flate2::read::GzDecoder::new(Cursor::new(data));
370                Ok(tar::Archive::new(decompressed))
371            }
372            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
373                Err(match self.latest_version(&spec.versionless()) {
374                    Ok(version) => PackageError::VersionNotFound(spec.clone(), version),
375                    Err(_) => PackageError::NotFound(spec.clone()),
376                })
377            }
378            Err(err) => Err(PackageError::NetworkFailed(Some(eco_format!("{err}")))),
379        }
380    }
381
382    /// Attempts to determine the latest version of a package.
383    ///
384    /// Will invoke the downloader with the key `"package index"`.
385    pub fn latest_version(
386        &self,
387        spec: &VersionlessPackageSpec,
388    ) -> StrResult<PackageVersion> {
389        /// Minimal information required about a package to determine its latest
390        /// version.
391        #[derive(Deserialize)]
392        struct MinimalPackageInfo {
393            name: String,
394            version: PackageVersion,
395        }
396
397        if spec.namespace != Self::NAMESPACE {
398            bail!(
399                "failed to determine latest version \
400                 (an index is only available for the `{}` namespace)",
401                Self::NAMESPACE
402            )
403        }
404
405        self.index()?
406            .iter()
407            .filter_map(|value| MinimalPackageInfo::deserialize(value).ok())
408            .filter(|package| package.name == spec.name)
409            .map(|package| package.version)
410            .max()
411            .ok_or_else(|| eco_format!("failed to find package {spec}"))
412    }
413
414    /// Downloads the package index for the default namespace from the registry
415    /// or serves it from its in-memory cache.
416    ///
417    /// For compatibility, the individual entries are left unserialized. This
418    /// way, packages that cannot be deserialized with this compiler version can
419    /// be skipped instead of failing completely.
420    ///
421    /// The index format of the official package registry is not specified or
422    /// stabilized and may be changed at any time.
423    fn index(&self) -> StrResult<&[serde_json::Value]> {
424        self.index
425            .get_or_try_init(|| {
426                let url = format!("{}/{}/index.json", self.url, Self::NAMESPACE);
427                match self.downloader.download(&"package index", &url) {
428                    Ok(data) => serde_json::from_slice(&data).map_err(|err| {
429                        eco_format!("failed to parse package index: {err}")
430                    }),
431                    Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
432                        bail!("failed to fetch package index (not found)")
433                    }
434                    Err(err) => bail!("failed to fetch package index ({err})"),
435                }
436            })
437            .map(AsRef::as_ref)
438    }
439}
440
441#[cfg(feature = "universe-packages")]
442impl Debug for UniversePackages {
443    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
444        f.debug_struct("Downloader")
445            .field("url", &self.url)
446            .finish_non_exhaustive()
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    #[test]
453    #[cfg(feature = "universe-packages")]
454    fn lazy_deserialize_index() {
455        use super::*;
456        use std::any::Any;
457
458        struct DummyDownloader;
459
460        impl Downloader for DummyDownloader {
461            fn stream(
462                &self,
463                _: &dyn Any,
464                _: &str,
465            ) -> std::io::Result<(Option<usize>, Box<dyn Read>)> {
466                Err(std::io::ErrorKind::NotFound.into())
467            }
468        }
469
470        let mut packages = UniversePackages::new(DummyDownloader);
471        packages.index = OnceCell::from(Box::new([
472            serde_json::json!({
473                "name": "charged-ieee",
474                "version": "0.1.0",
475                "entrypoint": "lib.typ",
476            }),
477            serde_json::json!({
478                "name": "unequivocal-ams",
479                // This version number is currently not valid, so this package
480                // can't be parsed.
481                "version": "0.2.0-dev",
482                "entrypoint": "lib.typ",
483            }),
484        ]) as Box<[_]>);
485
486        let ieee_version = packages.latest_version(&VersionlessPackageSpec {
487            namespace: "preview".into(),
488            name: "charged-ieee".into(),
489        });
490        assert_eq!(ieee_version, Ok(PackageVersion { major: 0, minor: 1, patch: 0 }));
491
492        let ams_version = packages.latest_version(&VersionlessPackageSpec {
493            namespace: "preview".into(),
494            name: "unequivocal-ams".into(),
495        });
496        assert_eq!(
497            ams_version,
498            Err("failed to find package @preview/unequivocal-ams".into())
499        )
500    }
501}