Skip to main content

snapper_fmt/parser/
org.rs

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