mod options;
mod value;
use anyhow::{anyhow, Context, Result};
use serde_json::Value;
use std::collections::HashSet;
use crate::typst::chunk_options::{
fence_label_from_chunk_body, parse_chunk_body_with_qmd_header,
parse_chunk_source_with_qmd_header, query_label_name, validate_chunk_arguments,
};
use crate::typst::crossref::{has_crossref_prefix, parse_label_names};
use crate::typst::model::{ChunkSpec, CrossrefLabelDoc, EngineName, FencedChunks};
use options::parse_chunk_options;
pub use options::{parse_setup_config, SetupConfig};
use value::{
is_auto, is_calepin_chunk_metadata, is_calepin_fence_label_metadata, is_raw_code_block,
parse_query_values, value_for,
};
#[cfg(test)]
fn parse_chunks(query_json: &str, defaults: Option<SetupConfig>) -> Result<Vec<ChunkSpec>> {
Ok(parse_chunks_with_warnings(query_json, defaults)?.chunks)
}
#[derive(Debug, Clone, PartialEq)]
pub struct ChunkParseResult {
pub chunks: Vec<ChunkSpec>,
pub warnings: Vec<String>,
}
pub fn parse_chunks_with_warnings(
query_json: &str,
defaults: Option<SetupConfig>,
) -> Result<ChunkParseResult> {
let config = defaults.unwrap_or_default();
let values = parse_query_values(query_json)
.context("failed to parse calepin chunk metadata from typst query output")?;
let has_chunk_metadata = values.iter().any(is_calepin_chunk_metadata);
let mut seen = HashSet::new();
let mut raw_labels = HashSet::new();
let mut chunks = Vec::with_capacity(values.len());
let mut auto_label_index = 1usize;
let mut warnings = Vec::new();
let mut index = 0usize;
while index < values.len() {
let ordinal = index;
let value = &values[index];
if is_raw_code_block(value) {
let lookahead_fence_label = values
.get(index + 1)
.filter(|value| is_calepin_fence_label_metadata(value))
.map(parse_fence_label_metadata)
.transpose()?;
let mut state = ChunkParseState {
seen: &mut seen,
auto_label_index: &mut auto_label_index,
warnings: &mut warnings,
};
if let Some(chunk) = parse_chunk_raw_block(
value,
&config,
&mut state,
ordinal,
has_chunk_metadata,
lookahead_fence_label.clone(),
)? {
raw_labels.insert(chunk.label.clone());
chunks.push(chunk);
}
if lookahead_fence_label.is_some() {
index += 2;
} else {
index += 1;
}
continue;
}
if is_calepin_fence_label_metadata(value) {
index += 1;
continue;
}
if is_calepin_chunk_metadata(value) {
let value = value
.get("value")
.context("chunk metadata is missing `value` field")?;
let label = parse_label(value)?;
let label = normalize_chunk_label(label.as_str(), &mut seen, &mut auto_label_index);
if !seen.insert(label.clone()) {
if raw_labels.remove(&label) {
chunks.retain(|chunk| chunk.label != label);
} else {
return Err(anyhow!("duplicate label `{}`", label));
}
}
parse_chunk_metadata(value, &config, &label, &mut chunks, ordinal, &mut warnings)?;
bump_auto_label(&mut auto_label_index, &label);
}
index += 1;
}
Ok(ChunkParseResult { chunks, warnings })
}
struct ChunkParseState<'a> {
seen: &'a mut HashSet<String>,
auto_label_index: &'a mut usize,
warnings: &'a mut Vec<String>,
}
fn parse_fence_label_metadata(value: &Value) -> Result<String> {
let label = value
.get("value")
.and_then(|value| value.get("label"))
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("calepin fence label metadata is missing `label`"))?;
query_label_name(label)
}
fn raw_block_query_label(value: &Value) -> Result<Option<String>> {
value
.get("label")
.and_then(Value::as_str)
.map(query_label_name)
.transpose()
}
fn parse_chunk_metadata(
value: &Value,
config: &SetupConfig,
label: &str,
chunks: &mut Vec<ChunkSpec>,
ordinal: usize,
warnings: &mut Vec<String>,
) -> Result<()> {
validate_chunk_arguments(value, label)?;
let (code, chunk_options, mut header_warnings) =
parse_chunk_body_with_qmd_header(value.get("body").unwrap_or(&Value::Null), label)?;
let fence_label =
fence_label_from_chunk_body(value.get("body").unwrap_or(&Value::Null), label)?;
warnings.append(&mut header_warnings);
let mut value_with_options = value
.as_object()
.cloned()
.ok_or_else(|| anyhow!("chunk metadata is not an object"))?;
for (key, value) in chunk_options {
value_with_options.insert(key, value);
}
let value = Value::Object(value_with_options);
let engine = parse_engine(&value)?;
let defaults = &config.defaults;
let (exec_options, display_options) = parse_chunk_options(&value, defaults)?;
let mut crossref_labels = parse_crossref_labels(&value)
.map_err(|err| anyhow!("invalid cross-reference labels for chunk `{label}`: {err}"))?;
if let Some(fence_label) = fence_label {
if has_crossref_prefix(&fence_label) {
let names = vec![fence_label];
let routed_fence_labels = parse_label_names(&names)
.map_err(|err| anyhow!("invalid trailing fence label for chunk `{label}`: {err}"))?
.into_iter()
.map(|label| label.to_doc())
.collect::<Vec<_>>();
if crossref_labels.is_empty() {
crossref_labels = routed_fence_labels;
} else if crossref_labels != routed_fence_labels {
return Err(anyhow!(
"chunk `{label}` supplied a trailing fence label and another label channel"
));
}
}
}
chunks.push(ChunkSpec {
label: label.to_string(),
engine,
code,
exec_options,
display_options,
ordinal,
crossref_labels,
});
Ok(())
}
fn parse_chunk_label_index(label: &str) -> Option<usize> {
let suffix = label.strip_prefix("chunk-")?;
suffix.parse::<usize>().ok()
}
fn normalize_chunk_label(
label: &str,
seen: &mut HashSet<String>,
auto_label_index: &mut usize,
) -> String {
let Some(label_index) = parse_chunk_label_index(label) else {
return label.to_string();
};
if label_index < *auto_label_index {
let next_label = format!("chunk-{auto_label_index}");
if seen.contains(&next_label) {
return next_available_label(seen, auto_label_index);
}
return next_label;
}
label.to_string()
}
fn parse_chunk_raw_block(
value: &Value,
config: &SetupConfig,
state: &mut ChunkParseState<'_>,
ordinal: usize,
has_chunk_metadata: bool,
lookahead_fence_label: Option<String>,
) -> Result<Option<ChunkSpec>> {
let Some(lang) = value.get("lang").and_then(Value::as_str) else {
return Ok(None);
};
if is_typst_fence(lang) {
return Ok(None);
}
let Some(engine) = EngineName::parse(lang).ok() else {
return Ok(None);
};
let raw_fence_label = raw_block_query_label(value)?;
let raw_text = value.get("text").and_then(Value::as_str).unwrap_or("");
let label_hint = format!("chunk-{}", *state.auto_label_index);
let (raw_code, chunk_options, mut header_warnings) =
parse_chunk_source_with_qmd_header(raw_text, &label_hint)?;
state.warnings.append(&mut header_warnings);
let mut value_with_options = value
.as_object()
.cloned()
.ok_or_else(|| anyhow!("fenced chunk is not an object"))?;
value_with_options.remove("label");
for (key, value) in chunk_options {
value_with_options.insert(key, value);
}
let value = Value::Object(value_with_options);
let raw_text = raw_code;
let (engine, code) = reattach_version_suffix(engine, lang, &raw_text);
let defaults = &config.defaults;
if !defaults.fenced_chunks.allows(lang) && !defaults.fenced_chunks.allows(engine.as_str()) {
return Ok(None);
}
if matches!(defaults.fenced_chunks, FencedChunks::All)
&& matches!(engine, EngineName::Jupyter(_))
{
return Ok(None);
}
if has_chunk_metadata && !matches!(engine, EngineName::Jupyter(_)) {
return Ok(None);
}
let fence_label = match (lookahead_fence_label, raw_fence_label) {
(Some(a), Some(b)) => {
return Err(anyhow!(
"fenced chunk supplied more than one trailing fence label (`{a}` and `{b}`)"
));
}
(Some(label), None) | (None, Some(label)) => Some(label),
(None, None) => None,
};
let (label, crossref_labels) = if let Some(label_value) = value_for(&value, "label") {
if let Some(fence_label) = fence_label {
return Err(anyhow!(
"fenced chunk supplied both `#| label:` and trailing fence label `{fence_label}`"
));
}
let names = label_names_from_value(label_value)?;
resolve_named_label(
names,
state.seen,
state.auto_label_index,
"invalid cross-reference labels for fenced chunk",
)?
} else if let Some(fence_label) = fence_label {
resolve_named_label(
vec![fence_label],
state.seen,
state.auto_label_index,
"invalid trailing fence label for fenced chunk",
)?
} else {
let label = next_available_label(state.seen, state.auto_label_index);
state.seen.insert(label.clone());
(label, vec![])
};
let (exec_options, display_options) = parse_chunk_options(&value, defaults)?;
Ok(Some(ChunkSpec {
label,
engine,
code,
exec_options,
display_options,
ordinal,
crossref_labels,
}))
}
fn resolve_named_label(
names: Vec<String>,
seen: &mut HashSet<String>,
auto_label_index: &mut usize,
error_context: &str,
) -> Result<(String, Vec<CrossrefLabelDoc>)> {
let label = names
.first()
.cloned()
.ok_or_else(|| anyhow!("fenced chunk label list is empty"))?;
let prefixed: Vec<String> = names
.iter()
.filter(|name| has_crossref_prefix(name))
.cloned()
.collect();
let crossref_labels = if prefixed.is_empty() {
Vec::new()
} else {
parse_label_names(&prefixed)
.map_err(|err| anyhow!("{error_context}: {err}"))?
.into_iter()
.map(|label| label.to_doc())
.collect()
};
if !seen.insert(label.clone()) {
return Err(anyhow!("duplicate label `{}`", label));
}
bump_auto_label(auto_label_index, &label);
Ok((label, crossref_labels))
}
fn is_typst_fence(lang: &str) -> bool {
matches!(lang, "typ" | "typst")
}
fn bump_auto_label(auto_label_index: &mut usize, label: &str) {
let Some(suffix) = label.strip_prefix("chunk-") else {
return;
};
if let Ok(idx) = suffix.parse::<usize>() {
*auto_label_index = (*auto_label_index).max(idx + 1);
}
}
fn next_available_label(seen: &mut HashSet<String>, counter: &mut usize) -> String {
while seen.contains(&format!("chunk-{counter}")) {
*counter += 1;
}
let label = format!("chunk-{counter}");
*counter += 1;
label
}
fn parse_label(value: &Value) -> Result<String> {
let Some(label) = value.get("label").and_then(Value::as_str) else {
return Err(anyhow!("missing label"));
};
if label.trim().is_empty() {
return Err(anyhow!("missing label"));
}
Ok(label.to_string())
}
fn parse_engine(value: &Value) -> Result<EngineName> {
let engine = value
.get("engine")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("missing engine"))?;
EngineName::parse(engine)
}
fn parse_crossref_labels(value: &Value) -> Result<Vec<CrossrefLabelDoc>> {
let Some(raw) = value.get("crossref-labels") else {
return Ok(Vec::new());
};
if is_auto(raw) || raw.is_null() {
return Ok(Vec::new());
}
let names = match raw {
Value::String(name) => vec![name.clone()],
Value::Array(items) => items
.iter()
.map(|item| {
item.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| anyhow!("`crossref-labels` entries must be strings"))
})
.collect::<Result<Vec<_>>>()?,
_ => return Err(anyhow!("`crossref-labels` must be a string or an array")),
};
if names.is_empty() {
return Ok(Vec::new());
}
let prefixed: Vec<String> = names
.into_iter()
.filter(|name| has_crossref_prefix(name))
.collect();
if prefixed.is_empty() {
return Ok(Vec::new());
}
parse_label_names(&prefixed)
.map(|labels| labels.into_iter().map(|label| label.to_doc()).collect())
}
fn label_names_from_value(value: &Value) -> Result<Vec<String>> {
match value {
Value::String(name) => Ok(vec![name.clone()]),
Value::Array(items) => items
.iter()
.map(|item| {
item.as_str()
.map(ToOwned::to_owned)
.ok_or_else(|| anyhow!("label entries must be strings"))
})
.collect::<Result<Vec<_>>>(),
_ => Err(anyhow!("label must be a string or an array")),
}
}
fn reattach_version_suffix(engine: EngineName, lang: &str, raw_text: &str) -> (EngineName, String) {
let text = raw_text.strip_prefix('\n').unwrap_or(raw_text);
if !matches!(engine, EngineName::Jupyter(_)) {
return (engine, text.to_string());
}
let (first_line, rest) = match text.split_once('\n') {
Some((f, r)) => (f, r),
None => (text, ""),
};
let is_version_suffix = first_line.starts_with('.')
&& first_line.len() > 1
&& first_line[1..]
.split('.')
.all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()));
if !is_version_suffix {
return (engine, text.to_string());
}
let full_name = format!("{}{}", lang, first_line);
let new_engine = EngineName::Jupyter(full_name);
(new_engine, rest.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::typst::model::{EngineName, ResultsMode, SetupDefaults};
fn metadata(value: &str) -> String {
format!(r#"[{{"func":"metadata","value":{value},"label":"<calepin-chunk>"}}]"#)
}
fn setup_metadata(value: &str) -> String {
format!(r#"[{{"func":"metadata","value":{value},"label":"<calepin-config>"}}]"#)
}
fn setup_config_with(defaults: SetupDefaults) -> SetupConfig {
SetupConfig { defaults }
}
#[test]
fn parse_valid_chunk() {
let json = metadata(
r#"{
"body":{"func":"raw","text":"x <- 1","block":false},
"code":"x <- 1",
"engine":"r",
"label":"setup",
"echo":false,
"eval":"auto",
"output":"auto",
"results":"render",
"warning":"auto",
"message":"auto",
"error":"auto",
"placeholder":"auto",
"fig-device-format":"auto",
"fig-device-dpi":"auto",
"fig-device-width":"auto",
"fig-device-height":"auto",
"fig-device-aspect":"auto",
"fig-width":"70%",
"fig-height":"auto",
"fig-align":"center",
"fig-responsive":true,
"fig-link":"https://example.com",
"fig-caption":{"func":"text","text":"Caption"},
"fig-cap-location":"top",
"fig-alt-text":null,
"fig-subcaptions":["A","B"],
"fig-layout-columns":[1,1],
"fig-layout-rows":"auto",
"kind":"auto"
}"#,
);
let chunks = parse_chunks(&json, None).unwrap();
assert_eq!(chunks.len(), 1);
let chunk = &chunks[0];
assert_eq!(chunk.label, "setup");
assert_eq!(chunk.engine, EngineName::R);
assert_eq!(chunk.code, "x <- 1");
assert!(!chunk.display_options.echo);
assert_eq!(chunk.exec_options.fig_device_format, "svg");
assert_eq!(chunk.exec_options.fig_device_width, 6.0);
assert_eq!(chunk.exec_options.fig_device_aspect, 0.618);
assert_eq!(
chunk.display_options.fig_caption.as_deref(),
Some("Caption")
);
assert_eq!(
chunk.display_options.fig_subcaptions.as_ref().unwrap(),
&vec!["A".to_string(), "B".to_string()]
);
assert_eq!(chunk.ordinal, 0);
}
#[test]
fn parses_qmd_style_headers_in_metadata_chunks() {
let json = metadata(
&serde_json::json!({
"body":{"func":"raw","text":"#| echo: false\n#| out-width: 80%\nprint(1)","block":false},
"engine":"r",
"label":"from-header"
})
.to_string(),
);
let parsed = parse_chunks_with_warnings(&json, None).unwrap();
let chunk = &parsed.chunks[0];
assert_eq!(chunk.label, "from-header");
assert_eq!(chunk.code, "print(1)");
assert!(!chunk.display_options.echo);
assert_eq!(
chunk
.display_options
.fig_width
.as_ref()
.and_then(|v| v.as_str()),
Some("80%")
);
assert!(parsed
.warnings
.iter()
.any(|warning| warning.contains("translated to `fig-width`")));
}
#[test]
fn parses_crossref_labels_from_metadata_chunks() {
let json = metadata(
&serde_json::json!({
"body":{"func":"raw","text":"plot(1)","block":false},
"engine":"r",
"label":"fig-plot",
"crossref-labels":["fig-plot","lst-plot"]
})
.to_string(),
);
let chunks = parse_chunks(&json, None).unwrap();
assert_eq!(chunks[0].label, "fig-plot");
assert_eq!(chunks[0].crossref_labels.len(), 2);
assert_eq!(chunks[0].crossref_labels[0].kind, "fig");
assert_eq!(chunks[0].crossref_labels[0].name, "fig-plot");
assert_eq!(chunks[0].crossref_labels[1].kind, "lst");
}
#[test]
fn accepts_unprefixed_label_as_plain_id_from_metadata_chunks() {
let json = metadata(
&serde_json::json!({
"body":{"func":"raw","text":"plot(1)","block":false},
"engine":"r",
"label":"plot",
"crossref-labels":["plot"]
})
.to_string(),
);
let chunks = parse_chunks(&json, None).unwrap();
assert_eq!(chunks[0].label, "plot");
assert!(chunks[0].crossref_labels.is_empty());
}
#[test]
fn parses_qmd_style_headers_in_fenced_chunks() {
let json = serde_json::json!([
{"func":"raw","text":"#| out-width: 90%\n#| fig-align: right\nprint(1)","block":true,"lang":"python"}
])
.to_string();
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::All,
..SetupDefaults::default()
};
let parsed = parse_chunks_with_warnings(&json, Some(setup_config_with(defaults))).unwrap();
let chunk = &parsed.chunks[0];
assert_eq!(chunk.label, "chunk-1");
assert_eq!(chunk.code, "print(1)");
assert_eq!(
chunk.display_options.fig_width.as_ref().unwrap(),
&Value::String("90%".to_string())
);
assert_eq!(
chunk.display_options.fig_align.as_ref().unwrap(),
&Value::String("right".to_string())
);
assert!(parsed
.warnings
.iter()
.any(|warning| warning.contains("translated to `fig-width`")));
}
#[test]
fn parses_qmd_label_in_fenced_chunks() {
let json = serde_json::json!([
{"func":"raw","text":"#| label: fig-fenced\n#| fig-caption: Fenced plot\nplot(1)","block":true,"lang":"r"}
])
.to_string();
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::All,
..SetupDefaults::default()
};
let parsed = parse_chunks_with_warnings(&json, Some(setup_config_with(defaults))).unwrap();
let chunk = &parsed.chunks[0];
assert_eq!(chunk.label, "fig-fenced");
assert_eq!(chunk.crossref_labels.len(), 1);
assert_eq!(chunk.crossref_labels[0].kind, "fig");
assert_eq!(chunk.crossref_labels[0].name, "fig-fenced");
assert_eq!(chunk.code, "plot(1)");
assert_eq!(
chunk.display_options.fig_caption.as_deref(),
Some("Fenced plot")
);
}
#[test]
fn parses_trailing_label_metadata_in_fenced_chunks() {
let json = serde_json::json!([
{"func":"raw","text":"plot(1)","block":true,"lang":"r"},
{"func":"metadata","value":{"label":"fig-trailing"},"label":"<calepin-fence-label>"}
])
.to_string();
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::All,
..SetupDefaults::default()
};
let parsed = parse_chunks_with_warnings(&json, Some(setup_config_with(defaults))).unwrap();
let chunk = &parsed.chunks[0];
assert_eq!(chunk.label, "fig-trailing");
assert_eq!(chunk.crossref_labels.len(), 1);
assert_eq!(chunk.crossref_labels[0].kind, "fig");
assert_eq!(chunk.crossref_labels[0].name, "fig-trailing");
assert_eq!(chunk.code, "plot(1)");
}
#[test]
fn parses_trailing_label_metadata_in_wrapped_chunks() {
let json = metadata(
&serde_json::json!({
"body":{
"func":"sequence",
"children":[
{"func":"raw","text":"plot(1)","block":true,"lang":"r"},
{"func":"space"},
{"func":"metadata","value":{"label":"fig-wrapped"},"label":"<calepin-fence-label>"}
]
},
"engine":"r",
"label":"fig-wrapped",
"crossref-labels":["fig-wrapped"]
})
.to_string(),
);
let chunks = parse_chunks(&json, None).unwrap();
let chunk = &chunks[0];
assert_eq!(chunk.label, "fig-wrapped");
assert_eq!(chunk.crossref_labels.len(), 1);
assert_eq!(chunk.crossref_labels[0].kind, "fig");
assert_eq!(chunk.crossref_labels[0].name, "fig-wrapped");
assert_eq!(chunk.code, "plot(1)");
}
#[test]
fn accepts_unprefixed_trailing_label_as_plain_id_in_fenced_chunks() {
let json = serde_json::json!([
{"func":"raw","text":"plot(1)","block":true,"lang":"r","label":"<plot>"}
])
.to_string();
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::All,
..SetupDefaults::default()
};
let chunks = parse_chunks_with_warnings(&json, Some(setup_config_with(defaults)))
.unwrap()
.chunks;
assert_eq!(chunks[0].label, "plot");
assert!(chunks[0].crossref_labels.is_empty());
}
#[test]
fn rejects_qmd_and_trailing_label_conflict_in_fenced_chunks() {
let json = serde_json::json!([
{"func":"raw","text":"#| label: fig-qmd\nplot(1)","block":true,"lang":"r"},
{"func":"metadata","value":{"label":"fig-trailing"},"label":"<calepin-fence-label>"}
])
.to_string();
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::All,
..SetupDefaults::default()
};
let err = parse_chunks_with_warnings(&json, Some(setup_config_with(defaults)))
.unwrap_err()
.to_string();
assert!(
err.contains("both `#| label:` and trailing fence label"),
"{err}"
);
}
#[test]
fn ignores_unknown_fences_when_all_fenced_chunks_enabled() {
let json = serde_json::json!([
{"func":"raw","text":"not executable","block":true,"lang":"text"}
])
.to_string();
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::All,
..SetupDefaults::default()
};
let parsed = parse_chunks_with_warnings(&json, Some(setup_config_with(defaults))).unwrap();
assert!(parsed.chunks.is_empty());
}
#[test]
fn maps_additional_quarto_figure_options() {
let json = metadata(
&serde_json::json!({
"body":{"func":"raw","text":"#| fig-device-width: 8\n#| fig-device-height: 6\n#| fig-dpi: 200\n#| fig-format: \"svg\"\n#| fig-asp: 1.5\n#| fig-link: https://example.com\n#| fig.cap: \"Figure title\"\n#| fig-alt: This is alt text\n#| fig-subcap: [A, B]\n#| fig-cap-location: top\n#| fig-align: center\n#| layout-ncol: 2\n#| layout-nrow: 1\nprint(1)","block":false},
"engine":"python",
"label":"mapped-quarto-options"
})
.to_string(),
);
let parsed = parse_chunks(&json, None).unwrap();
let chunk = &parsed[0];
assert_eq!(
chunk.display_options.fig_caption.as_deref(),
Some("Figure title")
);
assert_eq!(
chunk.display_options.fig_alt_text.as_deref(),
Some("This is alt text")
);
assert_eq!(
chunk.display_options.fig_subcaptions.as_ref().unwrap(),
&vec!["A".to_string(), "B".to_string()]
);
assert_eq!(
chunk.display_options.fig_cap_location,
Some(Value::String("top".to_string()))
);
assert_eq!(
chunk.display_options.fig_align,
Some(Value::String("center".to_string()))
);
assert_eq!(
chunk.display_options.fig_layout_columns.as_ref().unwrap(),
&Value::from(2)
);
assert_eq!(
chunk.display_options.fig_layout_rows.as_ref().unwrap(),
&Value::from(1)
);
assert_eq!(chunk.exec_options.fig_device_width, 8.0);
assert_eq!(chunk.exec_options.fig_device_height, Some(6.0));
assert_eq!(chunk.exec_options.fig_device_dpi, 200);
assert_eq!(chunk.exec_options.fig_device_format, "svg");
assert_eq!(chunk.exec_options.fig_device_aspect, 1.5);
assert_eq!(
chunk.display_options.fig_link,
Some(Value::String("https://example.com".to_string()))
);
}
#[test]
fn rejects_unknown_qmd_header_option() {
let json = metadata(
&serde_json::json!({
"body":{"func":"raw","text":"#| not-a-real-option: true\nprint(1)","block":false},
"engine":"r",
"label":"bad-header"
})
.to_string(),
);
let err = parse_chunks(&json, None).unwrap_err().to_string();
assert!(err.contains("chunk `bad-header` header line 1"));
assert!(err.contains("unsupported option `not-a-real-option`"));
}
#[test]
fn rejects_malformed_qmd_header_option() {
let json = metadata(
&serde_json::json!({
"body":{"func":"raw","text":"#| not-a-real-option true\nprint(1)","block":false},
"engine":"r",
"label":"bad-header-syntax"
})
.to_string(),
);
let err = parse_chunks(&json, None).unwrap_err().to_string();
assert!(err.contains("bad-header-syntax"));
assert!(err.contains("header line 1"));
assert!(err.contains("malformed option declaration"));
}
#[test]
fn rejects_unknown_calepin_chunk_argument() {
let json = metadata(
r#"{
"body":{"func":"raw","text":"print(1)","block":false},
"engine":"r",
"label":"bad-argument",
"not-a-real-argument":true
}"#,
);
let err = parse_chunks(&json, None).unwrap_err().to_string();
assert!(err.contains("chunk `bad-argument` has unsupported argument `not-a-real-argument`"));
}
#[test]
fn merges_setup_defaults_and_chunk_overrides() {
let json = metadata(
r#"{
"body":{"func":"raw","text":"print(x)","block":false},
"engine":"python",
"label":"show",
"echo":"auto",
"eval":false,
"output":"auto",
"results":"render",
"warning":"auto",
"message":"auto",
"error":"auto",
"placeholder":"auto",
"fig-device-format":"auto",
"fig-device-dpi":"auto",
"fig-device-width":"auto",
"fig-device-height":"auto",
"fig-device-aspect":"auto",
"fig-width":"auto",
"fig-height":"auto",
"fig-align":"auto",
"fig-responsive":"auto",
"fig-link":"auto",
"fig-caption":null,
"fig-cap-location":"auto",
"fig-alt-text":null,
"fig-subcaptions":null,
"fig-layout-columns":"auto",
"fig-layout-rows":"auto",
"kind":"auto"
}"#,
);
let defaults = SetupDefaults {
echo: false,
eval: true,
output: true,
results: "typst".to_string(),
warning: false,
message: false,
error: true,
placeholder: true,
fig_device_format: "png".to_string(),
fig_device_dpi: 300,
fig_device_width: 8.0,
fig_device_height: Some(4.0),
fig_device_aspect: 0.5,
fig_width: Some(Value::String("70%".to_string())),
fig_align: Some(Value::String("center".to_string())),
fig_responsive: Some(true),
fenced_chunks: FencedChunks::Off,
params: serde_json::json!({}),
theme: None,
};
let chunks = parse_chunks(&json, Some(setup_config_with(defaults))).unwrap();
let chunk = &chunks[0];
assert_eq!(chunk.engine, EngineName::Python);
assert!(!chunk.exec_options.eval);
assert_eq!(chunk.display_options.results, ResultsMode::Render);
assert_eq!(chunk.exec_options.fig_device_format, "png");
assert_eq!(chunk.exec_options.fig_device_dpi, 300);
assert_eq!(chunk.exec_options.fig_device_width, 8.0);
assert_eq!(chunk.exec_options.fig_device_height, Some(4.0));
assert_eq!(chunk.exec_options.fig_device_aspect, 0.5);
}
#[test]
fn auto_device_width_scales_from_display_width() {
let json = metadata(
r#"{
"body":{"func":"raw","text":"plot(x)","block":false},
"engine":"r",
"label":"wide",
"fig-device-width":"auto",
"fig-width":"95%"
}"#,
);
let chunks = parse_chunks(&json, None).unwrap();
let chunk = &chunks[0];
assert_eq!(chunk.display_options.fig_width.as_ref().unwrap(), "95%");
assert_eq!(chunk.display_options.fig_align.as_ref().unwrap(), "center");
assert_eq!(chunk.display_options.fig_responsive, Some(true));
assert!((chunk.exec_options.fig_device_width - 8.142_857_142).abs() < 0.000_001);
}
#[test]
fn setup_config_is_merged_in_order() {
let json = r#"[
{"func":"metadata","value":{"echo":false,"eval":false},"label":"<calepin-config>"},
{"func":"metadata","value":{"echo":true},"label":"<calepin-config>"}
]"#;
let config = parse_setup_config(json).unwrap().unwrap();
assert!(config.defaults.echo);
assert!(!config.defaults.eval);
}
#[test]
fn parses_setup_params_object() {
let json = setup_metadata(
r#"{
"echo":true,
"params":{"region":"NY","min_count":25,"active":true}
}"#,
);
let config = parse_setup_config(&json).unwrap().unwrap();
assert_eq!(
config.defaults.params,
serde_json::json!({"region":"NY","min_count":25,"active":true})
);
}
#[test]
fn setup_params_default_to_empty_object() {
let json = setup_metadata(r#"{"echo":true}"#);
let config = parse_setup_config(&json).unwrap().unwrap();
assert_eq!(config.defaults.params, serde_json::json!({}));
}
#[test]
fn rejects_non_object_setup_params() {
let json = setup_metadata(r#"{"params":[1,2,3]}"#);
let err = parse_setup_config(&json).unwrap_err().to_string();
assert!(err.contains("params"), "{err}");
}
#[test]
fn rejects_setup_lang_option() {
let json = r#"[
{"func":"metadata","value":{"lang":"python","echo":true},"label":"<calepin-config>"}
]"#;
let err = parse_setup_config(json).unwrap_err();
assert!(err.to_string().contains("`lang` is no longer supported"));
}
#[test]
fn parses_setup_fenced_chunks_option() {
let json = setup_metadata(
r#"{
"echo":true,
"eval":true,
"output":true,
"results":"verbatim",
"warning":true,
"message":true,
"error":false,
"placeholder":false,
"fig-device-format":"svg",
"fig-device-dpi":150,
"fig-device-width":6,
"fig-device-height":"auto",
"fig-device-aspect":0.618,
"fenced-chunks":true
}"#,
);
let config = parse_setup_config(&json).unwrap().unwrap();
assert_eq!(config.defaults.fenced_chunks, FencedChunks::All);
}
#[test]
fn parses_setup_fenced_chunks_single_engine() {
let json = setup_metadata(
r#"{
"echo":true,
"eval":true,
"output":true,
"results":"verbatim",
"warning":true,
"message":true,
"error":false,
"placeholder":false,
"fig-device-format":"svg",
"fig-device-dpi":150,
"fig-device-width":6,
"fig-device-height":"auto",
"fig-device-aspect":0.618,
"fenced-chunks":"python"
}"#,
);
let config = parse_setup_config(&json).unwrap().unwrap();
assert_eq!(
config.defaults.fenced_chunks,
FencedChunks::Only(vec!["python".to_string()])
);
}
#[test]
fn parses_setup_fenced_chunks_engine_list() {
let json = setup_metadata(
r#"{
"echo":true,
"eval":true,
"output":true,
"results":"verbatim",
"warning":true,
"message":true,
"error":false,
"placeholder":false,
"fig-device-format":"svg",
"fig-device-dpi":150,
"fig-device-width":6,
"fig-device-height":"auto",
"fig-device-aspect":0.618,
"fenced-chunks":["python","r"]
}"#,
);
let config = parse_setup_config(&json).unwrap().unwrap();
assert_eq!(
config.defaults.fenced_chunks,
FencedChunks::Only(vec!["python".to_string(), "r".to_string()])
);
}
#[test]
fn parses_diagram_chunk_engine() {
let json = metadata(
r#"{
"body":{"func":"raw","text":"graph TD\n A --> B","block":true},
"engine":"mermaid",
"label":"fig-flow"
}"#,
);
let chunks = parse_chunks(&json, None).unwrap();
assert_eq!(chunks[0].engine.as_str(), "mermaid");
assert_eq!(chunks[0].code, "graph TD\n A --> B");
}
#[test]
fn parses_julia_chunk_engine() {
let json = metadata(
r#"{
"body":{"func":"raw","text":"println(42)","block":true},
"engine":"julia",
"label":"julia-answer"
}"#,
);
let chunks = parse_chunks(&json, None).unwrap();
assert_eq!(chunks[0].engine, EngineName::Jupyter("julia".to_string()));
assert_eq!(chunks[0].engine.as_str(), "julia");
assert_eq!(chunks[0].code, "println(42)");
}
#[test]
fn rejects_missing_label() {
let json = metadata(
r#"{"body":{"func":"raw","text":"x","block":false},"engine":"r","label":null}"#,
);
let err = parse_chunks(&json, None).unwrap_err().to_string();
assert!(err.contains("missing label"));
}
#[test]
fn rejects_duplicate_labels() {
let json = r#"[
{"func":"metadata","value":{"body":{"func":"raw","text":"x","block":false},"engine":"r","label":"dup"},"label":"<calepin-chunk>"},
{"func":"metadata","value":{"body":{"func":"raw","text":"y","block":false},"engine":"r","label":"dup"},"label":"<calepin-chunk>"}
]"#;
let err = parse_chunks(json, None).unwrap_err().to_string();
assert!(err.contains("duplicate label `dup`"));
}
#[test]
fn unknown_engine_routes_to_jupyter() {
let json = metadata(
r#"{"body":{"func":"raw","text":"x","block":false},"engine":"ruby","label":"ch1"}"#,
);
let chunks = parse_chunks(&json, None).unwrap();
assert_eq!(chunks[0].engine, EngineName::Jupyter("ruby".to_string()));
}
#[test]
fn accepts_plain_language_raw_block_when_enabled() {
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::All,
..SetupDefaults::default()
};
let json = r#"[
{"func":"raw","text":"x","block":true,"lang":"r"}
]"#;
let chunks = parse_chunks(json, Some(setup_config_with(defaults))).unwrap();
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].engine, EngineName::R);
assert_eq!(chunks[0].code, "x");
}
#[test]
fn accepts_plain_language_raw_block_when_lang_configured() {
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::Only(vec!["python".to_string()]),
..SetupDefaults::default()
};
let json = r#"[
{"func":"raw","text":"x","block":true,"lang":"r"},
{"func":"raw","text":"print(1)","block":true,"lang":"python"}
]"#;
let chunks = parse_chunks(json, Some(setup_config_with(defaults))).unwrap();
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].engine, EngineName::Python);
assert_eq!(chunks[0].code, "print(1)");
}
#[test]
fn uses_language_specific_defaults_for_raw_blocks() {
let chunks = parse_chunks(
r#"[
{"func":"raw","text":"x","block":true,"lang":"r"},
{"func":"raw","text":"print(1)","block":true,"lang":"python"}
]"#,
Some(SetupConfig {
defaults: SetupDefaults {
fenced_chunks: FencedChunks::Only(vec!["python".to_string()]),
..SetupDefaults::default()
},
}),
)
.unwrap();
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].engine, EngineName::Python);
assert_eq!(chunks[0].code, "print(1)");
}
#[test]
fn normalizes_implicit_chunk_labels_when_fenced_chunks_configured() {
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::Only(vec!["ir".to_string()]),
..SetupDefaults::default()
};
let json = r#"[
{"func":"raw","text":"x <- 1","block":true,"lang":"ir"},
{"func":"metadata","value":{"body":{"func":"raw","text":"print(1)","block":false},"engine":"r","label":"chunk-1"},"label":"<calepin-chunk>"}
]"#;
let chunks = parse_chunks(json, Some(setup_config_with(defaults))).unwrap();
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].label, "chunk-1");
assert_eq!(chunks[1].label, "chunk-2");
}
#[test]
fn rejects_plain_language_raw_blocks_when_disabled() {
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::Off,
..SetupDefaults::default()
};
let json = r#"[
{"func":"raw","text":"x","block":true,"lang":"r"}
]"#;
let chunks = parse_chunks(json, Some(setup_config_with(defaults))).unwrap();
assert_eq!(chunks.len(), 0);
}
#[test]
fn ignores_typst_fences_even_when_fenced_chunks_enabled() {
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::All,
..SetupDefaults::default()
};
let json = r##"[
{"func":"raw","text":"#let x = 1","block":true,"lang":"typ"},
{"func":"raw","text":"#let y = 2","block":true,"lang":"typst"},
{"func":"raw","text":"print(1)","block":true,"lang":"python"}
]"##;
let chunks = parse_chunks(json, Some(setup_config_with(defaults))).unwrap();
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].engine, EngineName::Python);
assert_eq!(chunks[0].code, "print(1)");
}
#[test]
fn parses_chunked_raw_blocks() {
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::Only(vec!["julia".to_string()]),
..SetupDefaults::default()
};
let json = r#"[
{"func":"raw","text":"println(1)","block":true,"lang":"julia"},
{"func":"metadata","value":{"body":{"func":"raw","text":"print(2)","block":true},"engine":"r","label":"answer"},"label":"<calepin-chunk>"}
]"#;
let chunks = parse_chunks(json, Some(setup_config_with(defaults))).unwrap();
assert_eq!(chunks.len(), 2);
assert_eq!(chunks[0].engine, EngineName::Jupyter("julia".to_string()));
assert_eq!(chunks[0].label, "chunk-1");
assert_eq!(chunks[0].code, "println(1)");
assert_eq!(chunks[1].engine, EngineName::R);
assert_eq!(chunks[1].label, "answer");
assert_eq!(chunks[1].code, "print(2)");
}
#[test]
fn metadata_query_skips_wrapper_managed_raw_builtin_blocks() {
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::All,
..SetupDefaults::default()
};
let json = r#"[
{"func":"raw","text":"plot(1)","block":true,"lang":"r"},
{"func":"metadata","value":{"body":{"func":"raw","text":"plot(2)","block":true},"engine":"r","label":"chunk-1"},"label":"<calepin-chunk>"}
]"#;
let chunks = parse_chunks(json, Some(setup_config_with(defaults))).unwrap();
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].engine, EngineName::R);
assert_eq!(chunks[0].label, "chunk-1");
assert_eq!(chunks[0].code, "plot(2)");
}
#[test]
fn advances_auto_labels_around_explicit_chunk_labels() {
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::Only(vec!["julia".to_string()]),
..SetupDefaults::default()
};
let json = r#"[
{"func":"raw","text":"println(1)","block":true,"lang":"julia"},
{"func":"metadata","value":{"body":{"func":"raw","text":"println(2)","block":true},"engine":"julia","label":"chunk-1"},"label":"<calepin-chunk>"},
{"func":"raw","text":"println(3)","block":true,"lang":"julia"}
]"#;
let chunks = parse_chunks(json, Some(setup_config_with(defaults))).unwrap();
assert_eq!(chunks.len(), 3);
assert_eq!(chunks[0].label, "chunk-1");
assert_eq!(chunks[1].label, "chunk-2");
assert_eq!(chunks[2].label, "chunk-3");
}
#[test]
fn extracts_single_raw_child_from_sequence() {
let json = metadata(
r#"{"body":{"func":"sequence","children":[{"func":"space"},{"func":"raw","text":"x <- 1","block":false},{"func":"space"}]},"engine":"r","label":"ok"}"#,
);
let chunks = parse_chunks(&json, None).unwrap();
assert_eq!(chunks[0].code, "x <- 1");
}
#[test]
fn rejects_multiple_raw_children() {
let json = metadata(
r#"{"body":{"func":"sequence","children":[{"func":"raw","text":"x","block":false},{"func":"raw","text":"y","block":false}]},"engine":"r","label":"bad"}"#,
);
let err = parse_chunks(&json, None).unwrap_err().to_string();
assert!(err.contains("exactly one raw element"));
}
#[test]
fn reattaches_minor_version_suffix_no_leading_newline() {
let (engine, code) = reattach_version_suffix(
EngineName::Jupyter("julia-1".into()),
"julia-1",
".2\nx = 41\nprint(x + 1)",
);
assert_eq!(engine, EngineName::Jupyter("julia-1.2".into()));
assert_eq!(code, "x = 41\nprint(x + 1)");
}
#[test]
fn reattaches_minor_version_suffix_with_leading_newline() {
let (engine, code) = reattach_version_suffix(
EngineName::Jupyter("julia-1".into()),
"julia-1",
"\n.2\nx = 41\nprint(x + 1)",
);
assert_eq!(engine, EngineName::Jupyter("julia-1.2".into()));
assert_eq!(code, "x = 41\nprint(x + 1)");
}
#[test]
fn reattaches_two_part_version_suffix() {
let (engine, code) = reattach_version_suffix(
EngineName::Jupyter("some-kernel-1".into()),
"some-kernel-1",
".2.3\ncode",
);
assert_eq!(engine, EngineName::Jupyter("some-kernel-1.2.3".into()));
assert_eq!(code, "code");
}
#[test]
fn no_reattach_when_first_line_is_not_version() {
let (engine, code) =
reattach_version_suffix(EngineName::Jupyter("julia-1".into()), "julia-1", "\nx = 41");
assert_eq!(engine, EngineName::Jupyter("julia-1".into()));
assert_eq!(code, "x = 41");
}
#[test]
fn no_reattach_for_builtin_engine() {
let (engine, code) = reattach_version_suffix(EngineName::Python, "python", ".2\nprint(1)");
assert_eq!(engine, EngineName::Python);
assert_eq!(code, ".2\nprint(1)");
}
#[test]
fn raw_block_with_period_in_lang_parsed_correctly() {
let defaults = SetupDefaults {
fenced_chunks: FencedChunks::Only(vec!["julia-1.2".to_string()]),
..SetupDefaults::default()
};
let json = r#"[
{"func":"raw","text":".2\nx = 41\nprint(x + 1)","block":true,"lang":"julia-1"}
]"#;
let chunks = parse_chunks(json, Some(setup_config_with(defaults))).unwrap();
assert_eq!(chunks.len(), 1);
assert_eq!(chunks[0].engine, EngineName::Jupyter("julia-1.2".into()));
assert_eq!(chunks[0].code, "x = 41\nprint(x + 1)");
}
}