oxdock_parser/
markdown.rs1use anyhow::{Result, bail};
2use line_ending::LineEnding;
3
4#[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#[derive(Debug, Clone, Eq, PartialEq)]
15pub struct FencedBlock {
16 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
32pub 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}