Skip to main content

snapper_fmt/parser/
markdown.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4use crate::parser::{FormatParser, Region};
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 LIST_ITEM_RE: LazyLock<Regex> =
11    LazyLock::new(|| Regex::new(r"^(\s*(?:[-*+]|\d+[.)]) )(.*)$").unwrap());
12
13pub struct MarkdownParser;
14
15impl FormatParser for MarkdownParser {
16    fn parse(&self, input: &str) -> Vec<Region> {
17        let mut regions: Vec<Region> = Vec::new();
18        let mut current_prose = String::new();
19        let mut in_fenced_code = false;
20        let mut fence_marker = String::new();
21        let mut in_frontmatter = false;
22        let mut frontmatter_fence = String::new();
23        let mut line_number = 0;
24        let mut pragma_off = false;
25
26        let flush_prose = |prose: &mut String, regions: &mut Vec<Region>| {
27            if !prose.is_empty() {
28                regions.push(Region::Prose(prose.clone()));
29                prose.clear();
30            }
31        };
32
33        for line in input.lines() {
34            line_number += 1;
35
36            // Check for snapper:off/on pragmas
37            if let Some(on) = super::check_pragma(line) {
38                flush_prose(&mut current_prose, &mut regions);
39                pragma_off = !on;
40                regions.push(Region::Structure(format!("{line}\n")));
41                continue;
42            }
43
44            if pragma_off {
45                flush_prose(&mut current_prose, &mut regions);
46                regions.push(Region::Structure(format!("{line}\n")));
47                continue;
48            }
49
50            // Front matter detection (only at start of file)
51            if line_number == 1 && (line.trim() == "---" || line.trim() == "+++") {
52                in_frontmatter = true;
53                frontmatter_fence = line.trim().to_string();
54                regions.push(Region::Structure(format!("{line}\n")));
55                continue;
56            }
57
58            if in_frontmatter {
59                if line.trim() == frontmatter_fence {
60                    in_frontmatter = false;
61                }
62                regions.push(Region::Structure(format!("{line}\n")));
63                continue;
64            }
65
66            // Inside fenced code block
67            if in_fenced_code {
68                flush_prose(&mut current_prose, &mut regions);
69                if let Some(caps) = FENCED_CODE_RE.captures(line.trim_start()) {
70                    let marker = caps.get(1).unwrap().as_str();
71                    if marker.chars().next() == fence_marker.chars().next()
72                        && marker.len() >= fence_marker.len()
73                    {
74                        in_fenced_code = false;
75                    }
76                }
77                regions.push(Region::Structure(format!("{line}\n")));
78                continue;
79            }
80
81            // Fenced code block start
82            if let Some(caps) = FENCED_CODE_RE.captures(line.trim_start()) {
83                flush_prose(&mut current_prose, &mut regions);
84                fence_marker = caps.get(1).unwrap().as_str().to_string();
85                in_fenced_code = true;
86                regions.push(Region::Structure(format!("{line}\n")));
87                continue;
88            }
89
90            // Blank line
91            if line.trim().is_empty() {
92                flush_prose(&mut current_prose, &mut regions);
93                regions.push(Region::BlankLines(format!("{line}\n")));
94                continue;
95            }
96
97            // Heading
98            if let Some(caps) = HEADING_RE.captures(line) {
99                flush_prose(&mut current_prose, &mut regions);
100                let prefix = caps.get(1).unwrap().as_str();
101                let text = caps.get(2).unwrap().as_str();
102                regions.push(Region::Structure(prefix.to_string()));
103                if !text.is_empty() {
104                    regions.push(Region::Prose(text.to_string()));
105                }
106                regions.push(Region::Structure("\n".to_string()));
107                continue;
108            }
109
110            // List item
111            if let Some(caps) = LIST_ITEM_RE.captures(line) {
112                flush_prose(&mut current_prose, &mut regions);
113                let marker = caps.get(1).unwrap().as_str();
114                let text = caps.get(2).unwrap().as_str();
115                regions.push(Region::Structure(marker.to_string()));
116                if !text.is_empty() {
117                    regions.push(Region::Prose(text.to_string()));
118                }
119                regions.push(Region::Structure("\n".to_string()));
120                continue;
121            }
122
123            // Regular prose
124            if !current_prose.is_empty() {
125                current_prose.push(' ');
126            }
127            current_prose.push_str(line.trim());
128        }
129
130        flush_prose(&mut current_prose, &mut regions);
131        regions
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn simple_prose() {
141        let input = "Hello world. This is a test.\nAnother line here.";
142        let regions = MarkdownParser.parse(input);
143        assert_eq!(
144            regions,
145            vec![Region::Prose(
146                "Hello world. This is a test. Another line here.".to_string()
147            )]
148        );
149    }
150
151    #[test]
152    fn fenced_code_preserved() {
153        let input = "Some text.\n```python\nprint('hello')\n```\nMore text.";
154        let regions = MarkdownParser.parse(input);
155        assert!(matches!(&regions[0], Region::Prose(_)));
156        assert!(matches!(&regions[1], Region::Structure(_))); // ```python
157        assert!(matches!(&regions[2], Region::Structure(_))); // code
158        assert!(matches!(&regions[3], Region::Structure(_))); // ```
159    }
160
161    #[test]
162    fn frontmatter_preserved() {
163        let input = "---\ntitle: Test\nauthor: Someone\n---\n\nSome text.";
164        let regions = MarkdownParser.parse(input);
165        // First 4 lines are structure (frontmatter)
166        assert!(matches!(&regions[0], Region::Structure(_)));
167        assert!(matches!(&regions[1], Region::Structure(_)));
168        assert!(matches!(&regions[2], Region::Structure(_)));
169        assert!(matches!(&regions[3], Region::Structure(_)));
170    }
171
172    #[test]
173    fn heading_split() {
174        let input = "## My Heading";
175        let regions = MarkdownParser.parse(input);
176        assert_eq!(regions.len(), 3);
177        assert_eq!(regions[0], Region::Structure("## ".to_string()));
178        assert_eq!(regions[1], Region::Prose("My Heading".to_string()));
179        assert_eq!(regions[2], Region::Structure("\n".to_string()));
180    }
181}