snapper_fmt/parser/
org.rs1use regex::Regex;
2use std::sync::LazyLock;
3
4use crate::parser::{FormatParser, Region};
5
6static HEADLINE_RE: LazyLock<Regex> =
7 LazyLock::new(|| Regex::new(r"^(\*+\s+(?:TODO\s+|DONE\s+|NEXT\s+|WAIT\s+)?)(.*)$").unwrap());
8
9static LIST_ITEM_RE: LazyLock<Regex> =
10 LazyLock::new(|| Regex::new(r"^(\s*(?:[-+]|\d+[.)]) )(.*)$").unwrap());
11
12pub struct OrgParser;
13
14impl OrgParser {
15 fn is_block_begin(line: &str) -> bool {
17 let trimmed = line.trim_start();
18 trimmed.to_ascii_uppercase().starts_with("#+BEGIN_")
19 }
20
21 fn is_block_end(line: &str) -> bool {
23 let trimmed = line.trim_start();
24 trimmed.to_ascii_uppercase().starts_with("#+END_")
25 }
26
27 fn is_drawer_begin(line: &str) -> bool {
29 let trimmed = line.trim();
30 trimmed.starts_with(':') && trimmed.ends_with(':') && trimmed.len() > 2
31 }
32
33 fn is_drawer_end(line: &str) -> bool {
35 line.trim().eq_ignore_ascii_case(":END:")
36 }
37
38 fn is_keyword(line: &str) -> bool {
40 let trimmed = line.trim_start();
41 trimmed.starts_with("#+") && !Self::is_block_begin(line) && !Self::is_block_end(line)
42 }
43
44 fn is_comment(line: &str) -> bool {
46 let trimmed = line.trim_start();
47 trimmed.starts_with('#') && !trimmed.starts_with("#+")
48 }
49
50 fn is_table_row(line: &str) -> bool {
52 line.trim_start().starts_with('|')
53 }
54}
55
56impl FormatParser for OrgParser {
57 fn parse(&self, input: &str) -> Vec<Region> {
58 let mut regions: Vec<Region> = Vec::new();
59 let mut current_prose = String::new();
60 let mut in_block = false;
61 let mut in_drawer = false;
62 let mut pragma_off = false;
63 let mut list_item_indent: Option<usize> = None;
66
67 let flush_prose = |prose: &mut String, regions: &mut Vec<Region>| {
68 if !prose.is_empty() {
69 regions.push(Region::Prose(prose.clone()));
70 prose.clear();
71 }
72 };
73
74 for line in input.lines() {
75 if let Some(on) = super::check_pragma(line) {
77 flush_prose(&mut current_prose, &mut regions);
78 pragma_off = !on;
79 regions.push(Region::Structure(format!("{line}\n")));
80 continue;
81 }
82
83 if pragma_off {
85 flush_prose(&mut current_prose, &mut regions);
86 regions.push(Region::Structure(format!("{line}\n")));
87 continue;
88 }
89
90 if in_block {
92 flush_prose(&mut current_prose, &mut regions);
93 if Self::is_block_end(line) {
94 in_block = false;
95 }
96 regions.push(Region::Structure(format!("{line}\n")));
97 continue;
98 }
99
100 if in_drawer {
102 flush_prose(&mut current_prose, &mut regions);
103 if Self::is_drawer_end(line) {
104 in_drawer = false;
105 }
106 regions.push(Region::Structure(format!("{line}\n")));
107 continue;
108 }
109
110 if Self::is_block_begin(line) {
112 flush_prose(&mut current_prose, &mut regions);
113 in_block = true;
114 regions.push(Region::Structure(format!("{line}\n")));
115 continue;
116 }
117
118 if Self::is_drawer_begin(line) {
120 flush_prose(&mut current_prose, &mut regions);
121 in_drawer = true;
122 regions.push(Region::Structure(format!("{line}\n")));
123 continue;
124 }
125
126 if line.trim().is_empty() {
128 flush_prose(&mut current_prose, &mut regions);
129 list_item_indent = None;
130 regions.push(Region::BlankLines(format!("{line}\n")));
131 continue;
132 }
133
134 if Self::is_keyword(line) {
136 flush_prose(&mut current_prose, &mut regions);
137 regions.push(Region::Structure(format!("{line}\n")));
138 continue;
139 }
140
141 if Self::is_comment(line) {
143 flush_prose(&mut current_prose, &mut regions);
144 regions.push(Region::Structure(format!("{line}\n")));
145 continue;
146 }
147
148 if Self::is_table_row(line) {
150 flush_prose(&mut current_prose, &mut regions);
151 regions.push(Region::Structure(format!("{line}\n")));
152 continue;
153 }
154
155 if line.trim_start().starts_with("file:")
157 || line.trim_start().starts_with("http://")
158 || line.trim_start().starts_with("https://")
159 {
160 flush_prose(&mut current_prose, &mut regions);
161 regions.push(Region::Structure(format!("{line}\n")));
162 continue;
163 }
164
165 if let Some(caps) = HEADLINE_RE.captures(line) {
167 flush_prose(&mut current_prose, &mut regions);
168 let prefix = caps.get(1).unwrap().as_str();
169 let text = caps.get(2).unwrap().as_str();
170 regions.push(Region::Structure(prefix.to_string()));
171 if !text.is_empty() {
172 regions.push(Region::Prose(text.to_string()));
173 }
174 regions.push(Region::Structure("\n".to_string()));
175 continue;
176 }
177
178 if let Some(caps) = LIST_ITEM_RE.captures(line) {
180 flush_prose(&mut current_prose, &mut regions);
181 let marker = caps.get(1).unwrap().as_str();
182 let text = caps.get(2).unwrap().as_str();
183 list_item_indent = Some(marker.len());
185 regions.push(Region::Structure(marker.to_string()));
186 if !text.is_empty() {
187 regions.push(Region::Prose(text.to_string()));
188 }
189 regions.push(Region::Structure("\n".to_string()));
190 continue;
191 }
192
193 if let Some(indent) = list_item_indent {
195 let leading = line.len() - line.trim_start().len();
196 if leading >= indent && !line.trim().is_empty() {
197 if let Some(Region::Structure(s)) = regions.last() {
201 if s == "\n" {
202 regions.pop(); if let Some(Region::Prose(prose)) = regions.last_mut() {
204 prose.push(' ');
205 prose.push_str(line.trim());
206 }
207 regions.push(Region::Structure("\n".to_string()));
208 continue;
209 }
210 }
211 }
212 list_item_indent = None;
214 }
215
216 if !current_prose.is_empty() {
218 current_prose.push(' ');
219 }
220 current_prose.push_str(line.trim());
221 }
222
223 flush_prose(&mut current_prose, &mut regions);
225
226 regions
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn simple_prose() {
236 let input = "Hello world. This is a test.\nAnother line here.";
237 let regions = OrgParser.parse(input);
238 assert_eq!(
239 regions,
240 vec![Region::Prose(
241 "Hello world. This is a test. Another line here.".to_string()
242 )]
243 );
244 }
245
246 #[test]
247 fn preserves_blocks() {
248 let input = "Some prose.\n#+BEGIN_SRC python\nprint('hello')\n#+END_SRC\nMore prose.";
249 let regions = OrgParser.parse(input);
250 assert_eq!(regions.len(), 5);
251 assert!(matches!(®ions[0], Region::Prose(_)));
252 assert!(matches!(®ions[1], Region::Structure(_))); assert!(matches!(®ions[2], Region::Structure(_))); assert!(matches!(®ions[3], Region::Structure(_))); assert!(matches!(®ions[4], Region::Prose(_)));
256 }
257
258 #[test]
259 fn preserves_keywords() {
260 let input = "#+TITLE: My Document\n#+AUTHOR: Someone\n\nSome text here.";
261 let regions = OrgParser.parse(input);
262 assert!(matches!(®ions[0], Region::Structure(_)));
263 assert!(matches!(®ions[1], Region::Structure(_)));
264 }
265
266 #[test]
267 fn headline_split() {
268 let input = "* TODO This is a headline";
269 let regions = OrgParser.parse(input);
270 assert_eq!(regions.len(), 3);
271 assert_eq!(regions[0], Region::Structure("* TODO ".to_string()));
272 assert_eq!(regions[1], Region::Prose("This is a headline".to_string()));
273 assert_eq!(regions[2], Region::Structure("\n".to_string()));
274 }
275
276 #[test]
277 fn table_preserved() {
278 let input = "| Name | Age |\n|------+-----|\n| Alice | 30 |";
279 let regions = OrgParser.parse(input);
280 assert!(regions.iter().all(|r| matches!(r, Region::Structure(_))));
281 }
282
283 #[test]
284 fn list_item_split() {
285 let input = "- First item text\n- Second item text";
286 let regions = OrgParser.parse(input);
287 assert_eq!(regions.len(), 6);
289 assert_eq!(regions[0], Region::Structure("- ".to_string()));
290 assert_eq!(regions[1], Region::Prose("First item text".to_string()));
291 }
292
293 #[test]
294 fn list_item_continuation() {
295 let input = "- First sentence of item.\n Continuation of the same item.\n- Second item";
296 let regions = OrgParser.parse(input);
297 assert_eq!(regions[0], Region::Structure("- ".to_string()));
299 assert_eq!(
300 regions[1],
301 Region::Prose("First sentence of item. Continuation of the same item.".to_string())
302 );
303 assert_eq!(regions[2], Region::Structure("\n".to_string()));
304 assert_eq!(regions[3], Region::Structure("- ".to_string()));
306 assert_eq!(regions[4], Region::Prose("Second item".to_string()));
307 }
308
309 #[test]
310 fn drawer_preserved() {
311 let input = ":PROPERTIES:\n:ID: abc123\n:END:\nSome text.";
312 let regions = OrgParser.parse(input);
313 assert!(matches!(®ions[0], Region::Structure(_))); assert!(matches!(®ions[1], Region::Structure(_))); assert!(matches!(®ions[2], Region::Structure(_))); }
317}