Skip to main content

snapper_fmt/parser/
org.rs

1use regex::Regex;
2use std::sync::LazyLock;
3
4use crate::parser::{FormatParser, Region, flush_prose};
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
12/// Matches LaTeX \begin{env} lines embedded in org prose.
13static LATEX_BEGIN_RE: LazyLock<Regex> =
14    LazyLock::new(|| Regex::new(r"^\s*\\begin\{([^}]+)\}").unwrap());
15
16/// Matches LaTeX \end{env} lines.
17static LATEX_END_RE: LazyLock<Regex> =
18    LazyLock::new(|| Regex::new(r"^\s*\\end\{([^}]+)\}").unwrap());
19
20/// Matches org inline export snippets: @@backend:value@@
21static EXPORT_SNIPPET_RE: LazyLock<Regex> =
22    LazyLock::new(|| Regex::new(r"@@[a-zA-Z]+:[^@]*@@").unwrap());
23
24pub struct OrgParser;
25
26impl OrgParser {
27    /// Check if a line starts a block (#+BEGIN_...)
28    fn is_block_begin(line: &str) -> bool {
29        let trimmed = line.trim_start();
30        trimmed.to_ascii_uppercase().starts_with("#+BEGIN_")
31    }
32
33    /// Check if a line starts a source code block (#+BEGIN_SRC LANG ARGS...).
34    /// Returns the language token if present, or `Some(None)` for a bare
35    /// `#+BEGIN_SRC`. Returns `None` for non-src blocks.
36    fn is_src_begin(line: &str) -> Option<Option<String>> {
37        let trimmed = line.trim_start();
38        let upper = trimmed.to_ascii_uppercase();
39        if !upper.starts_with("#+BEGIN_SRC") {
40            return None;
41        }
42        // Slice the original (case-preserving) tail past the directive.
43        let rest = trimmed["#+BEGIN_SRC".len()..].trim_start();
44        if rest.is_empty() {
45            return Some(None);
46        }
47        // Language is the first whitespace-delimited token.
48        let lang = rest.split_whitespace().next().map(|s| s.to_string());
49        Some(lang)
50    }
51
52    /// Check if a line ends a block (#+END_...)
53    fn is_block_end(line: &str) -> bool {
54        let trimmed = line.trim_start();
55        trimmed.to_ascii_uppercase().starts_with("#+END_")
56    }
57
58    /// Check if a line ends a source code block (#+END_SRC).
59    fn is_src_end(line: &str) -> bool {
60        let trimmed = line.trim_start();
61        trimmed.to_ascii_uppercase().starts_with("#+END_SRC")
62    }
63
64    /// Check if a line starts a property drawer
65    fn is_drawer_begin(line: &str) -> bool {
66        let trimmed = line.trim();
67        trimmed.starts_with(':') && trimmed.ends_with(':') && trimmed.len() > 2
68    }
69
70    /// Check if a line ends a drawer
71    fn is_drawer_end(line: &str) -> bool {
72        line.trim().eq_ignore_ascii_case(":END:")
73    }
74
75    /// Check if a line is a keyword/directive (#+KEYWORD:)
76    fn is_keyword(line: &str) -> bool {
77        let trimmed = line.trim_start();
78        trimmed.starts_with("#+") && !Self::is_block_begin(line) && !Self::is_block_end(line)
79    }
80
81    /// Check if a line is a comment (starts with #, but not #+)
82    fn is_comment(line: &str) -> bool {
83        let trimmed = line.trim_start();
84        trimmed.starts_with('#') && !trimmed.starts_with("#+")
85    }
86
87    /// Check if a line is a table row
88    fn is_table_row(line: &str) -> bool {
89        line.trim_start().starts_with('|')
90    }
91
92    /// Check if a line starts a LaTeX environment (\begin{...})
93    fn is_latex_begin(line: &str) -> Option<String> {
94        LATEX_BEGIN_RE
95            .captures(line)
96            .map(|caps| caps.get(1).unwrap().as_str().to_string())
97    }
98
99    /// Check if a line ends a LaTeX environment (\end{...})
100    fn is_latex_end(line: &str, env: &str) -> bool {
101        LATEX_END_RE
102            .captures(line)
103            .is_some_and(|caps| caps.get(1).unwrap().as_str() == env)
104    }
105
106    /// Check if a line is a display math delimiter (\[ or \])
107    fn is_display_math_open(line: &str) -> bool {
108        line.trim() == r"\["
109    }
110
111    fn is_display_math_close(line: &str) -> bool {
112        line.trim() == r"\]"
113    }
114
115    /// Check if a line is entirely an inline export snippet (@@backend:...@@)
116    fn is_export_snippet_line(line: &str) -> bool {
117        let trimmed = line.trim();
118        EXPORT_SNIPPET_RE.is_match(trimmed) && trimmed.starts_with("@@")
119    }
120}
121
122impl FormatParser for OrgParser {
123    fn parse(&self, input: &str) -> Vec<Region> {
124        let mut regions: Vec<Region> = Vec::new();
125        let mut current_prose = String::new();
126        let mut in_block = false;
127        // Source block bookkeeping; `in_src_block` implies `in_block`.
128        let mut in_src_block = false;
129        let mut src_lang: Option<String> = None;
130        let mut src_header = String::new();
131        let mut src_body = String::new();
132        let mut in_drawer = false;
133        let mut in_latex_env: Option<String> = None;
134        let mut in_display_math = false;
135        let mut pragma_off = false;
136        // Track list item context: indent level of the marker text.
137        // Continuation lines indented at or beyond this level belong to the item.
138        let mut list_item_indent: Option<usize> = None;
139
140        for line in input.lines() {
141            // Check for snapper:off/on pragmas. Inside a source block we
142            // defer pragma handling to the code-block reflow so the
143            // language's own comment marker controls the freeze.
144            if !in_src_block {
145                if let Some(on) = super::check_pragma(line) {
146                    flush_prose(&mut current_prose, &mut regions);
147                    pragma_off = !on;
148                    regions.push(Region::Structure(format!("{line}\n")));
149                    continue;
150                }
151
152                // Inside pragma-off region: pass through unchanged
153                if pragma_off {
154                    flush_prose(&mut current_prose, &mut regions);
155                    regions.push(Region::Structure(format!("{line}\n")));
156                    continue;
157                }
158            }
159
160            // Inside a source block -- buffer body until #+END_SRC
161            if in_src_block {
162                flush_prose(&mut current_prose, &mut regions);
163                if Self::is_src_end(line) {
164                    in_src_block = false;
165                    in_block = false;
166                    regions.push(Region::Code {
167                        lang: src_lang.take(),
168                        header: std::mem::take(&mut src_header),
169                        body: std::mem::take(&mut src_body),
170                        footer: format!("{line}\n"),
171                    });
172                } else {
173                    src_body.push_str(line);
174                    src_body.push('\n');
175                }
176                continue;
177            }
178
179            // Inside a non-src block -- everything is structure
180            if in_block {
181                flush_prose(&mut current_prose, &mut regions);
182                if Self::is_block_end(line) {
183                    in_block = false;
184                }
185                regions.push(Region::Structure(format!("{line}\n")));
186                continue;
187            }
188
189            // Inside a drawer -- everything is structure
190            if in_drawer {
191                flush_prose(&mut current_prose, &mut regions);
192                if Self::is_drawer_end(line) {
193                    in_drawer = false;
194                }
195                regions.push(Region::Structure(format!("{line}\n")));
196                continue;
197            }
198
199            // Inside a LaTeX environment -- everything is structure
200            if let Some(ref env) = in_latex_env {
201                flush_prose(&mut current_prose, &mut regions);
202                let done = Self::is_latex_end(line, env);
203                regions.push(Region::Structure(format!("{line}\n")));
204                if done {
205                    in_latex_env = None;
206                }
207                continue;
208            }
209
210            // Inside display math \[...\] -- everything is structure
211            if in_display_math {
212                flush_prose(&mut current_prose, &mut regions);
213                if Self::is_display_math_close(line) {
214                    in_display_math = false;
215                }
216                regions.push(Region::Structure(format!("{line}\n")));
217                continue;
218            }
219
220            // Source block begin (#+BEGIN_SRC LANG ...)
221            if let Some(lang) = Self::is_src_begin(line) {
222                flush_prose(&mut current_prose, &mut regions);
223                in_block = true;
224                in_src_block = true;
225                src_lang = lang;
226                src_header = format!("{line}\n");
227                src_body.clear();
228                continue;
229            }
230
231            // Other #+BEGIN_ block: opaque structure
232            if Self::is_block_begin(line) {
233                flush_prose(&mut current_prose, &mut regions);
234                in_block = true;
235                regions.push(Region::Structure(format!("{line}\n")));
236                continue;
237            }
238
239            // Drawer begin
240            if Self::is_drawer_begin(line) {
241                flush_prose(&mut current_prose, &mut regions);
242                in_drawer = true;
243                regions.push(Region::Structure(format!("{line}\n")));
244                continue;
245            }
246
247            // LaTeX environment begin (\begin{equation} etc.)
248            if let Some(env) = Self::is_latex_begin(line) {
249                flush_prose(&mut current_prose, &mut regions);
250                in_latex_env = Some(env);
251                regions.push(Region::Structure(format!("{line}\n")));
252                continue;
253            }
254
255            // Display math open (\[)
256            if Self::is_display_math_open(line) {
257                flush_prose(&mut current_prose, &mut regions);
258                in_display_math = true;
259                regions.push(Region::Structure(format!("{line}\n")));
260                continue;
261            }
262
263            // Export snippet line (@@latex:...@@)
264            if Self::is_export_snippet_line(line) {
265                flush_prose(&mut current_prose, &mut regions);
266                regions.push(Region::Structure(format!("{line}\n")));
267                continue;
268            }
269
270            // Blank line
271            if line.trim().is_empty() {
272                flush_prose(&mut current_prose, &mut regions);
273                list_item_indent = None;
274                regions.push(Region::BlankLines(format!("{line}\n")));
275                continue;
276            }
277
278            // Keyword/directive
279            if Self::is_keyword(line) {
280                flush_prose(&mut current_prose, &mut regions);
281                regions.push(Region::Structure(format!("{line}\n")));
282                continue;
283            }
284
285            // Comment
286            if Self::is_comment(line) {
287                flush_prose(&mut current_prose, &mut regions);
288                regions.push(Region::Structure(format!("{line}\n")));
289                continue;
290            }
291
292            // Table row
293            if Self::is_table_row(line) {
294                flush_prose(&mut current_prose, &mut regions);
295                regions.push(Region::Structure(format!("{line}\n")));
296                continue;
297            }
298
299            // Bare file/http links on their own line -- treat as structure
300            if line.trim_start().starts_with("file:")
301                || line.trim_start().starts_with("http://")
302                || line.trim_start().starts_with("https://")
303            {
304                flush_prose(&mut current_prose, &mut regions);
305                regions.push(Region::Structure(format!("{line}\n")));
306                continue;
307            }
308
309            // Headline: stars + optional keyword are structure, rest is prose
310            if let Some(caps) = HEADLINE_RE.captures(line) {
311                flush_prose(&mut current_prose, &mut regions);
312                let prefix = caps.get(1).unwrap().as_str();
313                let text = caps.get(2).unwrap().as_str();
314                regions.push(Region::Structure(prefix.to_string()));
315                if !text.is_empty() {
316                    regions.push(Region::Prose(text.to_string()));
317                }
318                regions.push(Region::Structure("\n".to_string()));
319                continue;
320            }
321
322            // List item: marker is structure, rest is prose
323            if let Some(caps) = LIST_ITEM_RE.captures(line) {
324                flush_prose(&mut current_prose, &mut regions);
325                let marker = caps.get(1).unwrap().as_str();
326                let text = caps.get(2).unwrap().as_str();
327                // Track indent for continuation detection: text starts at marker length
328                list_item_indent = Some(marker.len());
329                regions.push(Region::Structure(marker.to_string()));
330                if !text.is_empty() {
331                    regions.push(Region::Prose(text.to_string()));
332                }
333                regions.push(Region::Structure("\n".to_string()));
334                continue;
335            }
336
337            // List item continuation: indented line following a list item
338            if let Some(indent) = list_item_indent {
339                let leading = line.len() - line.trim_start().len();
340                if leading >= indent && !line.trim().is_empty() {
341                    // Append to the previous Prose region of the list item.
342                    // The last three regions are Structure(marker), Prose(text), Structure(\n)
343                    // We want to extend the Prose region.
344                    if let Some(Region::Structure(s)) = regions.last() {
345                        if s == "\n" {
346                            regions.pop(); // remove the \n
347                            if let Some(Region::Prose(prose)) = regions.last_mut() {
348                                prose.push(' ');
349                                prose.push_str(line.trim());
350                            }
351                            regions.push(Region::Structure("\n".to_string()));
352                            continue;
353                        }
354                    }
355                }
356                // Not a continuation: leave list context
357                list_item_indent = None;
358            }
359
360            // Regular prose line -- accumulate
361            if !current_prose.is_empty() {
362                current_prose.push(' ');
363            }
364            current_prose.push_str(line.trim());
365        }
366
367        // Flush remaining
368        flush_prose(&mut current_prose, &mut regions);
369        // Unclosed source block at EOF: still emit as Code with empty footer.
370        if in_src_block {
371            regions.push(Region::Code {
372                lang: src_lang.take(),
373                header: std::mem::take(&mut src_header),
374                body: std::mem::take(&mut src_body),
375                footer: String::new(),
376            });
377        }
378
379        regions
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    #[test]
388    fn simple_prose() {
389        let input = "Hello world. This is a test.\nAnother line here.";
390        let regions = OrgParser.parse(input);
391        assert_eq!(
392            regions,
393            vec![Region::Prose(
394                "Hello world. This is a test. Another line here.".to_string()
395            )]
396        );
397    }
398
399    #[test]
400    fn preserves_blocks() {
401        let input = "Some prose.\n#+BEGIN_SRC python\nprint('hello')\n#+END_SRC\nMore prose.";
402        let regions = OrgParser.parse(input);
403        assert_eq!(regions.len(), 3);
404        assert!(matches!(&regions[0], Region::Prose(_)));
405        match &regions[1] {
406            Region::Code {
407                lang,
408                header,
409                body,
410                footer,
411            } => {
412                assert_eq!(lang.as_deref(), Some("python"));
413                assert_eq!(header, "#+BEGIN_SRC python\n");
414                assert_eq!(body, "print('hello')\n");
415                assert_eq!(footer, "#+END_SRC\n");
416            }
417            other => panic!("expected Region::Code, got {other:?}"),
418        }
419        assert!(matches!(&regions[2], Region::Prose(_)));
420    }
421
422    #[test]
423    fn preserves_keywords() {
424        let input = "#+TITLE: My Document\n#+AUTHOR: Someone\n\nSome text here.";
425        let regions = OrgParser.parse(input);
426        assert!(matches!(&regions[0], Region::Structure(_)));
427        assert!(matches!(&regions[1], Region::Structure(_)));
428    }
429
430    #[test]
431    fn headline_split() {
432        let input = "* TODO This is a headline";
433        let regions = OrgParser.parse(input);
434        assert_eq!(regions.len(), 3);
435        assert_eq!(regions[0], Region::Structure("* TODO ".to_string()));
436        assert_eq!(regions[1], Region::Prose("This is a headline".to_string()));
437        assert_eq!(regions[2], Region::Structure("\n".to_string()));
438    }
439
440    #[test]
441    fn headline_trailing_angle_bracket_round_trips() {
442        use crate::format::Format;
443        use crate::{FormatConfig, format_text};
444
445        let input = "* TODO R4 :: snapshot field is Box[T], not Vec[T]\nbody\n";
446        let cfg = FormatConfig {
447            format: Format::Org,
448            ..Default::default()
449        };
450        let out = format_text(input, &cfg).unwrap();
451        assert!(
452            out.contains("Vec[T]"),
453            "trailing `>` must survive formatting, got:\n{out}"
454        );
455        assert_eq!(format_text(&out, &cfg).unwrap(), out);
456    }
457
458    #[test]
459    fn bold_emphasis_with_period_does_not_become_headline() {
460        use crate::format::Format;
461        use crate::{FormatConfig, format_text};
462
463        let input = "End of first. *Bold spans period. Continues* after.\n";
464        let cfg = FormatConfig {
465            format: Format::Org,
466            ..Default::default()
467        };
468        let out = format_text(input, &cfg).unwrap();
469        // Emphasis with an internal period must stay on one line; splitting
470        // would leave a line starting with `*Bold` and a dangling closer.
471        let bold_lines: Vec<_> = out
472            .lines()
473            .filter(|l| l.contains("*Bold") || l.contains("Continues*"))
474            .collect();
475        assert_eq!(
476            bold_lines.len(),
477            1,
478            "bold emphasis must not split across lines, got:\n{out}"
479        );
480        assert!(bold_lines[0].contains("*Bold spans period. Continues*"));
481        // Org headlines are stars + space; ensure we never introduce one.
482        for line in out.lines() {
483            let stars = line.chars().take_while(|c| *c == '*').count();
484            if stars > 0 {
485                let rest = &line[stars..];
486                assert!(
487                    !rest.starts_with(' ') || rest.trim().is_empty() || line.starts_with("* "),
488                    "unexpected star-line: {line}"
489                );
490            }
491        }
492        assert_eq!(format_text(&out, &cfg).unwrap(), out);
493    }
494
495    #[test]
496    fn table_preserved() {
497        let input = "| Name | Age |\n|------+-----|\n| Alice | 30 |";
498        let regions = OrgParser.parse(input);
499        assert!(regions.iter().all(|r| matches!(r, Region::Structure(_))));
500    }
501
502    #[test]
503    fn list_item_split() {
504        let input = "- First item text\n- Second item text";
505        let regions = OrgParser.parse(input);
506        // Each list item: Structure(marker) + Prose(text) + Structure(\n)
507        assert_eq!(regions.len(), 6);
508        assert_eq!(regions[0], Region::Structure("- ".to_string()));
509        assert_eq!(regions[1], Region::Prose("First item text".to_string()));
510    }
511
512    #[test]
513    fn list_item_continuation() {
514        let input = "- First sentence of item.\n  Continuation of the same item.\n- Second item";
515        let regions = OrgParser.parse(input);
516        // First item: Structure("- ") + Prose("First sentence of item. Continuation of the same item.") + Structure("\n")
517        assert_eq!(regions[0], Region::Structure("- ".to_string()));
518        assert_eq!(
519            regions[1],
520            Region::Prose("First sentence of item. Continuation of the same item.".to_string())
521        );
522        assert_eq!(regions[2], Region::Structure("\n".to_string()));
523        // Second item: Structure("- ") + Prose("Second item") + Structure("\n")
524        assert_eq!(regions[3], Region::Structure("- ".to_string()));
525        assert_eq!(regions[4], Region::Prose("Second item".to_string()));
526    }
527
528    #[test]
529    fn drawer_preserved() {
530        let input = ":PROPERTIES:\n:ID: abc123\n:END:\nSome text.";
531        let regions = OrgParser.parse(input);
532        assert!(matches!(&regions[0], Region::Structure(_))); // :PROPERTIES:
533        assert!(matches!(&regions[1], Region::Structure(_))); // :ID:
534        assert!(matches!(&regions[2], Region::Structure(_))); // :END:
535    }
536
537    #[test]
538    fn latex_environment_preserved() {
539        let input = "Some text.\n\\begin{equation}\nx = 5\n\\end{equation}\nMore text.";
540        let regions = OrgParser.parse(input);
541        // Prose, Structure(\begin), Structure(x=5), Structure(\end), Prose
542        assert!(matches!(&regions[0], Region::Prose(_)));
543        assert!(matches!(&regions[1], Region::Structure(s) if s.contains("\\begin{equation}")));
544        assert!(matches!(&regions[2], Region::Structure(s) if s.contains("x = 5")));
545        assert!(matches!(&regions[3], Region::Structure(s) if s.contains("\\end{equation}")));
546        assert!(matches!(&regions[4], Region::Prose(_)));
547    }
548
549    #[test]
550    fn display_math_preserved() {
551        let input = "Some text.\n\\[\nx = 5\n\\]\nMore text.";
552        let regions = OrgParser.parse(input);
553        assert!(matches!(&regions[0], Region::Prose(_)));
554        assert!(matches!(&regions[1], Region::Structure(s) if s.contains("\\[")));
555        assert!(matches!(&regions[2], Region::Structure(s) if s.contains("x = 5")));
556        assert!(matches!(&regions[3], Region::Structure(s) if s.contains("\\]")));
557        assert!(matches!(&regions[4], Region::Prose(_)));
558    }
559
560    #[test]
561    fn export_snippet_preserved() {
562        let input = "Text before.\n@@latex:\\newpage@@\nText after.";
563        let regions = OrgParser.parse(input);
564        assert!(matches!(&regions[0], Region::Prose(_)));
565        assert!(matches!(&regions[1], Region::Structure(s) if s.contains("@@latex:")));
566        assert!(matches!(&regions[2], Region::Prose(_)));
567    }
568
569    #[test]
570    fn nested_latex_envs() {
571        let input = "Prose.\n\\begin{align}\na &= b \\\\\nc &= d\n\\end{align}\nMore prose.";
572        let regions = OrgParser.parse(input);
573        assert!(matches!(&regions[0], Region::Prose(_)));
574        // All lines inside align are structure
575        let struct_count = regions
576            .iter()
577            .filter(|r| matches!(r, Region::Structure(_)))
578            .count();
579        assert!(struct_count >= 4); // \begin, two content lines, \end
580    }
581}