calepin 0.0.27

A Rust CLI for preprocessing Typst documents with executable code chunks
use std::fs;
use std::path::{Component, Path, PathBuf};

use anyhow::{anyhow, Context, Result};

use crate::typst::io::write_if_changed;
use crate::typst::paths::CALEPIN_DIR;

const RUNTIME_DIR: &str = "runtime";
const GENERATED_SYNTAX_THEME_FILE: &str = "00_syntax-theme.typ";
const FACADE_FILE: &str = "calepin.typ";
const RUNTIME_FACADE_SOURCE: &str = r#"// Generated by Calepin. Do not edit.

#import "runtime/core/state.typ" as state
#import "runtime/core/target.typ" as target
#import "runtime/notebook/render.typ" as render
#import "runtime/notebook/options.typ" as options
#import "runtime/notebook/chunk.typ" as chunks
#import "runtime/elements/mod.typ" as elementmod

#let pages = state.pages
#let setup = options.setup
#let chunk = chunks.chunk
#let inline = chunks.inline
#let results = chunks.results
#let chunk_from_raw_plain = chunks.chunk_from_raw_plain
#let code-block = render.code-block
#let elements = elementmod

#let _mode = target._mode
#let _is-html = target._is-html
#let _is-paged = target._is-paged
#let _is-query = target._is-query
#let _is-render = target._is-render
#let _call-defaults = state._call-defaults
#let _disable-raw-chunk-transforms = state._disable-raw-chunk-transforms
#let _resolve-options = options._resolve-options
#let _html-themed-raw-block = render._html-themed-raw-block
#let _without-raw-chunk-transforms = chunks._without-raw-chunk-transforms
#let _fenced-chunks-runs = chunks._fenced-chunks-runs
"#;

struct RuntimeFile {
    path: &'static str,
    source: &'static str,
}

include!(concat!(env!("OUT_DIR"), "/typst_runtime_assets.rs"));

fn runtime_source_files() -> &'static [RuntimeFile] {
    RUNTIME_FILES
}

fn runtime_facade_source() -> &'static str {
    RUNTIME_FACADE_SOURCE
}

fn runtime_asset_path(path: &str) -> Result<&Path> {
    let path = Path::new(path);
    let is_valid = path.is_relative()
        && path.extension().and_then(|extension| extension.to_str()) == Some("typ")
        && path
            .components()
            .all(|component| matches!(component, Component::Normal(_)));
    if !is_valid {
        return Err(anyhow!(
            "invalid embedded runtime asset path `{}`",
            path.display()
        ));
    }
    Ok(path)
}

#[cfg(test)]
pub fn write_runtime(root: &Path) -> Result<PathBuf> {
    write_runtime_with_syntax_theme_in_dir(
        root,
        Path::new(CALEPIN_DIR),
        &crate::html::HtmlSyntaxTheme::builtin(),
    )
}

pub(crate) fn write_runtime_with_syntax_theme(
    root: &Path,
    syntax_theme: &crate::html::HtmlSyntaxTheme,
) -> Result<PathBuf> {
    write_runtime_with_syntax_theme_in_dir(root, Path::new(CALEPIN_DIR), syntax_theme)
}

pub(crate) fn write_runtime_with_syntax_theme_in_dir(
    root: &Path,
    asset_dir: &Path,
    syntax_theme: &crate::html::HtmlSyntaxTheme,
) -> Result<PathBuf> {
    let calepin_dir = root.join(asset_dir);
    let generated_runtime_dir = calepin_dir.join(RUNTIME_DIR);
    fs::create_dir_all(&generated_runtime_dir)
        .with_context(|| format!("failed to create {}", generated_runtime_dir.display()))?;

    write_if_changed(
        &generated_runtime_dir.join(GENERATED_SYNTAX_THEME_FILE),
        syntax_theme.typst_runtime_source(),
    )?;

    for source_file in runtime_source_files() {
        let destination = generated_runtime_dir.join(runtime_asset_path(source_file.path)?);
        write_if_changed(&destination, source_file.source)?;
    }

    let facade = calepin_dir.join(FACADE_FILE);
    write_if_changed(&facade, runtime_facade_source())?;
    Ok(facade)
}

#[cfg(test)]
mod embedded_runtime_tests {
    use super::*;

    #[test]
    fn runtime_source_files_are_embedded_relative_typ_files() {
        let files = runtime_source_files();

        assert!(!files.is_empty(), "expected embedded Typst runtime files");
        for file in files {
            let path = Path::new(file.path);
            assert!(
                path.is_relative(),
                "runtime asset path should be relative: {}",
                file.path
            );
            assert_eq!(
                path.extension().and_then(|extension| extension.to_str()),
                Some("typ")
            );
            assert!(
                !file.source.is_empty(),
                "runtime asset should have embedded source: {}",
                file.path
            );
        }
    }

    #[test]
    fn runtime_asset_path_rejects_non_normal_paths() {
        for path in ["../escape.typ", "core/../escape.typ", "/absolute.typ"] {
            assert!(
                runtime_asset_path(path).is_err(),
                "expected runtime asset path to be rejected: {path}"
            );
        }
    }

    #[test]
    fn runtime_facade_imports_embedded_runtime_files() {
        let embedded_paths = runtime_source_files()
            .iter()
            .map(|file| file.path)
            .collect::<std::collections::HashSet<_>>();

        for target in runtime_facade_imports(runtime_facade_source()) {
            let Some(runtime_path) = target.strip_prefix("runtime/") else {
                panic!("facade import should target runtime directory: {target}");
            };
            assert!(
                embedded_paths.contains(runtime_path),
                "facade import target is not embedded: {target}"
            );
        }
    }

    fn runtime_facade_imports(source: &str) -> impl Iterator<Item = &str> {
        source.lines().filter_map(|line| {
            line.trim_start()
                .strip_prefix("#import \"")
                .and_then(|line| line.split('"').next())
        })
    }
}

#[cfg(test)]
#[path = "runtime_tests.rs"]
mod runtime_tests;