use std::collections::{BTreeMap, BTreeSet};
use std::sync::Mutex;
use ecow::EcoVec;
use typst::diag::{FileError, FileResult, PackageError, SourceDiagnostic, Warned};
use typst::foundations::{Bytes, Datetime, Dict, Duration};
use typst::syntax::package::PackageSpec;
use typst::syntax::{FileId, RootedPath, Source, VirtualPath, VirtualRoot};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Feature, Library, LibraryExt, World};
use typst_kit::datetime::Time;
use typst_kit::files::{FileLoader, FileStore};
use crate::domain::{DocumentTime, TypstTarget};
use crate::embedded::EmbeddedTypst;
use crate::font_catalog::{CatalogFonts, FontCatalog, FontDisposition};
use crate::manifest::PackMetadata;
use crate::pack::{Pack, PackBuildError, PackInvariantError};
use crate::package_catalog::PackageCatalog;
use crate::package_failure::{PackageReadFailure, PackageReadFailureReason, PackageReadFailures};
use crate::payload::SharedBytes;
use crate::project_snapshot::ProjectSnapshot;
#[derive(Clone, Debug)]
pub struct DiscoverySpecification {
target: TypstTarget,
inputs: Dict,
document_time: DocumentTime,
features: Vec<Feature>,
}
impl DiscoverySpecification {
pub fn new(
target: TypstTarget,
inputs: Dict,
document_time: DocumentTime,
features: impl IntoIterator<Item = Feature>,
) -> Result<Self, DiscoverySpecificationError> {
if let DocumentTime::UnixTimestamp(timestamp) = document_time
&& Time::fixed_timestamp(timestamp).is_err()
{
return Err(DiscoverySpecificationError::InvalidDocumentTimestamp);
}
Ok(Self {
target,
inputs,
document_time,
features: features.into_iter().collect(),
})
}
pub fn target(&self) -> TypstTarget {
self.target
}
pub fn inputs(&self) -> &Dict {
&self.inputs
}
pub fn document_time(&self) -> DocumentTime {
self.document_time
}
pub fn features(&self) -> &[Feature] {
&self.features
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum DiscoverySpecificationError {
#[error("the discovery document-time UNIX timestamp is out of range")]
InvalidDocumentTimestamp,
}
#[derive(Clone, Copy, Debug)]
pub struct PackCreationInput<'a> {
pub project: &'a ProjectSnapshot,
pub packages: &'a PackageCatalog,
pub fonts: &'a FontCatalog,
pub package_failures: &'a PackageReadFailures,
pub discovery: &'a DiscoverySpecification,
pub metadata: Option<&'a PackMetadata>,
}
#[derive(Debug)]
#[allow(clippy::large_enum_variant)] pub enum PackCreationOutcome {
Created {
pack: Pack,
warnings: EcoVec<SourceDiagnostic>,
},
MissingPackageSpecifications(Vec<PackageSpec>),
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct DependencyDiscoveryRejection {
diagnostics: EcoVec<SourceDiagnostic>,
warnings: EcoVec<SourceDiagnostic>,
}
impl DependencyDiscoveryRejection {
pub fn diagnostics(&self) -> &[SourceDiagnostic] {
&self.diagnostics
}
pub fn warnings(&self) -> &[SourceDiagnostic] {
&self.warnings
}
pub fn into_parts(self) -> (EcoVec<SourceDiagnostic>, EcoVec<SourceDiagnostic>) {
(self.diagnostics, self.warnings)
}
}
#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
#[non_exhaustive]
pub enum PackCreationError {
#[error(
"dependency discovery was rejected with {} diagnostic(s)",
.0.diagnostics.len()
)]
DependencyDiscoveryRejected(DependencyDiscoveryRejection),
#[error(transparent)]
InvalidPack(#[from] PackInvariantError),
}
pub fn create(input: PackCreationInput<'_>) -> Result<PackCreationOutcome, PackCreationError> {
let entrypoint = VirtualPath::new(input.project.entrypoint())
.expect("Project Snapshot entrypoint invariant violated");
let mut world = SuppliedWorld {
library: LazyHash::new(
Library::builder()
.with_inputs(input.discovery.inputs.clone())
.with_features(input.discovery.features.iter().copied().collect())
.build(),
),
main: RootedPath::new(VirtualRoot::Project, entrypoint).intern(),
files: FileStore::new(SuppliedLoader {
project: input.project,
packages: input.packages,
package_failures: input.package_failures,
}),
fonts: input.fonts.expand(),
used_font_indices: Mutex::new(BTreeSet::new()),
clock: DiscoveryClock::new(input.discovery.document_time),
};
let Warned { output, warnings } = compile_creation_target(&world, input.discovery.target);
let observed = world.observed_packages();
if !observed.missing.is_empty() {
return Ok(PackCreationOutcome::MissingPackageSpecifications(
observed.missing,
));
}
if let Err(diagnostics) = output {
return Err(PackCreationError::DependencyDiscoveryRejected(
DependencyDiscoveryRejection {
diagnostics,
warnings,
},
));
}
let mut builder = Pack::builder(input.project.entrypoint());
for (path, data) in input.project.shared_files() {
builder = map_build(builder.shared_file(path, data.clone()))?;
}
let loader = world.files.loader();
for spec in observed.supplied {
let entry = loader
.packages
.get(&spec)
.expect("observed package was partitioned as supplied");
for (path, data) in entry.tree().shared_files() {
builder = if entry.disposition().is_embedded() {
map_build(builder.shared_package_file(spec.clone(), path, data.clone()))?
} else {
map_build(builder.shared_external_package_file(spec.clone(), path, data.clone()))?
};
}
}
for (font, disposition) in world.used_fonts() {
builder =
if disposition.is_embedded() {
map_build(
builder.shared_font(SharedBytes::from_typst(font.data().clone()), font.index()),
)?
} else {
map_build(builder.shared_external_font(
SharedBytes::from_typst(font.data().clone()),
font.index(),
))?
};
}
if let Some(metadata) = input.metadata {
builder = builder.metadata(metadata.clone());
}
Ok(PackCreationOutcome::Created {
pack: map_build(builder.build())?,
warnings,
})
}
fn map_build<T>(result: Result<T, PackBuildError>) -> Result<T, PackCreationError> {
match result {
Ok(value) => Ok(value),
Err(PackBuildError::Invariant(error)) => Err(PackCreationError::InvalidPack(error)),
}
}
#[derive(Default)]
struct ObservedPackages {
supplied: Vec<PackageSpec>,
missing: Vec<PackageSpec>,
}
struct SuppliedWorld<'a> {
library: LazyHash<Library>,
main: FileId,
files: FileStore<SuppliedLoader<'a>>,
fonts: CatalogFonts,
used_font_indices: Mutex<BTreeSet<usize>>,
clock: DiscoveryClock,
}
impl SuppliedWorld<'_> {
fn observed_packages(&mut self) -> ObservedPackages {
let mut specs: BTreeMap<String, PackageSpec> = BTreeMap::new();
let (loader, dependencies) = self.files.dependencies();
for id in dependencies {
if let VirtualRoot::Package(spec) = id.root() {
specs.insert(spec.to_string(), spec.clone());
}
}
let mut observed = ObservedPackages::default();
for spec in specs.into_values() {
if loader.packages.get(&spec).is_some() {
observed.supplied.push(spec);
} else if loader.package_failures.get(&spec).is_none() {
observed.missing.push(spec);
}
}
observed
}
fn used_fonts(&self) -> Vec<(Font, FontDisposition)> {
self.used_font_indices
.lock()
.expect("used font index lock poisoned")
.iter()
.filter_map(|index| Some((self.fonts.font(*index)?, self.fonts.disposition(*index)?)))
.collect()
}
}
impl World for SuppliedWorld<'_> {
fn library(&self) -> &LazyHash<Library> {
&self.library
}
fn book(&self) -> &LazyHash<FontBook> {
self.fonts.book()
}
fn main(&self) -> FileId {
self.main
}
fn source(&self, id: FileId) -> FileResult<Source> {
self.files.source(id)
}
fn file(&self, id: FileId) -> FileResult<Bytes> {
self.files.file(id)
}
fn font(&self, index: usize) -> Option<Font> {
let font = self.fonts.font(index);
if font.is_some() {
self.used_font_indices
.lock()
.expect("used font index lock poisoned")
.insert(index);
}
font
}
fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
self.clock.today(offset)
}
}
enum DiscoveryClock {
None,
Fixed(Datetime),
Timestamp(Time),
}
impl DiscoveryClock {
fn new(document_time: DocumentTime) -> Self {
match document_time {
DocumentTime::Absent => Self::None,
DocumentTime::Fixed(datetime) => Self::Fixed(datetime),
DocumentTime::UnixTimestamp(timestamp) => Self::Timestamp(
Time::fixed_timestamp(timestamp)
.expect("Discovery Specification validated its Document Time"),
),
}
}
fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
match self {
Self::None => None,
Self::Fixed(datetime) => Some(*datetime),
Self::Timestamp(time) => time.today(offset),
}
}
}
struct SuppliedLoader<'a> {
project: &'a ProjectSnapshot,
packages: &'a PackageCatalog,
package_failures: &'a PackageReadFailures,
}
impl FileLoader for SuppliedLoader<'_> {
fn load(&self, id: FileId) -> FileResult<Bytes> {
let path = id.vpath().get_without_slash();
match id.root() {
VirtualRoot::Project => self
.project
.shared_file(path)
.map(|data| data.to_typst())
.ok_or_else(|| FileError::NotFound(path.into())),
VirtualRoot::Package(spec) => {
let Some(entry) = self.packages.get(spec) else {
return Err(FileError::Package(
self.package_failures
.get(spec)
.map(package_failure_for_discovery)
.unwrap_or_else(|| PackageError::NotFound(spec.clone())),
));
};
entry
.tree()
.shared_file(path)
.map(SharedBytes::to_typst)
.ok_or_else(|| FileError::NotFound(path.into()))
}
}
}
}
fn package_failure_for_discovery(failure: &PackageReadFailure) -> PackageError {
let spec = failure.spec().clone();
match failure.reason() {
PackageReadFailureReason::NotFound => PackageError::NotFound(spec),
PackageReadFailureReason::VersionNotFound { latest } => {
PackageError::VersionNotFound(spec, *latest)
}
PackageReadFailureReason::NetworkFailed { detail } => {
PackageError::NetworkFailed(detail.clone().map(Into::into))
}
PackageReadFailureReason::MalformedArchive { detail } => {
PackageError::MalformedArchive(detail.clone().map(Into::into))
}
PackageReadFailureReason::Other { detail } => {
PackageError::Other(detail.clone().map(Into::into))
}
}
}
fn compile_creation_target(
world: &dyn World,
target: TypstTarget,
) -> Warned<Result<(), EcoVec<SourceDiagnostic>>> {
match target {
TypstTarget::Paged => {
let Warned { output, warnings } = EmbeddedTypst::compile_paged(world);
Warned {
output: output.map(|_| ()),
warnings,
}
}
TypstTarget::Html => {
let Warned { output, warnings } = EmbeddedTypst::compile_html(world);
Warned {
output: output.map(|_| ()),
warnings,
}
}
}
}