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
15static LATEX_BEGIN_RE: LazyLock<Regex> =
17 LazyLock::new(|| Regex::new(r"^\s*\\begin\{([^}]+)\}").unwrap());
18
19static LATEX_END_RE: LazyLock<Regex> =
21 LazyLock::new(|| Regex::new(r"^\s*\\end\{([^}]+)\}").unwrap());
22
23static EXPORT_SNIPPET_RE: LazyLock<Regex> =
25 LazyLock::new(|| Regex::new(r"@@[a-zA-Z]+:[^@]*@@").unwrap());
26
27pub struct OrgParser;
28
29impl OrgParser {
30 fn is_block_begin(line: &str) -> bool {
32 let trimmed = line.trim_start();
33 trimmed.to_ascii_uppercase().starts_with("#+BEGIN_")
34 }
35
36 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 let rest = trimmed["#+BEGIN_SRC".len()..].trim_start();
47 if rest.is_empty() {
48 return Some(None);
49 }
50 let lang = rest.split_whitespace().next().map(|s| s.to_string());
52 Some(lang)
53 }
54
55 fn is_block_end(line: &str) -> bool {
57 let trimmed = line.trim_start();
58 trimmed.to_ascii_uppercase().starts_with("#+END_")
59 }
60
61 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 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 fn is_drawer_end(line: &str) -> bool {
75 line.trim().eq_ignore_ascii_case(":END:")
76 }
77
78 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 fn is_comment(line: &str) -> bool {
86 let trimmed = line.trim_start();
87 trimmed.starts_with('#') && !trimmed.starts_with("#+")
88 }
89
90 fn is_table_row(line: &str) -> bool {
92 line.trim_start().starts_with('|')
93 }
94
95 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 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 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 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 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 let mut list_item_indent: Option<usize> = None;
143
144 for line in iter_lines(input) {
145 let line_text = line.text;
146 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 list_item_indent = None;
378 }
379
380 push_prose_line(&mut current_prose, &mut prose_span, &line, true, true);
382 }
383
384 flush_prose_spanned(&mut current_prose, &mut prose_span, &mut regions);
386 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!(®ions[0], Region::Prose(_)));
424 match ®ions[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!(®ions[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!(®ions[0], Region::Structure(_)));
446 assert!(matches!(®ions[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 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 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 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 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 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 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!(®ions[0], Region::Structure(_))); assert!(matches!(®ions[1], Region::Structure(_))); assert!(matches!(®ions[2], Region::Structure(_))); }
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 assert!(matches!(®ions[0], Region::Prose(_)));
616 assert!(matches!(®ions[1], Region::Structure(s) if s.contains("\\begin{equation}")));
617 assert!(matches!(®ions[2], Region::Structure(s) if s.contains("x = 5")));
618 assert!(matches!(®ions[3], Region::Structure(s) if s.contains("\\end{equation}")));
619 assert!(matches!(®ions[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!(®ions[0], Region::Prose(_)));
627 assert!(matches!(®ions[1], Region::Structure(s) if s.contains("\\[")));
628 assert!(matches!(®ions[2], Region::Structure(s) if s.contains("x = 5")));
629 assert!(matches!(®ions[3], Region::Structure(s) if s.contains("\\]")));
630 assert!(matches!(®ions[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!(®ions[0], Region::Prose(_)));
638 assert!(matches!(®ions[1], Region::Structure(s) if s.contains("@@latex:")));
639 assert!(matches!(®ions[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!(®ions[0], Region::Prose(_)));
647 let struct_count = regions
649 .iter()
650 .filter(|r| matches!(r, Region::Structure(_)))
651 .count();
652 assert!(struct_count >= 4); }
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}