Skip to main content

snapper_fmt/parser/
markdown.rs

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
10/// Capture the language token immediately after a fence marker.
11/// `lang` is `[A-Za-z0-9_+.-]+`; anything past it (info string) is ignored.
12static 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
18/// Match a markdown table row: line whose trimmed form starts and ends with `|`.
19/// Also matches separator rows like `|---|---|`.
20static TABLE_ROW_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\|.*\|\s*$").unwrap());
21
22pub struct MarkdownParser;
23
24/// Close an open list item: flush accumulated prose and emit the trailing newline.
25fn 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        // Buffer for the running code block: header line, body lines, lang
40        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            // Check for snapper:off/on pragmas. Inside a fenced code block,
53            // the markdown parser does NOT short-circuit on pragmas; the
54            // code-block reflow handles them per-language (the markers
55            // `#`, `//`, `--`, `;` are all valid pragma prefixes inside
56            // their respective languages).
57            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            // Front matter detection (only at start of file)
75            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            // Inside fenced code block
91            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            // Fenced code block start
119            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            // Blank line
133            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            // Heading
141            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            // Table row (pipe-delimited)
155            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            // List item: emit marker as Structure, start accumulating text as prose.
163            // Continuation lines are appended until a block boundary.
164            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            // Regular prose (also serves as list-item continuation when in_list_item)
178            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        // Unclosed fence at EOF: emit a code region with empty footer.
187        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!(&regions[0], Region::Prose(_)));
220        // Code blocks now collapse into a single Region::Code carrying
221        // header, body, and footer.
222        match &regions[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!(&regions[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        // First 4 lines are structure (frontmatter)
244        assert!(matches!(&regions[0], Region::Structure(_)));
245        assert!(matches!(&regions[1], Region::Structure(_)));
246        assert!(matches!(&regions[2], Region::Structure(_)));
247        assert!(matches!(&regions[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        // Should have: Prose, Blank, 3x Structure (table rows), Blank, Prose
266        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        // Verify each line is preserved exactly (with trailing newline)
285        for r in &regions {
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        // All three lines should be joined into one Prose region
299        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!(&regions[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        // First item
328        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        // Second item
335        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        // The exact bug from the user report
343        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}