1use regex::Regex;
2use std::sync::LazyLock;
3
4use crate::parser::{FormatParser, Region, flush_prose};
5
6static HEADING_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(#{1,6}\s+)(.*)$").unwrap());
7
8static FENCED_CODE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(`{3,}|~{3,})").unwrap());
9
10static FENCED_LANG_RE: LazyLock<Regex> =
13 LazyLock::new(|| Regex::new(r"^(?:`{3,}|~{3,})\s*([A-Za-z0-9_+.\-]+)").unwrap());
14
15static LIST_ITEM_RE: LazyLock<Regex> =
16 LazyLock::new(|| Regex::new(r"^(\s*(?:[-*+]|\d+[.)]) )(.*)$").unwrap());
17
18static TABLE_ROW_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\|.*\|\s*$").unwrap());
21
22pub struct MarkdownParser;
23
24fn close_list_item(in_list_item: &mut bool, current_prose: &mut String, regions: &mut Vec<Region>) {
26 if *in_list_item {
27 flush_prose(current_prose, regions);
28 regions.push(Region::Structure("\n".to_string()));
29 *in_list_item = false;
30 }
31}
32
33impl FormatParser for MarkdownParser {
34 fn parse(&self, input: &str) -> Vec<Region> {
35 let mut regions: Vec<Region> = Vec::new();
36 let mut current_prose = String::new();
37 let mut in_fenced_code = false;
38 let mut fence_marker = String::new();
39 let mut code_header = String::new();
41 let mut code_body = String::new();
42 let mut code_lang: Option<String> = None;
43 let mut in_frontmatter = false;
44 let mut frontmatter_fence = String::new();
45 let mut in_list_item = false;
46 let mut line_number = 0;
47 let mut pragma_off = false;
48
49 for line in input.lines() {
50 line_number += 1;
51
52 if !in_fenced_code {
58 if let Some(on) = super::check_pragma(line) {
59 close_list_item(&mut in_list_item, &mut current_prose, &mut regions);
60 flush_prose(&mut current_prose, &mut regions);
61 pragma_off = !on;
62 regions.push(Region::Structure(format!("{line}\n")));
63 continue;
64 }
65
66 if pragma_off {
67 close_list_item(&mut in_list_item, &mut current_prose, &mut regions);
68 flush_prose(&mut current_prose, &mut regions);
69 regions.push(Region::Structure(format!("{line}\n")));
70 continue;
71 }
72 }
73
74 if line_number == 1 && (line.trim() == "---" || line.trim() == "+++") {
76 in_frontmatter = true;
77 frontmatter_fence = line.trim().to_string();
78 regions.push(Region::Structure(format!("{line}\n")));
79 continue;
80 }
81
82 if in_frontmatter {
83 if line.trim() == frontmatter_fence {
84 in_frontmatter = false;
85 }
86 regions.push(Region::Structure(format!("{line}\n")));
87 continue;
88 }
89
90 if in_fenced_code {
92 close_list_item(&mut in_list_item, &mut current_prose, &mut regions);
93 flush_prose(&mut current_prose, &mut regions);
94 let mut closed = false;
95 if let Some(caps) = FENCED_CODE_RE.captures(line.trim_start()) {
96 let marker = caps.get(1).unwrap().as_str();
97 if marker.chars().next() == fence_marker.chars().next()
98 && marker.len() >= fence_marker.len()
99 {
100 closed = true;
101 }
102 }
103 if closed {
104 in_fenced_code = false;
105 regions.push(Region::Code {
106 lang: code_lang.take(),
107 header: std::mem::take(&mut code_header),
108 body: std::mem::take(&mut code_body),
109 footer: format!("{line}\n"),
110 });
111 } else {
112 code_body.push_str(line);
113 code_body.push('\n');
114 }
115 continue;
116 }
117
118 if let Some(caps) = FENCED_CODE_RE.captures(line.trim_start()) {
120 close_list_item(&mut in_list_item, &mut current_prose, &mut regions);
121 flush_prose(&mut current_prose, &mut regions);
122 fence_marker = caps.get(1).unwrap().as_str().to_string();
123 in_fenced_code = true;
124 code_lang = FENCED_LANG_RE
125 .captures(line.trim_start())
126 .map(|c| c.get(1).unwrap().as_str().to_string());
127 code_header = format!("{line}\n");
128 code_body.clear();
129 continue;
130 }
131
132 if line.trim().is_empty() {
134 close_list_item(&mut in_list_item, &mut current_prose, &mut regions);
135 flush_prose(&mut current_prose, &mut regions);
136 regions.push(Region::BlankLines(format!("{line}\n")));
137 continue;
138 }
139
140 if let Some(caps) = HEADING_RE.captures(line) {
142 close_list_item(&mut in_list_item, &mut current_prose, &mut regions);
143 flush_prose(&mut current_prose, &mut regions);
144 let prefix = caps.get(1).unwrap().as_str();
145 let text = caps.get(2).unwrap().as_str();
146 regions.push(Region::Structure(prefix.to_string()));
147 if !text.is_empty() {
148 regions.push(Region::Prose(text.to_string()));
149 }
150 regions.push(Region::Structure("\n".to_string()));
151 continue;
152 }
153
154 if TABLE_ROW_RE.is_match(line) {
156 close_list_item(&mut in_list_item, &mut current_prose, &mut regions);
157 flush_prose(&mut current_prose, &mut regions);
158 regions.push(Region::Structure(format!("{line}\n")));
159 continue;
160 }
161
162 if let Some(caps) = LIST_ITEM_RE.captures(line) {
165 close_list_item(&mut in_list_item, &mut current_prose, &mut regions);
166 flush_prose(&mut current_prose, &mut regions);
167 let marker = caps.get(1).unwrap().as_str();
168 let text = caps.get(2).unwrap().as_str();
169 regions.push(Region::Structure(marker.to_string()));
170 in_list_item = true;
171 if !text.is_empty() {
172 current_prose.push_str(text);
173 }
174 continue;
175 }
176
177 if !current_prose.is_empty() {
179 current_prose.push(' ');
180 }
181 current_prose.push_str(line.trim());
182 }
183
184 close_list_item(&mut in_list_item, &mut current_prose, &mut regions);
185 flush_prose(&mut current_prose, &mut regions);
186 if in_fenced_code {
188 regions.push(Region::Code {
189 lang: code_lang.take(),
190 header: std::mem::take(&mut code_header),
191 body: std::mem::take(&mut code_body),
192 footer: String::new(),
193 });
194 }
195 regions
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202
203 #[test]
204 fn simple_prose() {
205 let input = "Hello world. This is a test.\nAnother line here.";
206 let regions = MarkdownParser.parse(input);
207 assert_eq!(
208 regions,
209 vec![Region::Prose(
210 "Hello world. This is a test. Another line here.".to_string()
211 )]
212 );
213 }
214
215 #[test]
216 fn fenced_code_preserved() {
217 let input = "Some text.\n```python\nprint('hello')\n```\nMore text.";
218 let regions = MarkdownParser.parse(input);
219 assert!(matches!(®ions[0], Region::Prose(_)));
220 match ®ions[1] {
223 Region::Code {
224 lang,
225 header,
226 body,
227 footer,
228 } => {
229 assert_eq!(lang.as_deref(), Some("python"));
230 assert_eq!(header, "```python\n");
231 assert_eq!(body, "print('hello')\n");
232 assert_eq!(footer, "```\n");
233 }
234 other => panic!("expected Region::Code, got {other:?}"),
235 }
236 assert!(matches!(®ions[2], Region::Prose(_)));
237 }
238
239 #[test]
240 fn frontmatter_preserved() {
241 let input = "---\ntitle: Test\nauthor: Someone\n---\n\nSome text.";
242 let regions = MarkdownParser.parse(input);
243 assert!(matches!(®ions[0], Region::Structure(_)));
245 assert!(matches!(®ions[1], Region::Structure(_)));
246 assert!(matches!(®ions[2], Region::Structure(_)));
247 assert!(matches!(®ions[3], Region::Structure(_)));
248 }
249
250 #[test]
251 fn table_preserved() {
252 let input = "| Feature | Why |\n|---------|-----|\n| `Foo` | Bar |";
253 let regions = MarkdownParser.parse(input);
254 assert!(
255 regions.iter().all(|r| matches!(r, Region::Structure(_))),
256 "all table rows should be Structure, got: {:?}",
257 regions
258 );
259 }
260
261 #[test]
262 fn table_with_surrounding_prose() {
263 let input = "Some text before.\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nSome text after.";
264 let regions = MarkdownParser.parse(input);
265 let prose_count = regions
267 .iter()
268 .filter(|r| matches!(r, Region::Prose(_)))
269 .count();
270 let structure_count = regions
271 .iter()
272 .filter(|r| matches!(r, Region::Structure(_)))
273 .count();
274 assert_eq!(prose_count, 2);
275 assert_eq!(structure_count, 3);
276 }
277
278 #[test]
279 fn wide_table_preserved_verbatim() {
280 let input = "| Feature | Why excluded | Follow-up article type |\n|---------------------------------|-------------------------------------------------------|----------------------------|\n| `DraftValidation` | LLM-assisted; needs API key, not production-reliable | Step-by-Step Project |";
281 let regions = MarkdownParser.parse(input);
282 assert_eq!(regions.len(), 3);
283 assert!(regions.iter().all(|r| matches!(r, Region::Structure(_))));
284 for r in ®ions {
286 if let Region::Structure(s) = r {
287 assert!(s.starts_with('|'));
288 assert!(s.ends_with("|\n"));
289 }
290 }
291 }
292
293 #[test]
294 fn list_item_continuation_joined() {
295 let input = "1. First line of item\ncontinuation text here.\nAnother sentence.";
296 let regions = MarkdownParser.parse(input);
297 assert_eq!(regions[0], Region::Structure("1. ".to_string()));
298 assert_eq!(
300 regions[1],
301 Region::Prose(
302 "First line of item continuation text here. Another sentence.".to_string()
303 )
304 );
305 assert_eq!(regions[2], Region::Structure("\n".to_string()));
306 assert_eq!(regions.len(), 3);
307 }
308
309 #[test]
310 fn list_item_continuation_stops_at_blank() {
311 let input = "- Item one text.\ncontinuation.\n\nParagraph after.";
312 let regions = MarkdownParser.parse(input);
313 assert_eq!(regions[0], Region::Structure("- ".to_string()));
314 assert_eq!(
315 regions[1],
316 Region::Prose("Item one text. continuation.".to_string())
317 );
318 assert_eq!(regions[2], Region::Structure("\n".to_string()));
319 assert!(matches!(®ions[3], Region::BlankLines(_)));
320 assert_eq!(regions[4], Region::Prose("Paragraph after.".to_string()));
321 }
322
323 #[test]
324 fn list_item_continuation_stops_at_next_item() {
325 let input = "- First item\ncontinuation.\n- Second item";
326 let regions = MarkdownParser.parse(input);
327 assert_eq!(regions[0], Region::Structure("- ".to_string()));
329 assert_eq!(
330 regions[1],
331 Region::Prose("First item continuation.".to_string())
332 );
333 assert_eq!(regions[2], Region::Structure("\n".to_string()));
334 assert_eq!(regions[3], Region::Structure("- ".to_string()));
336 assert_eq!(regions[4], Region::Prose("Second item".to_string()));
337 assert_eq!(regions[5], Region::Structure("\n".to_string()));
338 }
339
340 #[test]
341 fn numbered_list_with_backtick_continuation() {
342 let input = "1. **Quality gates:** `Thresholds(warning=0.1)`\nlets you express failure rates. Replaces binary assert.";
344 let regions = MarkdownParser.parse(input);
345 assert_eq!(regions[0], Region::Structure("1. ".to_string()));
346 assert_eq!(
347 regions[1],
348 Region::Prose(
349 "**Quality gates:** `Thresholds(warning=0.1)` lets you express failure rates. Replaces binary assert.".to_string()
350 )
351 );
352 assert_eq!(regions[2], Region::Structure("\n".to_string()));
353 }
354
355 #[test]
356 fn heading_split() {
357 let input = "## My Heading";
358 let regions = MarkdownParser.parse(input);
359 assert_eq!(regions.len(), 3);
360 assert_eq!(regions[0], Region::Structure("## ".to_string()));
361 assert_eq!(regions[1], Region::Prose("My Heading".to_string()));
362 assert_eq!(regions[2], Region::Structure("\n".to_string()));
363 }
364}