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 let Some(caps) = HEADLINE_RE.captures(line) {
157 flush_prose(&mut current_prose, &mut regions);
158 let prefix = caps.get(1).unwrap().as_str();
159 let text = caps.get(2).unwrap().as_str();
160 regions.push(Region::Structure(prefix.to_string()));
161 if !text.is_empty() {
162 regions.push(Region::Prose(text.to_string()));
163 }
164 regions.push(Region::Structure("\n".to_string()));
165 continue;
166 }
167
168 if let Some(caps) = LIST_ITEM_RE.captures(line) {
170 flush_prose(&mut current_prose, &mut regions);
171 let marker = caps.get(1).unwrap().as_str();
172 let text = caps.get(2).unwrap().as_str();
173 list_item_indent = Some(marker.len());
175 regions.push(Region::Structure(marker.to_string()));
176 if !text.is_empty() {
177 regions.push(Region::Prose(text.to_string()));
178 }
179 regions.push(Region::Structure("\n".to_string()));
180 continue;
181 }
182
183 if let Some(indent) = list_item_indent {
185 let leading = line.len() - line.trim_start().len();
186 if leading >= indent && !line.trim().is_empty() {
187 if let Some(Region::Structure(s)) = regions.last() {
191 if s == "\n" {
192 regions.pop(); if let Some(Region::Prose(prose)) = regions.last_mut() {
194 prose.push(' ');
195 prose.push_str(line.trim());
196 }
197 regions.push(Region::Structure("\n".to_string()));
198 continue;
199 }
200 }
201 }
202 list_item_indent = None;
204 }
205
206 if !current_prose.is_empty() {
208 current_prose.push(' ');
209 }
210 current_prose.push_str(line.trim());
211 }
212
213 flush_prose(&mut current_prose, &mut regions);
215
216 regions
217 }
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn simple_prose() {
226 let input = "Hello world. This is a test.\nAnother line here.";
227 let regions = OrgParser.parse(input);
228 assert_eq!(
229 regions,
230 vec![Region::Prose(
231 "Hello world. This is a test. Another line here.".to_string()
232 )]
233 );
234 }
235
236 #[test]
237 fn preserves_blocks() {
238 let input = "Some prose.\n#+BEGIN_SRC python\nprint('hello')\n#+END_SRC\nMore prose.";
239 let regions = OrgParser.parse(input);
240 assert_eq!(regions.len(), 5);
241 assert!(matches!(®ions[0], Region::Prose(_)));
242 assert!(matches!(®ions[1], Region::Structure(_))); assert!(matches!(®ions[2], Region::Structure(_))); assert!(matches!(®ions[3], Region::Structure(_))); assert!(matches!(®ions[4], Region::Prose(_)));
246 }
247
248 #[test]
249 fn preserves_keywords() {
250 let input = "#+TITLE: My Document\n#+AUTHOR: Someone\n\nSome text here.";
251 let regions = OrgParser.parse(input);
252 assert!(matches!(®ions[0], Region::Structure(_)));
253 assert!(matches!(®ions[1], Region::Structure(_)));
254 }
255
256 #[test]
257 fn headline_split() {
258 let input = "* TODO This is a headline";
259 let regions = OrgParser.parse(input);
260 assert_eq!(regions.len(), 3);
261 assert_eq!(regions[0], Region::Structure("* TODO ".to_string()));
262 assert_eq!(regions[1], Region::Prose("This is a headline".to_string()));
263 assert_eq!(regions[2], Region::Structure("\n".to_string()));
264 }
265
266 #[test]
267 fn table_preserved() {
268 let input = "| Name | Age |\n|------+-----|\n| Alice | 30 |";
269 let regions = OrgParser.parse(input);
270 assert!(regions.iter().all(|r| matches!(r, Region::Structure(_))));
271 }
272
273 #[test]
274 fn list_item_split() {
275 let input = "- First item text\n- Second item text";
276 let regions = OrgParser.parse(input);
277 assert_eq!(regions.len(), 6);
279 assert_eq!(regions[0], Region::Structure("- ".to_string()));
280 assert_eq!(regions[1], Region::Prose("First item text".to_string()));
281 }
282
283 #[test]
284 fn list_item_continuation() {
285 let input = "- First sentence of item.\n Continuation of the same item.\n- Second item";
286 let regions = OrgParser.parse(input);
287 assert_eq!(regions[0], Region::Structure("- ".to_string()));
289 assert_eq!(
290 regions[1],
291 Region::Prose("First sentence of item. Continuation of the same item.".to_string())
292 );
293 assert_eq!(regions[2], Region::Structure("\n".to_string()));
294 assert_eq!(regions[3], Region::Structure("- ".to_string()));
296 assert_eq!(regions[4], Region::Prose("Second item".to_string()));
297 }
298
299 #[test]
300 fn drawer_preserved() {
301 let input = ":PROPERTIES:\n:ID: abc123\n:END:\nSome text.";
302 let regions = OrgParser.parse(input);
303 assert!(matches!(®ions[0], Region::Structure(_))); assert!(matches!(®ions[1], Region::Structure(_))); assert!(matches!(®ions[2], Region::Structure(_))); }
307}