snapper_fmt/parser/
rst.rs1use 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
11fn 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 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 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 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 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 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 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 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 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 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 if trimmed.ends_with("::") {
132 flush_prose(&mut current_prose, &mut regions);
133 regions.push(Region::Structure(format!("{line}\n")));
134 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 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 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
170fn 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}