#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChunkLabelSource {
InlinePositional,
InlineKey,
Hashpipe,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChunkLabel {
pub value: String,
pub source: ChunkLabelSource,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InlineChunkHeader {
pub engine: String,
pub labels: Vec<ChunkLabel>,
}
pub fn parse_inline_chunk_header(info_string: &str) -> Option<InlineChunkHeader> {
let trimmed = info_string.trim();
let inner = trimmed.strip_prefix('{')?.strip_suffix('}')?;
let mut tokens = tokenize_chunk_args(inner);
let engine = match tokens.next() {
Some(tok) if matches!(tok.kind, TokenKind::Bare) => tok.value,
_ => String::new(),
};
let mut labels: Vec<ChunkLabel> = Vec::new();
let mut seen_kv = false;
for tok in tokens {
match tok.kind {
TokenKind::Bare => {
if !seen_kv {
labels.push(ChunkLabel {
value: tok.value,
source: ChunkLabelSource::InlinePositional,
});
}
}
TokenKind::KeyValue { key } => {
seen_kv = true;
if key.eq_ignore_ascii_case("label") {
labels.push(ChunkLabel {
value: tok.value,
source: ChunkLabelSource::InlineKey,
});
}
}
}
}
Some(InlineChunkHeader { engine, labels })
}
pub fn parse_hashpipe_labels(body: &str) -> Vec<ChunkLabel> {
let mut out = Vec::new();
for line in body.lines() {
let Some(after) = line.trim_start().strip_prefix("#|") else {
if line.trim().is_empty() {
continue;
}
break;
};
let Some((key, value)) = after.split_once(':') else {
continue;
};
if !key.trim().eq_ignore_ascii_case("label") {
continue;
}
let value = value.trim().trim_matches(|c| c == '"' || c == '\'');
if value.is_empty() {
continue;
}
out.push(ChunkLabel {
value: value.to_string(),
source: ChunkLabelSource::Hashpipe,
});
}
out
}
pub fn is_executable_chunk(info_string: &str) -> bool {
parse_inline_chunk_header(info_string)
.is_some_and(|h| h.engine.chars().next().is_some_and(|c| c.is_ascii_alphabetic()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum TokenKind {
Bare,
KeyValue { key: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Token {
value: String,
kind: TokenKind,
}
fn tokenize_chunk_args(input: &str) -> impl Iterator<Item = Token> + '_ {
ChunkArgIter {
input,
bytes: input.as_bytes(),
pos: 0,
}
}
struct ChunkArgIter<'a> {
input: &'a str,
bytes: &'a [u8],
pos: usize,
}
impl Iterator for ChunkArgIter<'_> {
type Item = Token;
fn next(&mut self) -> Option<Token> {
self.skip_separators();
if self.pos >= self.bytes.len() {
return None;
}
if matches!(self.bytes[self.pos], b'"' | b'\'') {
let value = self.read_quoted();
return Some(Token {
value,
kind: TokenKind::Bare,
});
}
let key_start = self.pos;
while self.pos < self.bytes.len() {
let b = self.bytes[self.pos];
if b == b',' || b == b'=' || b == b'"' || b == b'\'' || b.is_ascii_whitespace() {
break;
}
self.pos += 1;
}
let key = &self.input[key_start..self.pos];
let lookahead = self.skip_inline_whitespace_peek();
if lookahead.is_none_or(|b| b != b'=') {
return Some(Token {
value: key.to_string(),
kind: TokenKind::Bare,
});
}
self.pos += 1;
self.skip_inline_whitespace();
let value = match self.bytes.get(self.pos).copied() {
Some(b'"') | Some(b'\'') => self.read_quoted(),
Some(_) => {
let val_start = self.pos;
while self.pos < self.bytes.len() {
let b = self.bytes[self.pos];
if b == b',' || b.is_ascii_whitespace() {
break;
}
self.pos += 1;
}
self.input[val_start..self.pos].to_string()
}
None => String::new(),
};
Some(Token {
value,
kind: TokenKind::KeyValue { key: key.to_string() },
})
}
}
impl ChunkArgIter<'_> {
fn skip_separators(&mut self) {
while self.pos < self.bytes.len() {
let b = self.bytes[self.pos];
if b == b',' || b.is_ascii_whitespace() {
self.pos += 1;
} else {
break;
}
}
}
fn skip_inline_whitespace(&mut self) {
while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_whitespace() {
self.pos += 1;
}
}
fn skip_inline_whitespace_peek(&mut self) -> Option<u8> {
let saved = self.pos;
self.skip_inline_whitespace();
let next = self.bytes.get(self.pos).copied();
if next != Some(b'=') {
self.pos = saved;
}
next
}
fn read_quoted(&mut self) -> String {
let q = self.bytes[self.pos];
self.pos += 1;
let start = self.pos;
while self.pos < self.bytes.len() && self.bytes[self.pos] != q {
self.pos += 1;
}
let val = self.input[start..self.pos].to_string();
if self.pos < self.bytes.len() {
self.pos += 1;
}
val
}
}
#[cfg(test)]
mod tests {
use super::*;
fn header(info: &str) -> InlineChunkHeader {
parse_inline_chunk_header(info).expect("should parse")
}
#[test]
fn plain_display_block_is_not_a_chunk_header() {
assert!(parse_inline_chunk_header("r").is_none());
assert!(parse_inline_chunk_header("python").is_none());
assert!(parse_inline_chunk_header("").is_none());
}
#[test]
fn bare_engine_has_no_label() {
let h = header("{r}");
assert_eq!(h.engine, "r");
assert!(h.labels.is_empty());
}
#[test]
fn inline_positional_label() {
let h = header("{r setup}");
assert_eq!(h.engine, "r");
assert_eq!(h.labels.len(), 1);
assert_eq!(h.labels[0].value, "setup");
assert_eq!(h.labels[0].source, ChunkLabelSource::InlinePositional);
}
#[test]
fn multiple_bare_words_are_all_positional() {
let h = header("{r several words}");
assert_eq!(h.engine, "r");
let vals: Vec<&str> = h.labels.iter().map(|l| l.value.as_str()).collect();
assert_eq!(vals, vec!["several", "words"]);
assert!(h.labels.iter().all(|l| l.source == ChunkLabelSource::InlinePositional));
}
#[test]
fn explicit_label_key() {
let h = header("{r, label=setup}");
assert_eq!(h.engine, "r");
assert_eq!(h.labels.len(), 1);
assert_eq!(h.labels[0].value, "setup");
assert_eq!(h.labels[0].source, ChunkLabelSource::InlineKey);
}
#[test]
fn quoted_label_with_spaces() {
let h = header(r#"{r, label="my label"}"#);
assert_eq!(h.labels.len(), 1);
assert_eq!(h.labels[0].value, "my label");
assert_eq!(h.labels[0].source, ChunkLabelSource::InlineKey);
}
#[test]
fn positional_then_options_only_collects_first_as_label() {
let h = header("{r setup, echo=FALSE}");
assert_eq!(h.labels.len(), 1);
assert_eq!(h.labels[0].value, "setup");
assert_eq!(h.labels[0].source, ChunkLabelSource::InlinePositional);
}
#[test]
fn bareword_after_kv_is_not_a_label() {
let h = header("{r, echo=FALSE stray}");
assert!(h.labels.is_empty());
}
#[test]
fn hashpipe_label_is_picked_up() {
let labels = parse_hashpipe_labels("#| label: setup\n#| echo: false\n1 + 1\n");
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].value, "setup");
assert_eq!(labels[0].source, ChunkLabelSource::Hashpipe);
}
#[test]
fn hashpipe_label_with_quotes() {
let labels = parse_hashpipe_labels("#| label: \"setup\"\n");
assert_eq!(labels.len(), 1);
assert_eq!(labels[0].value, "setup");
}
#[test]
fn hashpipe_options_must_be_at_top_of_block() {
let labels = parse_hashpipe_labels("1 + 1\n#| label: too-late\n");
assert!(labels.is_empty());
}
#[test]
fn hashpipe_blank_lines_at_top_are_skipped() {
let labels = parse_hashpipe_labels("\n#| label: setup\n");
assert_eq!(labels.len(), 1);
}
#[test]
fn hashpipe_value_without_colon_is_ignored() {
let labels = parse_hashpipe_labels("#| label\n");
assert!(labels.is_empty());
}
#[test]
fn hashpipe_empty_value_is_ignored() {
let labels = parse_hashpipe_labels("#| label:\n");
assert!(labels.is_empty());
}
#[test]
fn is_executable_chunk_recognises_braced_engines() {
assert!(is_executable_chunk("{r}"));
assert!(is_executable_chunk("{python}"));
assert!(is_executable_chunk("{r, label=foo}"));
assert!(!is_executable_chunk("r"));
assert!(!is_executable_chunk("python"));
assert!(!is_executable_chunk(""));
}
#[test]
fn is_executable_chunk_rejects_empty_engine() {
assert!(!is_executable_chunk("{}"));
assert!(!is_executable_chunk("{ }"));
}
#[test]
fn pandoc_attribute_fences_are_not_executable() {
assert!(!is_executable_chunk("{.python}"));
assert!(!is_executable_chunk("{.haskell .numberLines}"));
assert!(!is_executable_chunk("{#snippet .python startFrom=\"10\"}"));
}
#[test]
fn pandoc_raw_format_fences_are_not_executable() {
assert!(!is_executable_chunk("{=html}"));
assert!(!is_executable_chunk("{=latex}"));
}
#[test]
fn spaces_around_equals_in_key_value() {
let h = header("{r, label = setup}");
assert_eq!(h.labels.len(), 1);
assert_eq!(h.labels[0].value, "setup");
assert_eq!(h.labels[0].source, ChunkLabelSource::InlineKey);
}
#[test]
fn spaces_around_equals_with_quoted_value() {
let h = header(r#"{r, label = "my label"}"#);
assert_eq!(h.labels.len(), 1);
assert_eq!(h.labels[0].value, "my label");
assert_eq!(h.labels[0].source, ChunkLabelSource::InlineKey);
}
#[test]
fn quoted_bare_token_does_not_livelock() {
let h = header(r#"{r "setup"}"#);
assert_eq!(h.engine, "r");
assert_eq!(h.labels.len(), 1);
assert_eq!(h.labels[0].value, "setup");
assert_eq!(h.labels[0].source, ChunkLabelSource::InlinePositional);
}
#[test]
fn stray_quote_does_not_livelock() {
let h = header(r#"{r, label="oops}"#);
assert_eq!(h.engine, "r");
assert!(!h.labels.is_empty());
}
}