Skip to main content

snapper_fmt/parser/
rst.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4use crate::parser::{FormatParser, Region, flush_prose};
5
6/// Match `.. code-block:: LANG` or `.. sourcecode:: LANG` (or `.. code:: LANG`).
7static 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
19/// Line-based RST parser. Handles directives, literal blocks, sections,
20/// field lists, comments, and tables as structure regions.
21fn 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    // Code-block directive bookkeeping. Mutually exclusive with `in_directive`.
31    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        // Pragma check; inside a code-block directive the per-language
46        // reflow path handles pragmas instead.
47        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        // Inside an rst code-block directive body.
65        // The body consists of lines indented past `code_indent`, plus
66        // interior blank lines. The block ends at a non-blank line whose
67        // indent drops below `code_indent`.
68        if in_code_block {
69            let leading = line.len() - line.trim_start().len();
70            if line.trim().is_empty() {
71                // Could be interior blank or end-of-block; buffer and look ahead.
72                code_footer_blanks.push_str(line);
73                code_footer_blanks.push('\n');
74                i += 1;
75                continue;
76            }
77            if leading >= code_indent {
78                // Promote any buffered interior blanks into the body.
79                if !code_footer_blanks.is_empty() {
80                    code_body.push_str(&code_footer_blanks);
81                    code_footer_blanks.clear();
82                }
83                // Strip the directive's option indent if present? RST options
84                // are keyed `:option: value` at code_indent before the blank
85                // line. We've already passed those into the body verbatim
86                // since they look like normal indented lines; harmless.
87                code_body.push_str(line);
88                code_body.push('\n');
89                i += 1;
90                continue;
91            }
92            // Less-indented non-blank line: close the code block.
93            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            // Fall through to reprocess this line as normal.
101        }
102
103        // Inside literal block
104        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        // Inside directive body
115        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        // Blank line
126        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        // RST code-block directive (.. code-block:: LANG)
134        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            // Body indent: directive_indent + 3 spaces is the rst convention;
139            // be liberal and accept any deeper indent of the first body line.
140            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        // RST directive (.. something::)
150        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        // RST comment (.. without directive)
162        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        // Section underline
170        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        // Section title (next line is underline)
178        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        // Field list (:field: value)
186        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        // Literal block intro (line ending with ::)
198        if trimmed.ends_with("::") {
199            flush_prose(&mut current_prose, &mut regions);
200            regions.push(Region::Structure(format!("{line}\n")));
201            // Find indent of next non-blank line
202            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        // Grid/simple table rows
218        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        // Regular prose
226        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
245/// Check if a line is a section underline (2+ repeated punctuation chars).
246fn 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        // The code block surfaces as Region::Code with lang=python.
281        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}