oxdock_parser/
markdown.rs1use anyhow::{Result, bail};
2use line_ending::LineEnding;
3use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
4
5#[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#[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
23pub 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 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 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 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(¤t_info, current_line_no)?;
72 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}