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