Skip to main content

typst_pack/
creation.rs

1//! Pack Creation: one representative Typst request over supplied inputs.
2//!
3//! Creation reads nothing. The caller supplies a [`ProjectSnapshot`], a
4//! [`FontCatalog`], and the Package Trees resolved for the
5//! document, all as bytes it already holds, so the operation runs wherever the
6//! core runs — including a host with no filesystem and no clock. Obtaining
7//! those inputs belongs to Pack Assembly and a Pack Assembler.
8
9use std::collections::{BTreeMap, BTreeSet};
10use std::sync::Mutex;
11
12use ecow::EcoVec;
13use typst::diag::{FileError, FileResult, PackageError, SourceDiagnostic, Warned};
14use typst::foundations::{Bytes, Datetime, Dict, Duration};
15use typst::syntax::package::PackageSpec;
16use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
17use typst::text::{Font, FontBook};
18use typst::utils::LazyHash;
19use typst::{Feature, Library, LibraryExt, World};
20use typst_kit::datetime::Time;
21use typst_kit::files::{FileLoader, FileStore};
22
23use crate::domain::{DocumentTime, TypstTarget};
24use crate::embedded::EmbeddedTypst;
25use crate::font_catalog::{CatalogFonts, FontCatalog, FontDisposition};
26use crate::manifest::PackMetadata;
27use crate::pack::{Pack, PackBuildError, PackInvariantError};
28use crate::package_catalog::PackageCatalog;
29use crate::package_failure::{PackageReadFailure, PackageReadFailureReason, PackageReadFailures};
30use crate::payload::SharedBytes;
31use crate::project_snapshot::ProjectSnapshot;
32
33/// The semantic controls for one Dependency Discovery run.
34///
35/// These values select dependencies for one Pack Creation invocation. They do
36/// not become Pack state and do not restrict later Pack compilation requests.
37#[derive(Clone, Debug)]
38pub struct DiscoverySpecification {
39    target: TypstTarget,
40    inputs: Dict,
41    document_time: DocumentTime,
42    features: Vec<Feature>,
43}
44
45impl DiscoverySpecification {
46    /// Validates and groups every semantic control for one discovery run.
47    pub fn new(
48        target: TypstTarget,
49        inputs: Dict,
50        document_time: DocumentTime,
51        features: impl IntoIterator<Item = Feature>,
52    ) -> Result<Self, DiscoverySpecificationError> {
53        if let DocumentTime::UnixTimestamp(timestamp) = document_time
54            && Time::fixed_timestamp(timestamp).is_err()
55        {
56            return Err(DiscoverySpecificationError::InvalidDocumentTimestamp);
57        }
58        Ok(Self {
59            target,
60            inputs,
61            document_time,
62            features: features.into_iter().collect(),
63        })
64    }
65
66    /// The Typst document model selected for Dependency Discovery.
67    pub fn target(&self) -> TypstTarget {
68        self.target
69    }
70
71    /// Values exposed to document code through `sys.inputs`.
72    pub fn inputs(&self) -> &Dict {
73        &self.inputs
74    }
75
76    /// The exact or explicitly absent Document Time for this run.
77    pub fn document_time(&self) -> DocumentTime {
78        self.document_time
79    }
80
81    /// Typst engine features enabled for this run.
82    pub fn features(&self) -> &[Feature] {
83        &self.features
84    }
85}
86
87/// A failure while constructing a [`DiscoverySpecification`].
88#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
89#[non_exhaustive]
90pub enum DiscoverySpecificationError {
91    #[error("the discovery document-time UNIX timestamp is out of range")]
92    InvalidDocumentTimestamp,
93}
94
95/// Every value borrowed by one stateless Pack Creation invocation.
96#[derive(Clone, Copy, Debug)]
97pub struct PackCreationInput<'a> {
98    /// The complete, stabilized project tree and selected entrypoint.
99    pub project: &'a ProjectSnapshot,
100    /// Validated Package Trees available to Dependency Discovery.
101    pub packages: &'a PackageCatalog,
102    /// Ordered Font Containers available to Dependency Discovery.
103    pub fonts: &'a FontCatalog,
104    /// Failed package reads to attach at importing source spans.
105    pub package_failures: &'a PackageReadFailures,
106    /// Semantic controls used only for this Dependency Discovery run.
107    pub discovery: &'a DiscoverySpecification,
108    /// Optional descriptive Pack metadata, excluded from Pack Identity.
109    pub metadata: Option<&'a PackMetadata>,
110}
111
112/// What one Pack Creation invocation produced.
113#[derive(Debug)]
114#[allow(clippy::large_enum_variant)] // The accepted Created outcome owns its validated Pack.
115pub enum PackCreationOutcome {
116    /// Dependency Discovery succeeded and authoritative validation produced one
117    /// Pack. Discovery warnings remain separate from Pack state.
118    Created {
119        pack: Pack,
120        warnings: EcoVec<SourceDiagnostic>,
121    },
122    /// Exact specifications the caller must add to the Package Catalog before
123    /// invoking creation again. The list is nonempty, deduplicated, and in
124    /// canonical specification order.
125    MissingPackageSpecifications(Vec<PackageSpec>),
126}
127
128/// Complete compiler evidence from a rejected Dependency Discovery run.
129#[derive(Clone, Debug, Eq, PartialEq)]
130pub struct DependencyDiscoveryRejection {
131    diagnostics: EcoVec<SourceDiagnostic>,
132    warnings: EcoVec<SourceDiagnostic>,
133}
134
135impl DependencyDiscoveryRejection {
136    /// Every rejection diagnostic in compiler order.
137    pub fn diagnostics(&self) -> &[SourceDiagnostic] {
138        &self.diagnostics
139    }
140
141    /// Every discovery warning in compiler order.
142    pub fn warnings(&self) -> &[SourceDiagnostic] {
143        &self.warnings
144    }
145
146    /// Recovers the complete owned compiler evidence.
147    pub fn into_parts(self) -> (EcoVec<SourceDiagnostic>, EcoVec<SourceDiagnostic>) {
148        (self.diagnostics, self.warnings)
149    }
150}
151
152/// A failure that creates no Pack.
153#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
154#[non_exhaustive]
155pub enum PackCreationError {
156    /// Dependency Discovery did not compile. All diagnostics and warnings from
157    /// the run are retained.
158    #[error(
159        "dependency discovery was rejected with {} diagnostic(s)",
160        .0.diagnostics.len()
161    )]
162    DependencyDiscoveryRejected(DependencyDiscoveryRejection),
163    /// The selected inputs do not satisfy authoritative whole-Pack invariants.
164    #[error(transparent)]
165    InvalidPack(#[from] PackInvariantError),
166}
167
168/// Runs one representative Typst request over the supplied inputs and issues
169/// the Pack it selected, or reports the packages it needed and was not given.
170///
171/// Compiler observations select package and font requirements; project files
172/// come from the Project Snapshot alone. Creation fails rather than issuing an
173/// incomplete Pack when the representative request does not compile.
174///
175/// A request that read a package no supplied tree covers returns
176/// [`PackCreationOutcome::MissingPackageSpecifications`] instead: resolve
177/// those specifications, add their trees to the Package Catalog, and invoke
178/// creation again. Because a
179/// failed import ends module evaluation, one round reports what that round
180/// reached, and a project needing several packages completes over repeated
181/// invocation. A specification the caller cannot resolve is added to
182/// [`PackageReadFailures`], which fails the next round's
183/// Dependency Discovery at the import that needed it.
184///
185/// Creation borrows validated bytes and has nothing to re-read. A Pack represents the
186/// exact values its source adapters read, without guaranteeing that values
187/// from mutable sources all coexisted at one instant.
188///
189/// # Resuming until every package is supplied
190///
191/// The caller owns the retry loop, so any source can back it — a registry, a
192/// local directory, an object store, or a test fixture.
193///
194/// ```no_run
195/// use typst::syntax::package::PackageSpec;
196/// use typst_pack::{
197///     DiscoverySpecification, FontCatalog, Pack, PackCreationInput, PackCreationOutcome,
198///     PackageCatalog, PackageDisposition, PackageReadFailures, PackageTree, ProjectSnapshot,
199///     create,
200/// };
201///
202/// fn assemble(
203///     project: &ProjectSnapshot,
204///     fonts: &FontCatalog,
205///     discovery: &DiscoverySpecification,
206///     mut read_tree: impl FnMut(&PackageSpec) -> Result<PackageTree, Box<dyn std::error::Error>>,
207/// ) -> Result<Pack, Box<dyn std::error::Error>> {
208///     let mut packages = PackageCatalog::new();
209///     let package_failures = PackageReadFailures::new();
210///     loop {
211///         let outcome = create(PackCreationInput {
212///             project,
213///             packages: &packages,
214///             fonts,
215///             package_failures: &package_failures,
216///             discovery,
217///             metadata: None,
218///         })?;
219///         match outcome {
220///             PackCreationOutcome::Created { pack, .. } => return Ok(pack),
221///             PackCreationOutcome::MissingPackageSpecifications(missing) => {
222///                 for spec in missing {
223///                     let tree = read_tree(&spec)?;
224///                     packages.insert(spec, tree, PackageDisposition::Embedded)?;
225///                 }
226///             }
227///         }
228///     }
229/// }
230/// ```
231pub fn create(input: PackCreationInput<'_>) -> Result<PackCreationOutcome, PackCreationError> {
232    let entrypoint = VirtualPath::new(input.project.entrypoint())
233        .expect("Project Snapshot entrypoint invariant violated");
234
235    let mut world = SuppliedWorld {
236        library: LazyHash::new(
237            Library::builder()
238                .with_inputs(input.discovery.inputs.clone())
239                .with_features(input.discovery.features.iter().copied().collect())
240                .build(),
241        ),
242        main: RootedPath::new(VirtualRoot::Project, entrypoint).intern(),
243        files: FileStore::new(SuppliedLoader {
244            project: input.project,
245            packages: input.packages,
246            package_failures: input.package_failures,
247        }),
248        fonts: input.fonts.expand(),
249        used_font_indices: Mutex::new(BTreeSet::new()),
250        clock: DiscoveryClock::new(input.discovery.document_time),
251    };
252
253    let Warned { output, warnings } = compile_creation_target(&world, input.discovery.target);
254    let observed = world.observed_packages();
255
256    // Reported before the compile outcome is inspected, because the import that
257    // needed a tree is exactly what failed the compile. The caller resolves
258    // these and invokes creation again rather than reading diagnostics.
259    if !observed.missing.is_empty() {
260        return Ok(PackCreationOutcome::MissingPackageSpecifications(
261            observed.missing,
262        ));
263    }
264
265    if let Err(diagnostics) = output {
266        return Err(PackCreationError::DependencyDiscoveryRejected(
267            DependencyDiscoveryRejection {
268                diagnostics,
269                warnings,
270            },
271        ));
272    }
273
274    let mut builder = Pack::builder(input.project.entrypoint());
275    for (path, data) in input.project.shared_files() {
276        builder = map_build(builder.shared_file(path, data.clone()))?;
277    }
278
279    // Packages, in canonical specification order. The whole Package
280    // Tree travels, not only the files the representative request read.
281    let loader = world.files.loader();
282    for spec in observed.supplied {
283        let entry = loader
284            .packages
285            .get(&spec)
286            .expect("observed package was partitioned as supplied");
287        for (path, data) in entry.tree().shared_files() {
288            builder = if entry.disposition().is_embedded() {
289                map_build(builder.shared_package_file(spec.clone(), path, data.clone()))?
290            } else {
291                map_build(builder.shared_external_package_file(spec.clone(), path, data.clone()))?
292            };
293        }
294    }
295
296    // Selected faces in Font Catalog order, each under the disposition
297    // its container carries.
298    for (font, disposition) in world.used_fonts() {
299        builder =
300            if disposition.is_embedded() {
301                map_build(
302                    builder.shared_font(SharedBytes::from_typst(font.data().clone()), font.index()),
303                )?
304            } else {
305                map_build(builder.shared_external_font(
306                    SharedBytes::from_typst(font.data().clone()),
307                    font.index(),
308                ))?
309            };
310    }
311
312    if let Some(metadata) = input.metadata {
313        builder = builder.metadata(metadata.clone());
314    }
315
316    Ok(PackCreationOutcome::Created {
317        pack: map_build(builder.build())?,
318        warnings,
319    })
320}
321
322fn map_build<T>(result: Result<T, PackBuildError>) -> Result<T, PackCreationError> {
323    match result {
324        Ok(value) => Ok(value),
325        Err(PackBuildError::Invariant(error)) => Err(PackCreationError::InvalidPack(error)),
326    }
327}
328
329/// The package specifications one representative request asked for, split by
330/// whether the supplied trees covered them.
331#[derive(Default)]
332struct ObservedPackages {
333    /// Specifications a supplied tree covers, which become Package
334    /// Requirements.
335    supplied: Vec<PackageSpec>,
336    /// Specifications no supplied tree covers, which creation reports so the
337    /// caller can resolve them and invoke creation again.
338    missing: Vec<PackageSpec>,
339}
340
341/// The world the representative request compiles against: supplied bytes and
342/// nothing else.
343struct SuppliedWorld<'a> {
344    library: LazyHash<Library>,
345    main: FileId,
346    files: FileStore<SuppliedLoader<'a>>,
347    fonts: CatalogFonts,
348    used_font_indices: Mutex<BTreeSet<usize>>,
349    clock: DiscoveryClock,
350}
351
352impl SuppliedWorld<'_> {
353    /// The packages the representative request asked for a file of, split by
354    /// whether a supplied tree covers them, each in canonical specification
355    /// order.
356    ///
357    /// Both halves come from the requests the compiler made, not from what its
358    /// diagnostics said about them.
359    fn observed_packages(&mut self) -> ObservedPackages {
360        let mut specs: BTreeMap<String, PackageSpec> = BTreeMap::new();
361        let (loader, dependencies) = self.files.dependencies();
362        for id in dependencies {
363            if let VirtualRoot::Package(spec) = id.root() {
364                specs.insert(spec.to_string(), spec.clone());
365            }
366        }
367
368        let mut observed = ObservedPackages::default();
369        for spec in specs.into_values() {
370            if loader.packages.get(&spec).is_some() {
371                observed.supplied.push(spec);
372            } else if loader.package_failures.get(&spec).is_none() {
373                observed.missing.push(spec);
374            }
375            // A specification the caller declared unresolvable is neither. The
376            // representative request already failed at it, carrying the
377            // caller's own reason, and reporting it again would ask for what
378            // the caller said it cannot supply.
379        }
380        observed
381    }
382
383    /// The selected faces in Font Catalog order, each with the
384    /// disposition its container carries.
385    fn used_fonts(&self) -> Vec<(Font, FontDisposition)> {
386        self.used_font_indices
387            .lock()
388            .expect("used font index lock poisoned")
389            .iter()
390            .filter_map(|index| Some((self.fonts.font(*index)?, self.fonts.disposition(*index)?)))
391            .collect()
392    }
393}
394
395impl World for SuppliedWorld<'_> {
396    fn library(&self) -> &LazyHash<Library> {
397        &self.library
398    }
399
400    fn book(&self) -> &LazyHash<FontBook> {
401        self.fonts.book()
402    }
403
404    fn main(&self) -> FileId {
405        self.main
406    }
407
408    fn source(&self, id: FileId) -> FileResult<Source> {
409        self.files.source(id)
410    }
411
412    fn file(&self, id: FileId) -> FileResult<Bytes> {
413        self.files.file(id)
414    }
415
416    fn font(&self, index: usize) -> Option<Font> {
417        let font = self.fonts.font(index);
418        if font.is_some() {
419            self.used_font_indices
420                .lock()
421                .expect("used font index lock poisoned")
422                .insert(index);
423        }
424        font
425    }
426
427    fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
428        self.clock.today(offset)
429    }
430}
431
432enum DiscoveryClock {
433    None,
434    Fixed(Datetime),
435    Timestamp(Time),
436}
437
438impl DiscoveryClock {
439    fn new(document_time: DocumentTime) -> Self {
440        match document_time {
441            DocumentTime::Absent => Self::None,
442            DocumentTime::Fixed(datetime) => Self::Fixed(datetime),
443            DocumentTime::UnixTimestamp(timestamp) => Self::Timestamp(
444                Time::fixed_timestamp(timestamp)
445                    .expect("Discovery Specification validated its Document Time"),
446            ),
447        }
448    }
449
450    fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
451        match self {
452            Self::None => None,
453            Self::Fixed(datetime) => Some(*datetime),
454            Self::Timestamp(time) => time.today(offset),
455        }
456    }
457}
458
459/// Serves file requests from the supplied Project Snapshot and package trees.
460struct SuppliedLoader<'a> {
461    project: &'a ProjectSnapshot,
462    packages: &'a PackageCatalog,
463    package_failures: &'a PackageReadFailures,
464}
465
466impl FileLoader for SuppliedLoader<'_> {
467    fn load(&self, id: FileId) -> FileResult<Bytes> {
468        let path = id.vpath().get_without_slash();
469        match id.root() {
470            VirtualRoot::Project => self
471                .project
472                .shared_file(path)
473                .map(|data| data.to_typst())
474                .ok_or_else(|| FileError::NotFound(path.into())),
475            VirtualRoot::Package(spec) => {
476                let Some(entry) = self.packages.get(spec) else {
477                    // A supplied tree first, so a caller that resolved a
478                    // specification it had declared unresolvable is served it.
479                    return Err(FileError::Package(
480                        self.package_failures
481                            .get(spec)
482                            .map(package_failure_for_discovery)
483                            .unwrap_or_else(|| PackageError::NotFound(spec.clone())),
484                    ));
485                };
486                entry
487                    .tree()
488                    .shared_file(path)
489                    .map(SharedBytes::to_typst)
490                    .ok_or_else(|| FileError::NotFound(path.into()))
491            }
492        }
493    }
494}
495
496fn package_failure_for_discovery(failure: &PackageReadFailure) -> PackageError {
497    let spec = failure.spec().clone();
498    match failure.reason() {
499        PackageReadFailureReason::NotFound => PackageError::NotFound(spec),
500        PackageReadFailureReason::VersionNotFound { latest } => {
501            PackageError::VersionNotFound(spec, *latest)
502        }
503        PackageReadFailureReason::NetworkFailed { detail } => {
504            PackageError::NetworkFailed(detail.clone().map(Into::into))
505        }
506        PackageReadFailureReason::MalformedArchive { detail } => {
507            PackageError::MalformedArchive(detail.clone().map(Into::into))
508        }
509        PackageReadFailureReason::Other { detail } => {
510            PackageError::Other(detail.clone().map(Into::into))
511        }
512    }
513}
514
515/// Runs the representative request for the selected Typst Target, keeping only
516/// what selects requirements: whether it compiled, and its warnings.
517fn compile_creation_target(
518    world: &dyn World,
519    target: TypstTarget,
520) -> Warned<Result<(), EcoVec<SourceDiagnostic>>> {
521    match target {
522        TypstTarget::Paged => {
523            let Warned { output, warnings } = EmbeddedTypst::compile_paged(world);
524            Warned {
525                output: output.map(|_| ()),
526                warnings,
527            }
528        }
529        TypstTarget::Html => {
530            let Warned { output, warnings } = EmbeddedTypst::compile_html(world);
531            Warned {
532                output: output.map(|_| ()),
533                warnings,
534            }
535        }
536    }
537}