calepin 0.0.13

A Rust CLI for preprocessing Typst documents with executable code chunks
use anyhow::{anyhow, Context, Result};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Mutex, OnceLock};

use super::{root_relative, PreprocessMetadata, PAGE_META_LABEL};
use crate::typst::model::LayoutPaths;
use crate::utils::{process, tools};

static EVAL_AVAILABLE: OnceLock<Mutex<HashMap<PathBuf, bool>>> = OnceLock::new();

pub fn is_available(typst: &Path) -> bool {
    let cache = EVAL_AVAILABLE.get_or_init(|| Mutex::new(HashMap::new()));
    if let Some(available) = cache
        .lock()
        .ok()
        .and_then(|cache| cache.get(typst).copied())
    {
        return available;
    }

    let available = Command::new(typst)
        .arg("eval")
        .arg("1")
        .arg("--format")
        .arg("json")
        .output()
        .is_ok_and(|output| output.status.success());
    if let Ok(mut cache) = cache.lock() {
        cache.insert(typst.to_path_buf(), available);
    }
    available
}

pub fn preprocess_metadata(
    typst: &Path,
    layout: &LayoutPaths,
    input: &Path,
    results_input: &str,
) -> Result<PreprocessMetadata> {
    let output = typst_eval(
        typst,
        layout,
        input,
        r#"(
  setup: query(selector(<calepin-config>).or(<website-metadata>)),
  chunks: query(raw.where(block: true).or(<calepin-fence-label>).or(<calepin-chunk>)),
)"#,
        &[
            "calepin-mode=query".to_string(),
            format!("calepin-results={results_input}"),
            "calepin-target=paged".to_string(),
        ],
    )?;
    let root: Value =
        serde_json::from_str(&output).context("failed to parse typst eval metadata output")?;
    let setup = root
        .get("setup")
        .cloned()
        .ok_or_else(|| anyhow!("typst eval metadata output is missing `setup`"))?;
    let chunks = root
        .get("chunks")
        .cloned()
        .ok_or_else(|| anyhow!("typst eval metadata output is missing `chunks`"))?;
    let (setup_json, page_meta) = split_page_meta(setup)?;

    Ok(PreprocessMetadata {
        setup_json,
        page_meta,
        chunks_json: serde_json::to_string(&chunks)?,
    })
}

pub fn page_anchors(typst: &Path, layout: &LayoutPaths) -> Result<HashMap<String, usize>> {
    let results_input = super::results_input(layout);
    let output = typst_eval(
        typst,
        layout,
        &layout.render_input,
        r#"query(<calepin-page>).map(it => (
  label: it.value.label,
  page: it.value.page,
))"#,
        &[
            "calepin-mode=render".to_string(),
            format!("calepin-results={results_input}"),
            "calepin-target=paged".to_string(),
        ],
    )?;
    let root: Value =
        serde_json::from_str(&output).context("failed to parse typst eval page sync output")?;
    super::parse_page_anchor_entries(&root)
}

fn typst_eval(
    typst: &Path,
    layout: &LayoutPaths,
    input: &Path,
    expression: &str,
    inputs: &[String],
) -> Result<String> {
    process::validate_executable(typst, "run typst eval", Some(&tools::TYPST))?;
    let input = root_relative(input, &layout.root);
    let mut command = Command::new(typst);
    command
        .arg("eval")
        .arg(expression)
        .arg("--in")
        .arg(input)
        .arg("--root")
        .arg(&layout.root)
        .arg("--format")
        .arg("json");
    for input in inputs {
        command.arg("--input").arg(input);
    }
    let output = command
        .current_dir(&layout.root)
        .output()
        .map_err(|error| {
            process::spawn_error(typst, "run typst eval", error, Some(&tools::TYPST))
        })?;

    if !output.status.success() {
        return Err(anyhow!(
            "typst eval failed:\n{}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }

    String::from_utf8(output.stdout).context("typst eval output was not UTF-8")
}

fn split_page_meta(setup: Value) -> Result<(String, Option<Value>)> {
    let array = setup
        .as_array()
        .ok_or_else(|| anyhow!("typst eval setup output must be an array"))?;
    let page_meta_label = format!("<{PAGE_META_LABEL}>");
    let mut rest = Vec::new();
    let mut page_meta = None;
    for item in array {
        let label = item.get("label").and_then(Value::as_str);
        if label == Some(page_meta_label.as_str()) {
            if page_meta.is_none() {
                page_meta = item.get("value").cloned();
            }
        } else {
            rest.push(item.clone());
        }
    }
    Ok((serde_json::to_string(&rest)?, page_meta))
}

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

    #[test]
    fn split_page_meta_separates_setup_entries_from_page_metadata() {
        let setup = serde_json::json!([
            {"func":"metadata","value":{"echo":true},"label":"<calepin-config>"},
            {"func":"metadata","value":{"title":"T","pdf":false},"label":"<website-metadata>"}
        ]);

        let (setup_json, page_meta) = split_page_meta(setup).unwrap();

        let setup: Value = serde_json::from_str(&setup_json).unwrap();
        assert_eq!(setup.as_array().unwrap().len(), 1);
        assert_eq!(setup[0]["label"], "<calepin-config>");
        assert_eq!(
            page_meta,
            Some(serde_json::json!({"title": "T", "pdf": false}))
        );
    }
}