Skip to main content

snapper_fmt/parser/
rst.rs

1use crate::parser::{FormatParser, Region};
2
3pub struct RstParser;
4
5impl FormatParser for RstParser {
6    fn parse(&self, input: &str) -> Vec<Region> {
7        parse_line_based(input)
8    }
9}
10
11/// Line-based RST parser. Handles directives, literal blocks, sections,
12/// field lists, comments, and tables as structure regions.
13fn parse_line_based(input: &str) -> Vec<Region> {
14    let mut regions = Vec::new();
15    let mut current_prose = String::new();
16    let mut in_literal_block = false;
17    let mut literal_indent: usize = 0;
18    let mut in_directive = false;
19    let mut directive_indent: usize = 0;
20    let mut pragma_off = false;
21
22    let flush_prose = |prose: &mut String, regions: &mut Vec<Region>| {
23        if !prose.is_empty() {
24            regions.push(Region::Prose(prose.clone()));
25            prose.clear();
26        }
27    };
28
29    let lines: Vec<&str> = input.lines().collect();
30    let total = lines.len();
31    let mut i = 0;
32
33    while i < total {
34        let line = lines[i];
35
36        // Pragma check
37        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            i += 1;
42            continue;
43        }
44
45        if pragma_off {
46            flush_prose(&mut current_prose, &mut regions);
47            regions.push(Region::Structure(format!("{line}\n")));
48            i += 1;
49            continue;
50        }
51
52        // Inside literal block
53        if in_literal_block {
54            let leading = line.len() - line.trim_start().len();
55            if line.trim().is_empty() || leading >= literal_indent {
56                regions.push(Region::Structure(format!("{line}\n")));
57                i += 1;
58                continue;
59            }
60            in_literal_block = false;
61        }
62
63        // Inside directive body
64        if in_directive {
65            let leading = line.len() - line.trim_start().len();
66            if line.trim().is_empty() || leading >= directive_indent {
67                regions.push(Region::Structure(format!("{line}\n")));
68                i += 1;
69                continue;
70            }
71            in_directive = false;
72        }
73
74        // Blank line
75        if line.trim().is_empty() {
76            flush_prose(&mut current_prose, &mut regions);
77            regions.push(Region::BlankLines(format!("{line}\n")));
78            i += 1;
79            continue;
80        }
81
82        // RST directive (.. something::)
83        let trimmed = line.trim_start();
84        if trimmed.starts_with(".. ") && trimmed.contains("::") {
85            flush_prose(&mut current_prose, &mut regions);
86            regions.push(Region::Structure(format!("{line}\n")));
87            let leading = line.len() - trimmed.len();
88            directive_indent = leading + 3;
89            in_directive = true;
90            i += 1;
91            continue;
92        }
93
94        // RST comment (.. without directive)
95        if trimmed.starts_with(".. ") && !trimmed.contains("::") {
96            flush_prose(&mut current_prose, &mut regions);
97            regions.push(Region::Structure(format!("{line}\n")));
98            i += 1;
99            continue;
100        }
101
102        // Section underline
103        if is_underline(line) {
104            flush_prose(&mut current_prose, &mut regions);
105            regions.push(Region::Structure(format!("{line}\n")));
106            i += 1;
107            continue;
108        }
109
110        // Section title (next line is underline)
111        if i + 1 < total && is_underline(lines[i + 1]) {
112            flush_prose(&mut current_prose, &mut regions);
113            regions.push(Region::Structure(format!("{line}\n")));
114            i += 1;
115            continue;
116        }
117
118        // Field list (:field: value)
119        if trimmed.starts_with(':') && trimmed.len() > 2 {
120            if let Some(colon_pos) = trimmed[1..].find(':') {
121                if colon_pos > 0 && colon_pos < trimmed.len() - 2 {
122                    flush_prose(&mut current_prose, &mut regions);
123                    regions.push(Region::Structure(format!("{line}\n")));
124                    i += 1;
125                    continue;
126                }
127            }
128        }
129
130        // Literal block intro (line ending with ::)
131        if trimmed.ends_with("::") {
132            flush_prose(&mut current_prose, &mut regions);
133            regions.push(Region::Structure(format!("{line}\n")));
134            // Find indent of next non-blank line
135            let mut j = i + 1;
136            while j < total && lines[j].trim().is_empty() {
137                j += 1;
138            }
139            if j < total {
140                let next_indent = lines[j].len() - lines[j].trim_start().len();
141                if next_indent > 0 {
142                    literal_indent = next_indent;
143                    in_literal_block = true;
144                }
145            }
146            i += 1;
147            continue;
148        }
149
150        // Grid/simple table rows
151        if trimmed.starts_with('|') || trimmed.starts_with('+') {
152            flush_prose(&mut current_prose, &mut regions);
153            regions.push(Region::Structure(format!("{line}\n")));
154            i += 1;
155            continue;
156        }
157
158        // Regular prose
159        if !current_prose.is_empty() {
160            current_prose.push(' ');
161        }
162        current_prose.push_str(trimmed);
163        i += 1;
164    }
165
166    flush_prose(&mut current_prose, &mut regions);
167    regions
168}
169
170/// Check if a line is a section underline (2+ repeated punctuation chars).
171fn is_underline(line: &str) -> bool {
172    let trimmed = line.trim();
173    if trimmed.len() < 2 {
174        return false;
175    }
176    let first = trimmed.as_bytes()[0];
177    matches!(first, b'=' | b'-' | b'~' | b'^' | b'"' | b'#' | b'*' | b'+')
178        && trimmed.bytes().all(|b| b == first)
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn simple_prose() {
187        let input = "Hello world. This is a test.\nAnother line here.";
188        let regions = RstParser.parse(input);
189        assert!(
190            regions
191                .iter()
192                .any(|r| matches!(r, Region::Prose(s) if s.contains("Hello world.")))
193        );
194    }
195
196    #[test]
197    fn directive_preserved() {
198        let input = "Some prose.\n\n.. code-block:: python\n\n   print('hello')\n\nMore prose.";
199        let regions = RstParser.parse(input);
200        let prose_count = regions
201            .iter()
202            .filter(|r| matches!(r, Region::Prose(_)))
203            .count();
204        assert_eq!(prose_count, 2);
205    }
206
207    #[test]
208    fn section_title_preserved() {
209        let input = "My Title\n========\n\nSome text here.";
210        let regions = RstParser.parse(input);
211        assert!(
212            regions
213                .iter()
214                .any(|r| matches!(r, Region::Structure(s) if s.contains("My Title")))
215        );
216        assert!(
217            regions
218                .iter()
219                .any(|r| matches!(r, Region::Structure(s) if s.contains("====")))
220        );
221    }
222
223    #[test]
224    fn literal_block_preserved() {
225        let input = "Example::\n\n   some code\n   more code\n\nBack to prose.";
226        let regions = RstParser.parse(input);
227        let structure_count = regions
228            .iter()
229            .filter(|r| matches!(r, Region::Structure(_)))
230            .count();
231        assert!(structure_count >= 3);
232    }
233
234    #[test]
235    fn field_list_preserved() {
236        let input = ":Author: Someone\n:Date: 2026\n\nParagraph text.";
237        let regions = RstParser.parse(input);
238        assert!(
239            regions
240                .iter()
241                .any(|r| matches!(r, Region::Structure(s) if s.contains("Author")))
242        );
243    }
244}