Skip to main content

snapper_fmt/parser/
org.rs

1use 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    /// Check if a line starts a block (#+BEGIN_...)
16    fn is_block_begin(line: &str) -> bool {
17        let trimmed = line.trim_start();
18        trimmed.to_ascii_uppercase().starts_with("#+BEGIN_")
19    }
20
21    /// Check if a line ends a block (#+END_...)
22    fn is_block_end(line: &str) -> bool {
23        let trimmed = line.trim_start();
24        trimmed.to_ascii_uppercase().starts_with("#+END_")
25    }
26
27    /// Check if a line starts a property drawer
28    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    /// Check if a line ends a drawer
34    fn is_drawer_end(line: &str) -> bool {
35        line.trim().eq_ignore_ascii_case(":END:")
36    }
37
38    /// Check if a line is a keyword/directive (#+KEYWORD:)
39    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    /// Check if a line is a comment (starts with #, but not #+)
45    fn is_comment(line: &str) -> bool {
46        let trimmed = line.trim_start();
47        trimmed.starts_with('#') && !trimmed.starts_with("#+")
48    }
49
50    /// Check if a line is a table row
51    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        // Track list item context: indent level of the marker text.
64        // Continuation lines indented at or beyond this level belong to the item.
65        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            // Check for snapper:off/on pragmas
76            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            // Inside pragma-off region: pass through unchanged
84            if pragma_off {
85                flush_prose(&mut current_prose, &mut regions);
86                regions.push(Region::Structure(format!("{line}\n")));
87                continue;
88            }
89
90            // Inside a block -- everything is structure
91            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            // Inside a drawer -- everything is structure
101            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            // Block begin
111            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            // Drawer begin
119            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            // Blank line
127            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            // Keyword/directive
135            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            // Comment
142            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            // Table row
149            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            // Headline: stars + optional keyword are structure, rest is prose
156            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            // List item: marker is structure, rest is prose
169            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                // Track indent for continuation detection: text starts at marker length
174                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            // List item continuation: indented line following a list item
184            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                    // Append to the previous Prose region of the list item.
188                    // The last three regions are Structure(marker), Prose(text), Structure(\n)
189                    // We want to extend the Prose region.
190                    if let Some(Region::Structure(s)) = regions.last() {
191                        if s == "\n" {
192                            regions.pop(); // remove the \n
193                            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                // Not a continuation: leave list context
203                list_item_indent = None;
204            }
205
206            // Regular prose line -- accumulate
207            if !current_prose.is_empty() {
208                current_prose.push(' ');
209            }
210            current_prose.push_str(line.trim());
211        }
212
213        // Flush remaining
214        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!(&regions[0], Region::Prose(_)));
242        assert!(matches!(&regions[1], Region::Structure(_))); // BEGIN
243        assert!(matches!(&regions[2], Region::Structure(_))); // code
244        assert!(matches!(&regions[3], Region::Structure(_))); // END
245        assert!(matches!(&regions[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!(&regions[0], Region::Structure(_)));
253        assert!(matches!(&regions[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        // Each list item: Structure(marker) + Prose(text) + Structure(\n)
278        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        // First item: Structure("- ") + Prose("First sentence of item. Continuation of the same item.") + Structure("\n")
288        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        // Second item: Structure("- ") + Prose("Second item") + Structure("\n")
295        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!(&regions[0], Region::Structure(_))); // :PROPERTIES:
304        assert!(matches!(&regions[1], Region::Structure(_))); // :ID:
305        assert!(matches!(&regions[2], Region::Structure(_))); // :END:
306    }
307}