Skip to main content

oxdock_parser/
markdown.rs

1use anyhow::{Result, bail};
2use line_ending::LineEnding;
3use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
4
5/// Runner configuration parsed from a fenced block's info-string.
6#[derive(Debug, Default, Clone, Eq, PartialEq)]
7pub struct BlockMetadata {
8    pub env: Vec<(String, String)>,
9    pub unified_roots: bool,
10    pub expect_error: Option<String>,
11}
12
13/// A fenced code block extracted from a Markdown document.
14#[derive(Debug, Clone, Eq, PartialEq)]
15pub struct FencedBlock {
16    /// 1-based line number of the opening fence in the source document.
17    pub line_no: usize,
18    pub info_string: String,
19    pub metadata: BlockMetadata,
20    pub body: String,
21}
22
23/// Extract fenced code blocks whose info-string starts with `lang`.
24///
25/// Uses `pulldown-cmark` for reliable CommonMark fence parsing. The first
26/// whitespace-delimited token of an opening fence's info-string selects the
27/// language; remaining tokens are scanned as `key:value` metadata.
28pub fn extract_fenced_blocks(markdown: &str, lang: &str) -> Result<Vec<FencedBlock>> {
29    let mut options = Options::empty();
30    options.insert(Options::ENABLE_TABLES);
31    let offset_parser = Parser::new_ext(markdown, options).into_offset_iter();
32
33    // Pre-compute newline byte offsets for line-number mapping.
34    let mut line_starts: Vec<usize> = vec![0];
35    for (i, _) in markdown.match_indices('\n') {
36        line_starts.push(i + 1);
37    }
38
39    let mut blocks = Vec::new();
40    let mut in_block = false;
41    let mut current_line_no = 0;
42    let mut current_info = String::new();
43    let mut current_body = String::new();
44
45    for (event, range) in offset_parser {
46        match event {
47            Event::Start(Tag::CodeBlock(CodeBlockKind::Fenced(info))) => {
48                let info_str = info.to_string();
49                let first_token = info_str.split_whitespace().next().unwrap_or("");
50                if first_token == lang {
51                    in_block = true;
52                    // Map byte offset to 1-based line number.
53                    let line_no = line_starts.partition_point(|&start| start <= range.start);
54                    current_line_no = line_no;
55                    current_info = info_str;
56                    current_body.clear();
57                }
58            }
59            Event::Text(text) if in_block => {
60                current_body.push_str(&text);
61            }
62            Event::End(TagEnd::CodeBlock) if in_block => {
63                in_block = false;
64                // pulldown-cmark auto-closes unclosed fences by emitting End
65                // at EOF. Detect this by checking whether the block byte
66                // range terminates with an explicit fence closer.
67                let block_slice = markdown[range.start..range.end].trim_end();
68                if !block_slice.ends_with("```") && !block_slice.ends_with("~~~") {
69                    bail!("markdown:{current_line_no}: code fence is never closed");
70                }
71                let metadata = parse_metadata(&current_info, current_line_no)?;
72                // Trim trailing newline that pulldown-cmark includes from the last line.
73                let body = current_body.trim_end_matches('\n').to_string();
74                blocks.push(FencedBlock {
75                    line_no: current_line_no,
76                    info_string: std::mem::take(&mut current_info),
77                    metadata,
78                    body: LineEnding::normalize(&body),
79                });
80            }
81            _ => {}
82        }
83    }
84
85    if in_block {
86        bail!("markdown:{current_line_no}: code fence is never closed");
87    }
88    Ok(blocks)
89}
90
91fn skip_token(info: &str) -> &str {
92    let first = info.split_whitespace().next().unwrap_or("");
93    info.trim_start()[first.len()..].trim_start()
94}
95
96fn parse_metadata(info: &str, line_no: usize) -> Result<BlockMetadata> {
97    let fail = |msg: String| anyhow::anyhow!("markdown:{line_no}: {msg}");
98
99    let rest = skip_token(info);
100    let mut metadata = BlockMetadata::default();
101    let mut scanner = rest.chars().peekable();
102
103    loop {
104        while matches!(scanner.peek(), Some(c) if c.is_whitespace()) {
105            scanner.next();
106        }
107        let Some(first_char) = scanner.peek().copied() else {
108            break;
109        };
110        if !first_char.is_ascii_alphanumeric() && first_char != '_' {
111            return Err(fail(format!(
112                "unexpected character '{first_char}' in fence metadata"
113            )));
114        }
115        let mut key = String::new();
116        let mut saw_equals = false;
117        while let Some(&c) = scanner.peek()
118            && c != ':'
119            && c != '='
120            && !c.is_whitespace()
121        {
122            key.push(c);
123            scanner.next();
124        }
125        match scanner.peek() {
126            Some(':') => {
127                scanner.next();
128            }
129            Some('=') => {
130                saw_equals = true;
131            }
132            other => {
133                let _ = other;
134                return Err(fail(format!(
135                    "fence metadata item '{key}' is missing ':' after its key"
136                )));
137            }
138        }
139        if saw_equals {
140            return Err(fail(format!(
141                "fence metadata item '{key}' must use 'key:value' syntax"
142            )));
143        }
144
145        while matches!(scanner.peek(), Some(' ')) {
146            scanner.next();
147        }
148        let value = if scanner.peek() == Some(&'"') {
149            scanner.next();
150            let mut value = String::new();
151            loop {
152                match scanner.next() {
153                    Some('"') => break,
154                    Some(c) => value.push(c),
155                    None => {
156                        return Err(fail(
157                            "unterminated double quote in fence metadata".to_string(),
158                        ));
159                    }
160                }
161            }
162            value
163        } else {
164            let mut value = String::new();
165            while let Some(&c) = scanner.peek()
166                && !c.is_whitespace()
167            {
168                value.push(c);
169                scanner.next();
170            }
171            value
172        };
173
174        match key.as_str() {
175            "env" => {
176                let (name, val) = value
177                    .split_once('=')
178                    .ok_or_else(|| fail("env metadata must be 'env:KEY=VALUE'".to_string()))?;
179                metadata.env.push((name.to_string(), val.to_string()));
180            }
181            "roots" => {
182                if value != "unified" {
183                    return Err(fail(format!(
184                        "unsupported roots metadata '{value}' (only 'unified' is defined)"
185                    )));
186                }
187                if metadata.unified_roots {
188                    return Err(fail("roots metadata specified more than once".to_string()));
189                }
190                metadata.unified_roots = true;
191            }
192            "expect_error" => {
193                if metadata.expect_error.replace(value.clone()).is_some() {
194                    return Err(fail(
195                        "expect_error metadata specified more than once".to_string(),
196                    ));
197                }
198            }
199            other => {
200                return Err(fail(format!(
201                    "unknown fence metadata key '{other}' (supported: env, roots, expect_error)"
202                )));
203            }
204        }
205    }
206    Ok(metadata)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use indoc::indoc;
213
214    #[test]
215    fn extracts_matching_language_blocks_with_line_numbers() {
216        let md = indoc! {r#"
217            before
218
219            ```bash
220            RUN not extracted
221            ```
222
223            ```oxdock
224            ECHO first
225            ```
226
227            ```text oxdock-like
228            ignored because the first token is text
229            ```
230
231            ```oxdock
232            ECHO second
233            ```
234        "#};
235        let blocks = extract_fenced_blocks(md, "oxdock").expect("extract");
236        assert_eq!(blocks.len(), 2);
237        assert_eq!(blocks[0].line_no, 7);
238        assert_eq!(blocks[0].body, "ECHO first");
239        assert_eq!(blocks[1].line_no, 15);
240        assert_eq!(blocks[1].body, "ECHO second");
241        assert_eq!(blocks[0].metadata, BlockMetadata::default());
242    }
243
244    #[test]
245    fn closing_fence_requires_at_least_as_many_ticks() {
246        let md = indoc! {r#"
247            ````oxdock
248            ECHO stays-open-here
249            ``` not a closer
250            ECHO still-body
251            ````
252            ```oxdock
253            ECHO next
254            ```
255        "#};
256        let blocks = extract_fenced_blocks(md, "oxdock").expect("extract");
257        assert_eq!(blocks.len(), 2);
258        assert_eq!(
259            blocks[0].body,
260            "ECHO stays-open-here\n``` not a closer\nECHO still-body"
261        );
262    }
263
264    #[test]
265    fn unclosed_fence_is_an_error() {
266        let err =
267            extract_fenced_blocks("```oxdock\nECHO x\n", "oxdock").expect_err("unclosed fence");
268        assert!(err.to_string().contains("never closed"));
269    }
270
271    #[test]
272    fn parses_quoted_values_containing_spaces() {
273        let md = "```oxdock expect_error:\"EXIT requested with code 42\"\nEXIT 42\n```\n";
274        let block = &extract_fenced_blocks(md, "oxdock").expect("extract")[0];
275        assert_eq!(
276            block.metadata.expect_error.as_deref(),
277            Some("EXIT requested with code 42")
278        );
279    }
280
281    #[test]
282    fn parses_env_and_roots_metadata() {
283        let md = concat!(
284            "```oxdock env:A=1 env:B=two roots:unified\n",
285            "ECHO x\n",
286            "```\n"
287        );
288        let block = &extract_fenced_blocks(md, "oxdock").expect("extract")[0];
289        assert_eq!(
290            block.metadata.env,
291            vec![
292                ("A".to_string(), "1".to_string()),
293                ("B".to_string(), "two".to_string())
294            ]
295        );
296        assert!(block.metadata.unified_roots);
297    }
298
299    #[test]
300    fn unknown_and_duplicate_keys_are_errors() {
301        let md = "```oxdock bogus:x\nECHO x\n```\n";
302        let err = extract_fenced_blocks(md, "oxdock").expect_err("unknown key");
303        assert!(
304            err.to_string()
305                .contains("unknown fence metadata key 'bogus'")
306        );
307
308        let roots = "```oxdock roots:squashed\nECHO x\n```\n";
309        let err = extract_fenced_blocks(roots, "oxdock").expect_err("bad roots");
310        assert!(err.to_string().contains("only 'unified'"));
311
312        let env = "```oxdock env:NOVALUE\nECHO x\n```\n";
313        let err = extract_fenced_blocks(env, "oxdock").expect_err("env without '='");
314        assert!(err.to_string().contains("env:KEY=VALUE"));
315    }
316
317    #[test]
318    fn unterminated_quote_is_an_error() {
319        let md = "```oxdock expect_error:\"oops\nECHO x\n```\n";
320        let err = extract_fenced_blocks(md, "oxdock").expect_err("unterminated quote");
321        assert!(err.to_string().contains("unterminated double quote"));
322    }
323
324    #[test]
325    fn equals_separator_is_rejected_with_guidance() {
326        let md = "```oxdock expect_error=\"oops\"\nECHO x\n```\n"
327            .replace("expect_error:", "expect_error=");
328        let err = extract_fenced_blocks(&md, "oxdock").expect_err("equals separator");
329        assert!(err.to_string().contains("must use 'key:value' syntax"));
330    }
331
332    #[test]
333    fn crlf_bodies_are_normalized_to_lf() {
334        let md = "```oxdock\r\nECHO a\r\nECHO b\r\n```\r\n";
335        let block = &extract_fenced_blocks(md, "oxdock").expect("extract")[0];
336        assert_eq!(block.body, "ECHO a\nECHO b");
337    }
338
339    #[test]
340    fn bare_cr_bodies_are_normalized_to_lf() {
341        let md = "```oxdock\nECHO a\rECHO b\r\n```\n";
342        let block = &extract_fenced_blocks(md, "oxdock").expect("extract")[0];
343        assert_eq!(block.body, "ECHO a\nECHO b");
344    }
345}