snapper_fmt/parser/
rst.rs1use regex::Regex;
2use std::sync::LazyLock;
3
4use crate::parser::{FormatParser, Region, flush_prose};
5
6static CODE_DIRECTIVE_RE: LazyLock<Regex> = LazyLock::new(|| {
8 Regex::new(r"^\s*\.\.\s+(?:code-block|sourcecode|code)::\s*([A-Za-z0-9_+.\-]+)?\s*$").unwrap()
9});
10
11pub struct RstParser;
12
13impl FormatParser for RstParser {
14 fn parse(&self, input: &str) -> Vec<Region> {
15 parse_line_based(input)
16 }
17}
18
19fn parse_line_based(input: &str) -> Vec<Region> {
22 let mut regions = Vec::new();
23 let mut current_prose = String::new();
24 let mut in_literal_block = false;
25 let mut literal_indent: usize = 0;
26 let mut in_directive = false;
27 let mut directive_indent: usize = 0;
28 let mut pragma_off = false;
29
30 let mut in_code_block = false;
32 let mut code_indent: usize = 0;
33 let mut code_lang: Option<String> = None;
34 let mut code_header = String::new();
35 let mut code_body = String::new();
36 let mut code_footer_blanks = String::new();
37
38 let lines: Vec<&str> = input.lines().collect();
39 let total = lines.len();
40 let mut i = 0;
41
42 while i < total {
43 let line = lines[i];
44
45 if !in_code_block {
48 if let Some(on) = super::check_pragma(line) {
49 flush_prose(&mut current_prose, &mut regions);
50 pragma_off = !on;
51 regions.push(Region::Structure(format!("{line}\n")));
52 i += 1;
53 continue;
54 }
55
56 if pragma_off {
57 flush_prose(&mut current_prose, &mut regions);
58 regions.push(Region::Structure(format!("{line}\n")));
59 i += 1;
60 continue;
61 }
62 }
63
64 if in_code_block {
69 let leading = line.len() - line.trim_start().len();
70 if line.trim().is_empty() {
71 code_footer_blanks.push_str(line);
73 code_footer_blanks.push('\n');
74 i += 1;
75 continue;
76 }
77 if leading >= code_indent {
78 if !code_footer_blanks.is_empty() {
80 code_body.push_str(&code_footer_blanks);
81 code_footer_blanks.clear();
82 }
83 code_body.push_str(line);
88 code_body.push('\n');
89 i += 1;
90 continue;
91 }
92 in_code_block = false;
94 regions.push(Region::Code {
95 lang: code_lang.take(),
96 header: std::mem::take(&mut code_header),
97 body: std::mem::take(&mut code_body),
98 footer: std::mem::take(&mut code_footer_blanks),
99 });
100 }
102
103 if in_literal_block {
105 let leading = line.len() - line.trim_start().len();
106 if line.trim().is_empty() || leading >= literal_indent {
107 regions.push(Region::Structure(format!("{line}\n")));
108 i += 1;
109 continue;
110 }
111 in_literal_block = false;
112 }
113
114 if in_directive {
116 let leading = line.len() - line.trim_start().len();
117 if line.trim().is_empty() || leading >= directive_indent {
118 regions.push(Region::Structure(format!("{line}\n")));
119 i += 1;
120 continue;
121 }
122 in_directive = false;
123 }
124
125 if line.trim().is_empty() {
127 flush_prose(&mut current_prose, &mut regions);
128 regions.push(Region::BlankLines(format!("{line}\n")));
129 i += 1;
130 continue;
131 }
132
133 if let Some(caps) = CODE_DIRECTIVE_RE.captures(line) {
135 flush_prose(&mut current_prose, &mut regions);
136 code_lang = caps.get(1).map(|m| m.as_str().to_string());
137 code_header = format!("{line}\n");
138 let leading = line.len() - line.trim_start().len();
141 code_indent = leading + 3;
142 code_body.clear();
143 code_footer_blanks.clear();
144 in_code_block = true;
145 i += 1;
146 continue;
147 }
148
149 let trimmed = line.trim_start();
151 if trimmed.starts_with(".. ") && trimmed.contains("::") {
152 flush_prose(&mut current_prose, &mut regions);
153 regions.push(Region::Structure(format!("{line}\n")));
154 let leading = line.len() - trimmed.len();
155 directive_indent = leading + 3;
156 in_directive = true;
157 i += 1;
158 continue;
159 }
160
161 if trimmed.starts_with(".. ") && !trimmed.contains("::") {
163 flush_prose(&mut current_prose, &mut regions);
164 regions.push(Region::Structure(format!("{line}\n")));
165 i += 1;
166 continue;
167 }
168
169 if is_underline(line) {
171 flush_prose(&mut current_prose, &mut regions);
172 regions.push(Region::Structure(format!("{line}\n")));
173 i += 1;
174 continue;
175 }
176
177 if i + 1 < total && is_underline(lines[i + 1]) {
179 flush_prose(&mut current_prose, &mut regions);
180 regions.push(Region::Structure(format!("{line}\n")));
181 i += 1;
182 continue;
183 }
184
185 if trimmed.starts_with(':') && trimmed.len() > 2 {
187 if let Some(colon_pos) = trimmed[1..].find(':') {
188 if colon_pos > 0 && colon_pos < trimmed.len() - 2 {
189 flush_prose(&mut current_prose, &mut regions);
190 regions.push(Region::Structure(format!("{line}\n")));
191 i += 1;
192 continue;
193 }
194 }
195 }
196
197 if trimmed.ends_with("::") {
199 flush_prose(&mut current_prose, &mut regions);
200 regions.push(Region::Structure(format!("{line}\n")));
201 let mut j = i + 1;
203 while j < total && lines[j].trim().is_empty() {
204 j += 1;
205 }
206 if j < total {
207 let next_indent = lines[j].len() - lines[j].trim_start().len();
208 if next_indent > 0 {
209 literal_indent = next_indent;
210 in_literal_block = true;
211 }
212 }
213 i += 1;
214 continue;
215 }
216
217 if trimmed.starts_with('|') || trimmed.starts_with('+') {
219 flush_prose(&mut current_prose, &mut regions);
220 regions.push(Region::Structure(format!("{line}\n")));
221 i += 1;
222 continue;
223 }
224
225 if !current_prose.is_empty() {
227 current_prose.push(' ');
228 }
229 current_prose.push_str(trimmed);
230 i += 1;
231 }
232
233 flush_prose(&mut current_prose, &mut regions);
234 if in_code_block {
235 regions.push(Region::Code {
236 lang: code_lang.take(),
237 header: std::mem::take(&mut code_header),
238 body: std::mem::take(&mut code_body),
239 footer: std::mem::take(&mut code_footer_blanks),
240 });
241 }
242 regions
243}
244
245fn is_underline(line: &str) -> bool {
247 let trimmed = line.trim();
248 if trimmed.len() < 2 {
249 return false;
250 }
251 let first = trimmed.as_bytes()[0];
252 matches!(first, b'=' | b'-' | b'~' | b'^' | b'"' | b'#' | b'*' | b'+')
253 && trimmed.bytes().all(|b| b == first)
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn simple_prose() {
262 let input = "Hello world. This is a test.\nAnother line here.";
263 let regions = RstParser.parse(input);
264 assert!(
265 regions
266 .iter()
267 .any(|r| matches!(r, Region::Prose(s) if s.contains("Hello world.")))
268 );
269 }
270
271 #[test]
272 fn directive_preserved() {
273 let input = "Some prose.\n\n.. code-block:: python\n\n print('hello')\n\nMore prose.";
274 let regions = RstParser.parse(input);
275 let prose_count = regions
276 .iter()
277 .filter(|r| matches!(r, Region::Prose(_)))
278 .count();
279 assert_eq!(prose_count, 2);
280 let code = regions.iter().find_map(|r| match r {
282 Region::Code { lang, body, .. } => Some((lang.clone(), body.clone())),
283 _ => None,
284 });
285 let (lang, body) = code.expect("expected one Region::Code");
286 assert_eq!(lang.as_deref(), Some("python"));
287 assert!(body.contains("print('hello')"));
288 }
289
290 #[test]
291 fn section_title_preserved() {
292 let input = "My Title\n========\n\nSome text here.";
293 let regions = RstParser.parse(input);
294 assert!(
295 regions
296 .iter()
297 .any(|r| matches!(r, Region::Structure(s) if s.contains("My Title")))
298 );
299 assert!(
300 regions
301 .iter()
302 .any(|r| matches!(r, Region::Structure(s) if s.contains("====")))
303 );
304 }
305
306 #[test]
307 fn literal_block_preserved() {
308 let input = "Example::\n\n some code\n more code\n\nBack to prose.";
309 let regions = RstParser.parse(input);
310 let structure_count = regions
311 .iter()
312 .filter(|r| matches!(r, Region::Structure(_)))
313 .count();
314 assert!(structure_count >= 3);
315 }
316
317 #[test]
318 fn field_list_preserved() {
319 let input = ":Author: Someone\n:Date: 2026\n\nParagraph text.";
320 let regions = RstParser.parse(input);
321 assert!(
322 regions
323 .iter()
324 .any(|r| matches!(r, Region::Structure(s) if s.contains("Author")))
325 );
326 }
327}