typst-pack 0.5.0

Portable single-file packs of Typst projects: sources, resources, packages, and fonts
Documentation
use std::collections::BTreeSet;
use std::sync::Mutex;

use typst::diag::{FileError, FileResult};
use typst::foundations::{Bytes, Datetime, Duration};
use typst::syntax::{FileId, Source, VirtualRoot};
use typst::text::{Font, FontBook};
use typst::utils::LazyHash;
use typst::{Library, World};

use crate::{
    CompilationAccessKind, CompilationAccessObservation, CompilationAccessOutcome,
    CompilationAccessTrace,
};

pub(crate) struct WorldTrace<'a, W: ?Sized> {
    world: &'a W,
    observations: Mutex<BTreeSet<CompilationAccessObservation>>,
}

impl<'a, W: World + ?Sized> WorldTrace<'a, W> {
    pub(crate) fn new(world: &'a W) -> Self {
        Self {
            world,
            observations: Mutex::new(BTreeSet::new()),
        }
    }

    pub(crate) fn snapshot(&self) -> CompilationAccessTrace {
        CompilationAccessTrace::from_observations(
            self.observations
                .lock()
                .expect("world trace lock poisoned")
                .clone(),
        )
    }

    fn record_file<T>(
        &self,
        id: FileId,
        kind: CompilationAccessKind,
        result: &FileResult<T>,
        bytes: impl FnOnce(&T) -> &[u8],
    ) {
        let outcome = match result {
            Ok(value) => read_outcome(bytes(value)),
            Err(FileError::NotFound(_)) => CompilationAccessOutcome::Missing,
            Err(_) => CompilationAccessOutcome::Failed,
        };
        self.record(CompilationAccessObservation::new(
            kind,
            logical_path(id),
            None,
            outcome,
        ));
    }

    fn record(&self, observation: CompilationAccessObservation) {
        self.observations
            .lock()
            .expect("world trace lock poisoned")
            .insert(observation);
    }
}

impl<W: World + ?Sized> World for WorldTrace<'_, W> {
    fn library(&self) -> &LazyHash<Library> {
        self.world.library()
    }

    fn book(&self) -> &LazyHash<FontBook> {
        self.world.book()
    }

    fn main(&self) -> FileId {
        self.world.main()
    }

    fn source(&self, id: FileId) -> FileResult<Source> {
        let result = self.world.source(id);
        let outcome = match &result {
            Ok(_) => self
                .world
                .file(id)
                .map_or(CompilationAccessOutcome::Failed, |bytes| {
                    read_outcome(bytes.as_slice())
                }),
            Err(FileError::NotFound(_)) => CompilationAccessOutcome::Missing,
            Err(_) => CompilationAccessOutcome::Failed,
        };
        self.record(CompilationAccessObservation::new(
            CompilationAccessKind::Source,
            logical_path(id),
            None,
            outcome,
        ));
        result
    }

    fn file(&self, id: FileId) -> FileResult<Bytes> {
        let result = self.world.file(id);
        self.record_file(id, CompilationAccessKind::File, &result, Bytes::as_slice);
        result
    }

    fn font(&self, requested_index: usize) -> Option<Font> {
        let font = self.world.font(requested_index);
        let observation = match &font {
            Some(font) => CompilationAccessObservation::new(
                CompilationAccessKind::Font,
                format!(
                    "font:{:032x}",
                    u128::from_be_bytes(
                        crate::pack::font_container_identity(font.data().as_slice()).digest()
                    )
                ),
                Some(font.index() as usize),
                read_outcome(font.data().as_slice()),
            ),
            None => CompilationAccessObservation::new(
                CompilationAccessKind::Font,
                format!("font-index:{requested_index}"),
                Some(requested_index),
                CompilationAccessOutcome::Missing,
            ),
        };
        self.record(observation);
        font
    }

    fn today(&self, offset: Option<Duration>) -> Option<Datetime> {
        self.world.today(offset)
    }
}

fn read_outcome(data: &[u8]) -> CompilationAccessOutcome {
    CompilationAccessOutcome::Read {
        byte_length: data.len(),
        digest: typst::utils::hash128(&data).to_be_bytes(),
    }
}

pub(crate) fn logical_path(id: FileId) -> String {
    let path = id.vpath().get_without_slash();
    match id.root() {
        VirtualRoot::Project => format!("project:{path}"),
        VirtualRoot::Package(spec) => format!("package:{spec}/{path}"),
    }
}

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use typst::syntax::{RootedPath, VirtualPath};

    use super::*;
    use crate::{Pack, world::PackWorld};

    #[test]
    fn captures_exact_file_outcomes_and_missing_font_requests() {
        let pack = Pack::builder("main.typ")
            .file("main.typ", b"Hello".to_vec())
            .unwrap()
            .file("bad.typ", vec![0xff])
            .unwrap()
            .build()
            .unwrap();
        let dependencies = pack.materialize_compilation_dependency_snapshot(
            std::collections::BTreeMap::new(),
            std::collections::BTreeMap::new(),
        );
        let world = PackWorld::new(
            pack,
            dependencies,
            std::collections::BTreeMap::new(),
            typst::foundations::Dict::new(),
            vec![],
            crate::DocumentTime::Absent,
        )
        .unwrap();
        let trace = WorldTrace::new(&world);
        let bad =
            RootedPath::new(VirtualRoot::Project, VirtualPath::new("bad.typ").unwrap()).intern();
        let missing = RootedPath::new(
            VirtualRoot::Project,
            VirtualPath::new("missing.bin").unwrap(),
        )
        .intern();

        trace.source(world.main()).unwrap();
        trace.source(world.main()).unwrap();
        assert!(matches!(trace.source(bad), Err(FileError::InvalidUtf8)));
        assert_eq!(
            trace.file(missing),
            Err(FileError::NotFound(PathBuf::from("missing.bin")))
        );
        assert!(trace.font(41).is_none());

        let observations = trace.snapshot();
        let observations = observations.observations().collect::<Vec<_>>();
        assert_eq!(observations.len(), 4);
        assert!(observations.iter().any(|observation| {
            observation.kind() == CompilationAccessKind::Source
                && observation.logical_path() == "project:main.typ"
                && observation.font_index().is_none()
                && observation.outcome()
                    == &CompilationAccessOutcome::Read {
                        byte_length: 5,
                        digest: typst::utils::hash128(&b"Hello".as_slice()).to_be_bytes(),
                    }
        }));
        assert!(observations.iter().any(|observation| {
            observation.kind() == CompilationAccessKind::Source
                && observation.logical_path() == "project:bad.typ"
                && observation.font_index().is_none()
                && observation.outcome() == &CompilationAccessOutcome::Failed
        }));
        assert!(observations.iter().any(|observation| {
            observation.kind() == CompilationAccessKind::File
                && observation.logical_path() == "project:missing.bin"
                && observation.font_index().is_none()
                && observation.outcome() == &CompilationAccessOutcome::Missing
        }));
        assert!(observations.iter().any(|observation| {
            observation.kind() == CompilationAccessKind::Font
                && observation.logical_path() == "font-index:41"
                && observation.font_index() == Some(41)
                && observation.outcome() == &CompilationAccessOutcome::Missing
        }));
    }
}