1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum ChunkLabelSource {
23 InlinePositional,
25 InlineKey,
27 Hashpipe,
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ChunkLabel {
34 pub value: String,
35 pub source: ChunkLabelSource,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct InlineChunkHeader {
41 pub engine: String,
43 pub labels: Vec<ChunkLabel>,
47}
48
49pub fn parse_inline_chunk_header(info_string: &str) -> Option<InlineChunkHeader> {
53 let trimmed = info_string.trim();
54 let inner = trimmed.strip_prefix('{')?.strip_suffix('}')?;
55
56 let mut tokens = tokenize_chunk_args(inner);
57
58 let engine = match tokens.next() {
62 Some(tok) if matches!(tok.kind, TokenKind::Bare) => tok.value,
63 _ => String::new(),
64 };
65
66 let mut labels: Vec<ChunkLabel> = Vec::new();
67 let mut seen_kv = false;
68 for tok in tokens {
69 match tok.kind {
70 TokenKind::Bare => {
71 if !seen_kv {
75 labels.push(ChunkLabel {
76 value: tok.value,
77 source: ChunkLabelSource::InlinePositional,
78 });
79 }
80 }
81 TokenKind::KeyValue { key } => {
82 seen_kv = true;
83 if key.eq_ignore_ascii_case("label") {
84 labels.push(ChunkLabel {
85 value: tok.value,
86 source: ChunkLabelSource::InlineKey,
87 });
88 }
89 }
90 }
91 }
92
93 Some(InlineChunkHeader { engine, labels })
94}
95
96pub fn parse_hashpipe_labels(body: &str) -> Vec<ChunkLabel> {
103 let mut out = Vec::new();
104 for line in body.lines() {
105 let Some(after) = line.trim_start().strip_prefix("#|") else {
106 if line.trim().is_empty() {
108 continue;
109 }
110 break;
111 };
112 let Some((key, value)) = after.split_once(':') else {
113 continue;
114 };
115 if !key.trim().eq_ignore_ascii_case("label") {
116 continue;
117 }
118 let value = value.trim().trim_matches(|c| c == '"' || c == '\'');
119 if value.is_empty() {
120 continue;
121 }
122 out.push(ChunkLabel {
123 value: value.to_string(),
124 source: ChunkLabelSource::Hashpipe,
125 });
126 }
127 out
128}
129
130pub fn is_executable_chunk(info_string: &str) -> bool {
137 parse_inline_chunk_header(info_string)
138 .is_some_and(|h| h.engine.chars().next().is_some_and(|c| c.is_ascii_alphabetic()))
139}
140
141#[derive(Debug, Clone, PartialEq, Eq)]
142enum TokenKind {
143 Bare,
144 KeyValue { key: String },
145}
146
147#[derive(Debug, Clone, PartialEq, Eq)]
148struct Token {
149 value: String,
150 kind: TokenKind,
151}
152
153fn tokenize_chunk_args(input: &str) -> impl Iterator<Item = Token> + '_ {
159 ChunkArgIter {
160 input,
161 bytes: input.as_bytes(),
162 pos: 0,
163 }
164}
165
166struct ChunkArgIter<'a> {
167 input: &'a str,
168 bytes: &'a [u8],
169 pos: usize,
170}
171
172impl Iterator for ChunkArgIter<'_> {
173 type Item = Token;
174
175 fn next(&mut self) -> Option<Token> {
176 self.skip_separators();
177 if self.pos >= self.bytes.len() {
178 return None;
179 }
180
181 if matches!(self.bytes[self.pos], b'"' | b'\'') {
183 let value = self.read_quoted();
184 return Some(Token {
185 value,
186 kind: TokenKind::Bare,
187 });
188 }
189
190 let key_start = self.pos;
192 while self.pos < self.bytes.len() {
193 let b = self.bytes[self.pos];
194 if b == b',' || b == b'=' || b == b'"' || b == b'\'' || b.is_ascii_whitespace() {
195 break;
196 }
197 self.pos += 1;
198 }
199 let key = &self.input[key_start..self.pos];
200
201 let lookahead = self.skip_inline_whitespace_peek();
204 if lookahead.is_none_or(|b| b != b'=') {
205 return Some(Token {
206 value: key.to_string(),
207 kind: TokenKind::Bare,
208 });
209 }
210
211 self.pos += 1;
213 self.skip_inline_whitespace();
214
215 let value = match self.bytes.get(self.pos).copied() {
216 Some(b'"') | Some(b'\'') => self.read_quoted(),
217 Some(_) => {
218 let val_start = self.pos;
219 while self.pos < self.bytes.len() {
220 let b = self.bytes[self.pos];
221 if b == b',' || b.is_ascii_whitespace() {
222 break;
223 }
224 self.pos += 1;
225 }
226 self.input[val_start..self.pos].to_string()
227 }
228 None => String::new(),
229 };
230
231 Some(Token {
232 value,
233 kind: TokenKind::KeyValue { key: key.to_string() },
234 })
235 }
236}
237
238impl ChunkArgIter<'_> {
239 fn skip_separators(&mut self) {
240 while self.pos < self.bytes.len() {
241 let b = self.bytes[self.pos];
242 if b == b',' || b.is_ascii_whitespace() {
243 self.pos += 1;
244 } else {
245 break;
246 }
247 }
248 }
249
250 fn skip_inline_whitespace(&mut self) {
251 while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_whitespace() {
252 self.pos += 1;
253 }
254 }
255
256 fn skip_inline_whitespace_peek(&mut self) -> Option<u8> {
259 let saved = self.pos;
260 self.skip_inline_whitespace();
261 let next = self.bytes.get(self.pos).copied();
262 if next != Some(b'=') {
263 self.pos = saved;
264 }
265 next
266 }
267
268 fn read_quoted(&mut self) -> String {
272 let q = self.bytes[self.pos];
273 self.pos += 1;
274 let start = self.pos;
275 while self.pos < self.bytes.len() && self.bytes[self.pos] != q {
276 self.pos += 1;
277 }
278 let val = self.input[start..self.pos].to_string();
279 if self.pos < self.bytes.len() {
280 self.pos += 1;
281 }
282 val
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 fn header(info: &str) -> InlineChunkHeader {
291 parse_inline_chunk_header(info).expect("should parse")
292 }
293
294 #[test]
295 fn plain_display_block_is_not_a_chunk_header() {
296 assert!(parse_inline_chunk_header("r").is_none());
297 assert!(parse_inline_chunk_header("python").is_none());
298 assert!(parse_inline_chunk_header("").is_none());
299 }
300
301 #[test]
302 fn bare_engine_has_no_label() {
303 let h = header("{r}");
304 assert_eq!(h.engine, "r");
305 assert!(h.labels.is_empty());
306 }
307
308 #[test]
309 fn inline_positional_label() {
310 let h = header("{r setup}");
311 assert_eq!(h.engine, "r");
312 assert_eq!(h.labels.len(), 1);
313 assert_eq!(h.labels[0].value, "setup");
314 assert_eq!(h.labels[0].source, ChunkLabelSource::InlinePositional);
315 }
316
317 #[test]
318 fn multiple_bare_words_are_all_positional() {
319 let h = header("{r several words}");
320 assert_eq!(h.engine, "r");
321 let vals: Vec<&str> = h.labels.iter().map(|l| l.value.as_str()).collect();
322 assert_eq!(vals, vec!["several", "words"]);
323 assert!(h.labels.iter().all(|l| l.source == ChunkLabelSource::InlinePositional));
324 }
325
326 #[test]
327 fn explicit_label_key() {
328 let h = header("{r, label=setup}");
329 assert_eq!(h.engine, "r");
330 assert_eq!(h.labels.len(), 1);
331 assert_eq!(h.labels[0].value, "setup");
332 assert_eq!(h.labels[0].source, ChunkLabelSource::InlineKey);
333 }
334
335 #[test]
336 fn quoted_label_with_spaces() {
337 let h = header(r#"{r, label="my label"}"#);
338 assert_eq!(h.labels.len(), 1);
339 assert_eq!(h.labels[0].value, "my label");
340 assert_eq!(h.labels[0].source, ChunkLabelSource::InlineKey);
341 }
342
343 #[test]
344 fn positional_then_options_only_collects_first_as_label() {
345 let h = header("{r setup, echo=FALSE}");
346 assert_eq!(h.labels.len(), 1);
347 assert_eq!(h.labels[0].value, "setup");
348 assert_eq!(h.labels[0].source, ChunkLabelSource::InlinePositional);
349 }
350
351 #[test]
352 fn bareword_after_kv_is_not_a_label() {
353 let h = header("{r, echo=FALSE stray}");
356 assert!(h.labels.is_empty());
357 }
358
359 #[test]
360 fn hashpipe_label_is_picked_up() {
361 let labels = parse_hashpipe_labels("#| label: setup\n#| echo: false\n1 + 1\n");
362 assert_eq!(labels.len(), 1);
363 assert_eq!(labels[0].value, "setup");
364 assert_eq!(labels[0].source, ChunkLabelSource::Hashpipe);
365 }
366
367 #[test]
368 fn hashpipe_label_with_quotes() {
369 let labels = parse_hashpipe_labels("#| label: \"setup\"\n");
370 assert_eq!(labels.len(), 1);
371 assert_eq!(labels[0].value, "setup");
372 }
373
374 #[test]
375 fn hashpipe_options_must_be_at_top_of_block() {
376 let labels = parse_hashpipe_labels("1 + 1\n#| label: too-late\n");
378 assert!(labels.is_empty());
379 }
380
381 #[test]
382 fn hashpipe_blank_lines_at_top_are_skipped() {
383 let labels = parse_hashpipe_labels("\n#| label: setup\n");
384 assert_eq!(labels.len(), 1);
385 }
386
387 #[test]
388 fn hashpipe_value_without_colon_is_ignored() {
389 let labels = parse_hashpipe_labels("#| label\n");
390 assert!(labels.is_empty());
391 }
392
393 #[test]
394 fn hashpipe_empty_value_is_ignored() {
395 let labels = parse_hashpipe_labels("#| label:\n");
396 assert!(labels.is_empty());
397 }
398
399 #[test]
400 fn is_executable_chunk_recognises_braced_engines() {
401 assert!(is_executable_chunk("{r}"));
402 assert!(is_executable_chunk("{python}"));
403 assert!(is_executable_chunk("{r, label=foo}"));
404 assert!(!is_executable_chunk("r"));
405 assert!(!is_executable_chunk("python"));
406 assert!(!is_executable_chunk(""));
407 }
408
409 #[test]
410 fn is_executable_chunk_rejects_empty_engine() {
411 assert!(!is_executable_chunk("{}"));
413 assert!(!is_executable_chunk("{ }"));
414 }
415
416 #[test]
417 fn pandoc_attribute_fences_are_not_executable() {
418 assert!(!is_executable_chunk("{.python}"));
420 assert!(!is_executable_chunk("{.haskell .numberLines}"));
421 assert!(!is_executable_chunk("{#snippet .python startFrom=\"10\"}"));
422 }
423
424 #[test]
425 fn pandoc_raw_format_fences_are_not_executable() {
426 assert!(!is_executable_chunk("{=html}"));
428 assert!(!is_executable_chunk("{=latex}"));
429 }
430
431 #[test]
432 fn spaces_around_equals_in_key_value() {
433 let h = header("{r, label = setup}");
436 assert_eq!(h.labels.len(), 1);
437 assert_eq!(h.labels[0].value, "setup");
438 assert_eq!(h.labels[0].source, ChunkLabelSource::InlineKey);
439 }
440
441 #[test]
442 fn spaces_around_equals_with_quoted_value() {
443 let h = header(r#"{r, label = "my label"}"#);
444 assert_eq!(h.labels.len(), 1);
445 assert_eq!(h.labels[0].value, "my label");
446 assert_eq!(h.labels[0].source, ChunkLabelSource::InlineKey);
447 }
448
449 #[test]
450 fn quoted_bare_token_does_not_livelock() {
451 let h = header(r#"{r "setup"}"#);
453 assert_eq!(h.engine, "r");
454 assert_eq!(h.labels.len(), 1);
456 assert_eq!(h.labels[0].value, "setup");
457 assert_eq!(h.labels[0].source, ChunkLabelSource::InlinePositional);
458 }
459
460 #[test]
461 fn stray_quote_does_not_livelock() {
462 let h = header(r#"{r, label="oops}"#);
464 assert_eq!(h.engine, "r");
465 assert!(!h.labels.is_empty());
467 }
468}