Skip to main content

snapper_fmt/parser/
rst.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4use crate::parser::{
5    ByteSpan, FormatParser, SpannedRegion, flush_prose_spanned, iter_lines, push_prose_line,
6};
7
8/// Match `.. code-block:: LANG` or `.. sourcecode:: LANG` (or `.. code:: LANG`).
9static 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
21/// Line-based RST parser. Handles directives, literal blocks, sections,
22/// field lists, comments, and tables as structure regions.
23fn 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    // Code-block directive bookkeeping. Mutually exclusive with `in_directive`.
34    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        // Pragma check; inside a code-block directive the per-language
51        // reflow path handles pragmas instead.
52        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        // Inside an rst code-block directive body.
70        // The body consists of lines indented past `code_indent`, plus
71        // interior blank lines. The block ends at a non-blank line whose
72        // indent drops below `code_indent`.
73        if in_code_block {
74            let leading = line_text.len() - line_text.trim_start().len();
75            if line_text.trim().is_empty() {
76                // Could be interior blank or end-of-block; buffer and look ahead.
77                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                // A later body line claims any buffered interior blanks.
85                code_footer_start = None;
86                code_body_end = line.end;
87                i += 1;
88                continue;
89            }
90            // Less-indented non-blank line: close the code block.
91            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            // Fall through to reprocess this line as normal.
109        }
110
111        // Inside literal block
112        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        // Inside directive body
123        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        // Blank line
134        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        // RST code-block directive (.. code-block:: LANG)
142        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            // Body indent: directive_indent + 3 spaces is the rst convention;
147            // be liberal and accept any deeper indent of the first body line.
148            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        // RST directive (.. something::)
159        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        // RST comment (.. without directive)
171        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        // Section underline
179        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        // Section title (next line is underline)
187        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        // Field list (:field: value)
195        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        // Literal block intro (line ending with ::)
207        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            // Find indent of next non-blank line
211            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        // Grid/simple table rows
228        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        // Regular prose
236        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
262/// Check if a line is a section underline (2+ repeated punctuation chars).
263fn 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        // The code block surfaces as Region::Code with lang=python.
299        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}