Skip to main content

oxdock_parser/
markdown.rs

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