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: keep the entire line as Structure.
310            // Splitting Structure(stars)+Prose(title) reflowed multi-sentence
311            // titles and left continuation lines without stars (orphan body).
312            // Org headlines are single-line; do not reflow them.
313            if HEADLINE_RE.is_match(line) {
314                flush_prose(&mut current_prose, &mut regions);
315                regions.push(Region::Structure(format!("{line}\n")));
316                continue;
317            }
318
319            // List item: marker is structure, rest is prose
320            if let Some(caps) = LIST_ITEM_RE.captures(line) {
321                flush_prose(&mut current_prose, &mut regions);
322                let marker = caps.get(1).unwrap().as_str();
323                let text = caps.get(2).unwrap().as_str();
324                // Track indent for continuation detection: text starts at marker length
325                list_item_indent = Some(marker.len());
326                regions.push(Region::Structure(marker.to_string()));
327                if !text.is_empty() {
328                    regions.push(Region::Prose(text.to_string()));
329                }
330                regions.push(Region::Structure("\n".to_string()));
331                continue;
332            }
333
334            // List item continuation: indented line following a list item
335            if let Some(indent) = list_item_indent {
336                let leading = line.len() - line.trim_start().len();
337                if leading >= indent && !line.trim().is_empty() {
338                    // Append to the previous Prose region of the list item.
339                    // The last three regions are Structure(marker), Prose(text), Structure(\n)
340                    // We want to extend the Prose region.
341                    if let Some(Region::Structure(s)) = regions.last() {
342                        if s == "\n" {
343                            regions.pop(); // remove the \n
344                            if let Some(Region::Prose(prose)) = regions.last_mut() {
345                                prose.push(' ');
346                                prose.push_str(line.trim());
347                            }
348                            regions.push(Region::Structure("\n".to_string()));
349                            continue;
350                        }
351                    }
352                }
353                // Not a continuation: leave list context
354                list_item_indent = None;
355            }
356
357            // Regular prose line -- accumulate
358            if !current_prose.is_empty() {
359                current_prose.push(' ');
360            }
361            current_prose.push_str(line.trim());
362        }
363
364        // Flush remaining
365        flush_prose(&mut current_prose, &mut regions);
366        // Unclosed source block at EOF: still emit as Code with empty footer.
367        if in_src_block {
368            regions.push(Region::Code {
369                lang: src_lang.take(),
370                header: std::mem::take(&mut src_header),
371                body: std::mem::take(&mut src_body),
372                footer: String::new(),
373            });
374        }
375
376        regions
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    #[test]
385    fn simple_prose() {
386        let input = "Hello world. This is a test.\nAnother line here.";
387        let regions = OrgParser.parse(input);
388        assert_eq!(
389            regions,
390            vec![Region::Prose(
391                "Hello world. This is a test. Another line here.".to_string()
392            )]
393        );
394    }
395
396    #[test]
397    fn preserves_blocks() {
398        let input = "Some prose.\n#+BEGIN_SRC python\nprint('hello')\n#+END_SRC\nMore prose.";
399        let regions = OrgParser.parse(input);
400        assert_eq!(regions.len(), 3);
401        assert!(matches!(&regions[0], Region::Prose(_)));
402        match &regions[1] {
403            Region::Code {
404                lang,
405                header,
406                body,
407                footer,
408            } => {
409                assert_eq!(lang.as_deref(), Some("python"));
410                assert_eq!(header, "#+BEGIN_SRC python\n");
411                assert_eq!(body, "print('hello')\n");
412                assert_eq!(footer, "#+END_SRC\n");
413            }
414            other => panic!("expected Region::Code, got {other:?}"),
415        }
416        assert!(matches!(&regions[2], Region::Prose(_)));
417    }
418
419    #[test]
420    fn preserves_keywords() {
421        let input = "#+TITLE: My Document\n#+AUTHOR: Someone\n\nSome text here.";
422        let regions = OrgParser.parse(input);
423        assert!(matches!(&regions[0], Region::Structure(_)));
424        assert!(matches!(&regions[1], Region::Structure(_)));
425    }
426
427    #[test]
428    fn headline_is_structure_not_prose() {
429        let input = "* TODO This is a headline";
430        let regions = OrgParser.parse(input);
431        assert_eq!(regions.len(), 1);
432        assert_eq!(
433            regions[0],
434            Region::Structure("* TODO This is a headline\n".to_string())
435        );
436    }
437
438    #[test]
439    fn multi_sentence_headline_stays_one_line() {
440        use crate::format::Format;
441        use crate::{FormatConfig, format_text};
442
443        let input = "** Multi sentence. Second sentence in title\nbody prose. Second body.\n";
444        let cfg = FormatConfig {
445            format: Format::Org,
446            ..Default::default()
447        };
448        let out = format_text(input, &cfg).unwrap();
449        assert!(
450            out.lines()
451                .any(|l| l == "** Multi sentence. Second sentence in title"),
452            "headline must stay one line, got:\n{out}"
453        );
454        assert!(
455            !out.contains("** Multi sentence.\nSecond"),
456            "must not orphan second title sentence without stars:\n{out}"
457        );
458        assert_eq!(format_text(&out, &cfg).unwrap(), out);
459    }
460
461    #[test]
462    fn headline_trailing_angle_bracket_round_trips() {
463        use crate::format::Format;
464        use crate::{FormatConfig, format_text};
465
466        let input = "* TODO R4 :: snapshot field is Box[T], not Vec[T]\nbody\n";
467        let cfg = FormatConfig {
468            format: Format::Org,
469            ..Default::default()
470        };
471        let out = format_text(input, &cfg).unwrap();
472        assert!(
473            out.contains("Vec[T]"),
474            "trailing `>` must survive formatting, got:\n{out}"
475        );
476        assert_eq!(format_text(&out, &cfg).unwrap(), out);
477    }
478
479    #[test]
480    fn bold_emphasis_with_period_does_not_become_headline() {
481        use crate::format::Format;
482        use crate::{FormatConfig, format_text};
483
484        let input = "End of first. *Bold spans period. Continues* after.\n";
485        let cfg = FormatConfig {
486            format: Format::Org,
487            ..Default::default()
488        };
489        let out = format_text(input, &cfg).unwrap();
490        // Emphasis with an internal period must stay on one line; splitting
491        // would leave a line starting with `*Bold` and a dangling closer.
492        let bold_lines: Vec<_> = out
493            .lines()
494            .filter(|l| l.contains("*Bold") || l.contains("Continues*"))
495            .collect();
496        assert_eq!(
497            bold_lines.len(),
498            1,
499            "bold emphasis must not split across lines, got:\n{out}"
500        );
501        assert!(bold_lines[0].contains("*Bold spans period. Continues*"));
502        // Org headlines are stars + space; ensure we never introduce one.
503        for line in out.lines() {
504            let stars = line.chars().take_while(|c| *c == '*').count();
505            if stars > 0 {
506                let rest = &line[stars..];
507                assert!(
508                    !rest.starts_with(' ') || rest.trim().is_empty() || line.starts_with("* "),
509                    "unexpected star-line: {line}"
510                );
511            }
512        }
513        assert_eq!(format_text(&out, &cfg).unwrap(), out);
514    }
515
516    #[test]
517    fn table_preserved() {
518        let input = "| Name | Age |\n|------+-----|\n| Alice | 30 |";
519        let regions = OrgParser.parse(input);
520        assert!(regions.iter().all(|r| matches!(r, Region::Structure(_))));
521    }
522
523    #[test]
524    fn list_item_split() {
525        let input = "- First item text\n- Second item text";
526        let regions = OrgParser.parse(input);
527        // Each list item: Structure(marker) + Prose(text) + Structure(\n)
528        assert_eq!(regions.len(), 6);
529        assert_eq!(regions[0], Region::Structure("- ".to_string()));
530        assert_eq!(regions[1], Region::Prose("First item text".to_string()));
531    }
532
533    #[test]
534    fn list_item_continuation() {
535        let input = "- First sentence of item.\n  Continuation of the same item.\n- Second item";
536        let regions = OrgParser.parse(input);
537        // First item: Structure("- ") + Prose("First sentence of item. Continuation of the same item.") + Structure("\n")
538        assert_eq!(regions[0], Region::Structure("- ".to_string()));
539        assert_eq!(
540            regions[1],
541            Region::Prose("First sentence of item. Continuation of the same item.".to_string())
542        );
543        assert_eq!(regions[2], Region::Structure("\n".to_string()));
544        // Second item: Structure("- ") + Prose("Second item") + Structure("\n")
545        assert_eq!(regions[3], Region::Structure("- ".to_string()));
546        assert_eq!(regions[4], Region::Prose("Second item".to_string()));
547    }
548
549    #[test]
550    fn drawer_preserved() {
551        let input = ":PROPERTIES:\n:ID: abc123\n:END:\nSome text.";
552        let regions = OrgParser.parse(input);
553        assert!(matches!(&regions[0], Region::Structure(_))); // :PROPERTIES:
554        assert!(matches!(&regions[1], Region::Structure(_))); // :ID:
555        assert!(matches!(&regions[2], Region::Structure(_))); // :END:
556    }
557
558    #[test]
559    fn latex_environment_preserved() {
560        let input = "Some text.\n\\begin{equation}\nx = 5\n\\end{equation}\nMore text.";
561        let regions = OrgParser.parse(input);
562        // Prose, Structure(\begin), Structure(x=5), Structure(\end), Prose
563        assert!(matches!(&regions[0], Region::Prose(_)));
564        assert!(matches!(&regions[1], Region::Structure(s) if s.contains("\\begin{equation}")));
565        assert!(matches!(&regions[2], Region::Structure(s) if s.contains("x = 5")));
566        assert!(matches!(&regions[3], Region::Structure(s) if s.contains("\\end{equation}")));
567        assert!(matches!(&regions[4], Region::Prose(_)));
568    }
569
570    #[test]
571    fn display_math_preserved() {
572        let input = "Some text.\n\\[\nx = 5\n\\]\nMore text.";
573        let regions = OrgParser.parse(input);
574        assert!(matches!(&regions[0], Region::Prose(_)));
575        assert!(matches!(&regions[1], Region::Structure(s) if s.contains("\\[")));
576        assert!(matches!(&regions[2], Region::Structure(s) if s.contains("x = 5")));
577        assert!(matches!(&regions[3], Region::Structure(s) if s.contains("\\]")));
578        assert!(matches!(&regions[4], Region::Prose(_)));
579    }
580
581    #[test]
582    fn export_snippet_preserved() {
583        let input = "Text before.\n@@latex:\\newpage@@\nText after.";
584        let regions = OrgParser.parse(input);
585        assert!(matches!(&regions[0], Region::Prose(_)));
586        assert!(matches!(&regions[1], Region::Structure(s) if s.contains("@@latex:")));
587        assert!(matches!(&regions[2], Region::Prose(_)));
588    }
589
590    #[test]
591    fn nested_latex_envs() {
592        let input = "Prose.\n\\begin{align}\na &= b \\\\\nc &= d\n\\end{align}\nMore prose.";
593        let regions = OrgParser.parse(input);
594        assert!(matches!(&regions[0], Region::Prose(_)));
595        // All lines inside align are structure
596        let struct_count = regions
597            .iter()
598            .filter(|r| matches!(r, Region::Structure(_)))
599            .count();
600        assert!(struct_count >= 4); // \begin, two content lines, \end
601    }
602}