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}