snapper_fmt/parser/
markdown.rs1use 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 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 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 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 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 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 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 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 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!(®ions[0], Region::Prose(_)));
156 assert!(matches!(®ions[1], Region::Structure(_))); assert!(matches!(®ions[2], Region::Structure(_))); assert!(matches!(®ions[3], Region::Structure(_))); }
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 assert!(matches!(®ions[0], Region::Structure(_)));
167 assert!(matches!(®ions[1], Region::Structure(_)));
168 assert!(matches!(®ions[2], Region::Structure(_)));
169 assert!(matches!(®ions[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}