calepin 0.0.1

A Rust CLI for preprocessing Typst documents with executable code chunks
pub mod diagram;
pub mod julia;
pub mod python;
pub mod r;
pub mod sh;
pub mod subprocess;

use anyhow::Result;
use std::path::{Path, PathBuf};

use crate::typst::model::{EngineName, FigureSpec};
use crate::utils::tools;

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum EngineResult {
    Source(Vec<String>),
    Output(String),
    Warning(String),
    Message(String),
    Error(String),
    Plot(PathBuf),
    Asis(String),
    Preamble(String),
}

/// Holds mutable references to active engine sessions.
pub struct EngineContext<'a> {
    pub r: Option<&'a mut r::RSession>,
    pub python: Option<&'a mut python::PythonSession>,
    pub julia: Option<&'a mut julia::JuliaSession>,
    pub sh: Option<&'a mut sh::ShSession>,
}

/// Execute a Typst chunk and capture its output.
pub fn execute_chunk(
    source: &[String],
    engine: EngineName,
    label: &str,
    fig_dir: &Path,
    figure: &FigureSpec,
    ctx: &mut EngineContext,
) -> Result<Vec<EngineResult>> {
    let code = source.join("\n");
    let mut results = Vec::new();

    let interleaved = engine != EngineName::Sh;
    if !interleaved {
        results.push(EngineResult::Source(source.to_vec()));
    }

    let is_table_chunk = label.starts_with("tbl-");
    std::fs::create_dir_all(fig_dir)?;
    let fig_full_path = fig_dir.join(figure.numbered_filename(label));
    let fig_abs = if fig_full_path.is_relative() {
        std::env::current_dir()?.join(&fig_full_path)
    } else {
        fig_full_path.clone()
    };
    let fig_full_str = if is_table_chunk {
        String::new()
    } else {
        fig_abs.to_string_lossy().replace('\\', "/")
    };

    let captured =
        match engine {
            EngineName::Sh => {
                let session = ctx
                    .sh
                    .as_mut()
                    .ok_or_else(|| anyhow::anyhow!("{}", tools::not_found_message(&tools::SH)))?;
                session.capture(&code)?
            }
            EngineName::Python => {
                let session = ctx.python.as_mut().ok_or_else(|| {
                    anyhow::anyhow!("{}", tools::not_found_message(&tools::PYTHON))
                })?;
                session.capture(
                    &code,
                    &fig_full_str,
                    figure.width,
                    figure.height,
                    f64::from(figure.dpi),
                )?
            }
            EngineName::Julia => {
                let session = ctx.julia.as_mut().ok_or_else(|| {
                    anyhow::anyhow!("{}", tools::not_found_message(&tools::JULIA))
                })?;
                session.capture(
                    &code,
                    &fig_full_str,
                    &figure.format,
                    figure.width,
                    figure.height,
                    f64::from(figure.dpi),
                )?
            }
            EngineName::R => {
                let session = ctx.r.as_mut().ok_or_else(|| {
                    anyhow::anyhow!("{}", tools::not_found_message(&tools::RSCRIPT))
                })?;
                session.capture(
                    &code,
                    &fig_full_str,
                    figure.r_device(),
                    figure.width,
                    figure.height,
                    f64::from(figure.dpi),
                )?
            }
            other => return Err(anyhow::anyhow!("unsupported engine `{}`", other)),
        };

    process_results(&captured, &fig_full_path, &mut results)?;

    if interleaved
        && !results
            .iter()
            .any(|result| matches!(result, EngineResult::Source(_)))
    {
        results.insert(0, EngineResult::Source(source.to_vec()));
    }

    Ok(results)
}

pub fn make_sentinel() -> String {
    use std::sync::atomic::{AtomicU64, Ordering};

    static COUNTER: AtomicU64 = AtomicU64::new(0);
    let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
    format!("__CALEPIN_{:x}_{:x}__", std::process::id(), seq)
}

fn process_results(raw: &str, fig_path: &Path, results: &mut Vec<EngineResult>) -> Result<()> {
    let (sentinel, rest) = raw.split_once('\n').unwrap_or(("", raw));
    let sep = format!("\n{}_SEP\n", sentinel);

    let source_prefix = format!("{}_SOURCE:", sentinel);
    let output_prefix = format!("{}_OUTPUT:", sentinel);
    let asis_prefix = format!("{}_ASIS:", sentinel);
    let error_prefix = format!("{}_ERROR:", sentinel);
    let warning_prefix = format!("{}_WARNING:", sentinel);
    let message_prefix = format!("{}_MESSAGE:", sentinel);
    let plot_prefix = format!("{}_PLOT:", sentinel);
    let preamble_prefix = format!("{}_PREAMBLE:", sentinel);

    for part in rest.split(&sep) {
        let part = part.trim();
        if part.is_empty() {
            continue;
        }
        if let Some(text) = part.strip_prefix(&source_prefix) {
            if !text.is_empty() {
                results.push(EngineResult::Source(
                    text.lines().map(ToOwned::to_owned).collect(),
                ));
            }
        } else if let Some(text) = part.strip_prefix(&error_prefix) {
            if !text.is_empty() {
                results.push(EngineResult::Error(text.to_string()));
            }
        } else if let Some(text) = part.strip_prefix(&asis_prefix) {
            if !text.is_empty() {
                results.push(EngineResult::Asis(text.to_string()));
            }
        } else if let Some(text) = part.strip_prefix(&output_prefix) {
            if let Some(message) = text.strip_prefix(&error_prefix) {
                results.push(EngineResult::Error(message.to_string()));
            } else if !text.is_empty() {
                results.push(EngineResult::Output(text.to_string()));
            }
        } else if let Some(text) = part.strip_prefix(&warning_prefix) {
            if !text.is_empty() {
                results.push(EngineResult::Warning(text.to_string()));
            }
        } else if let Some(text) = part.strip_prefix(&message_prefix) {
            if !text.is_empty() {
                results.push(EngineResult::Message(text.to_string()));
            }
        } else if part.starts_with(&plot_prefix) {
            if fig_path.exists() {
                results.push(EngineResult::Plot(fig_path.to_path_buf()));
            }
        } else if let Some(text) = part.strip_prefix(&preamble_prefix) {
            if !text.is_empty() {
                results.push(EngineResult::Preamble(text.to_string()));
            }
        }
    }

    Ok(())
}