pub mod diagram;
pub mod jupyter;
pub mod prelude;
pub mod python;
pub mod r;
pub mod subprocess;
use anyhow::{Context, Result};
use serde_json::Value;
use std::path::{Path, PathBuf};
use crate::engines::jupyter::JupyterCapture;
use crate::typst::model::{EngineName, FigureSpec};
pub(crate) const META_PREFIX: &str = "META:";
pub(crate) fn build_payload(meta: Value, code: &str) -> Result<String> {
Ok(format!("{}\n{}", format_meta_payload(meta)?, code))
}
fn format_meta_payload(meta: Value) -> Result<String> {
let encoded = serde_json::to_string(&meta).context("serialize engine meta payload")?;
Ok(format!("{META_PREFIX}{encoded}"))
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum EngineResult {
Source(Vec<String>),
Output(String),
Warning(String),
Message(String),
Error(String),
Unavailable(String),
Plot(PathBuf),
Preamble(String),
}
pub struct EngineContext<'a> {
pub r: Option<&'a mut r::RSession>,
pub python: Option<&'a mut python::PythonSession>,
pub jupyter: Option<&'a mut jupyter::JupyterBridgeSession>,
}
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 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::Python => {
let session = ctx
.python
.as_mut()
.ok_or_else(|| anyhow::anyhow!("Python engine session was not initialized"))?;
session.capture(
&code,
&fig_full_str,
figure.width,
figure.height,
f64::from(figure.dpi),
)?
}
EngineName::R => {
let session = ctx
.r
.as_mut()
.ok_or_else(|| anyhow::anyhow!("R engine session was not initialized"))?;
session.capture(
&code,
&fig_full_str,
figure.r_device(),
figure.width,
figure.height,
f64::from(figure.dpi),
)?
}
EngineName::Jupyter(ref kernel) => {
let session = ctx
.jupyter
.as_mut()
.ok_or_else(|| anyhow::anyhow!("Jupyter engine session was not initialized"))?;
session.capture(JupyterCapture {
kernel,
code: &code,
fig_path: &fig_full_str,
fig_format: &figure.format,
width: figure.width,
height: figure.height,
dpi: f64::from(figure.dpi),
})?
}
other => return Err(anyhow::anyhow!("unsupported engine `{}`", other)),
};
process_results(&captured, &fig_full_path, &mut results)?;
if !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_marker = format!("{}_SEP", sentinel);
let source_prefix = format!("{}_SOURCE:", sentinel);
let output_prefix = format!("{}_OUTPUT:", sentinel);
let error_prefix = format!("{}_ERROR:", sentinel);
let warning_prefix = format!("{}_WARNING:", sentinel);
let message_prefix = format!("{}_MESSAGE:", sentinel);
let unavailable_prefix = format!("{}_UNAVAILABLE:", sentinel);
let plot_prefix = format!("{}_PLOT:", sentinel);
let preamble_prefix = format!("{}_PREAMBLE:", sentinel);
for part in split_result_parts(rest, &sep_marker) {
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(&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 let Some(text) = part.strip_prefix(&unavailable_prefix) {
if !text.is_empty() {
results.push(EngineResult::Unavailable(text.to_string()));
}
} else if let Some(text) = part.strip_prefix(&plot_prefix) {
let path = if text.is_empty() {
fig_path.to_path_buf()
} else {
PathBuf::from(text)
};
results.push(EngineResult::Plot(path));
} else if let Some(text) = part.strip_prefix(&preamble_prefix) {
if !text.is_empty() {
results.push(EngineResult::Preamble(text.to_string()));
}
}
}
Ok(())
}
fn split_result_parts(rest: &str, sep_marker: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
for line in rest.split_inclusive('\n') {
let trimmed_line = line.trim_end_matches('\n').trim_end_matches('\r');
if trimmed_line == sep_marker {
parts.push(std::mem::take(&mut current));
} else {
current.push_str(line);
}
}
parts.push(current);
parts
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn process_results_preserves_missing_plot_paths_for_later_validation() {
let dir = tempfile::tempdir().unwrap();
let missing = dir.path().join("missing-plot.svg");
let raw = format!("__TEST__\n__TEST___PLOT:{}", missing.display());
let mut results = Vec::new();
process_results(&raw, &dir.path().join("fallback.svg"), &mut results).unwrap();
assert_eq!(results.len(), 1);
assert!(matches!(&results[0], EngineResult::Plot(path) if path == &missing));
}
#[test]
fn process_results_parses_unavailable_engine_marker() {
let raw = "__TEST__\n__TEST___UNAVAILABLE:no kernel named sh";
let mut results = Vec::new();
process_results(raw, Path::new("unused.svg"), &mut results).unwrap();
assert_eq!(results.len(), 1);
assert!(
matches!(&results[0], EngineResult::Unavailable(message) if message == "no kernel named sh")
);
}
#[test]
fn process_results_parses_crlf_separated_output_records() {
let raw =
"__TEST__\n__TEST___SOURCE:x = 1\r\nprint(x)\r\n__TEST___SEP\r\n__TEST___OUTPUT:1\r";
let mut results = Vec::new();
process_results(raw, Path::new("unused.svg"), &mut results).unwrap();
assert_eq!(results.len(), 2);
assert!(matches!(
&results[0],
EngineResult::Source(lines) if lines == &vec!["x = 1".to_string(), "print(x)".to_string()]
));
assert!(matches!(&results[1], EngineResult::Output(text) if text == "1"));
}
#[test]
fn process_results_does_not_leak_crlf_markers_into_warning_records() {
let raw = "__TEST__\n__TEST___WARNING:3\r\n__TEST___SEP\r\n__TEST___SOURCE:import sys\r\nprint(3, file=sys.stderr)\r";
let mut results = Vec::new();
process_results(raw, Path::new("unused.svg"), &mut results).unwrap();
assert_eq!(results.len(), 2);
assert!(matches!(&results[0], EngineResult::Warning(text) if text == "3"));
assert!(matches!(
&results[1],
EngineResult::Source(lines) if lines == &vec![
"import sys".to_string(),
"print(3, file=sys.stderr)".to_string()
]
));
}
}