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
12static LATEX_BEGIN_RE: LazyLock<Regex> =
14 LazyLock::new(|| Regex::new(r"^\s*\\begin\{([^}]+)\}").unwrap());
15
16static LATEX_END_RE: LazyLock<Regex> =
18 LazyLock::new(|| Regex::new(r"^\s*\\end\{([^}]+)\}").unwrap());
19
20static EXPORT_SNIPPET_RE: LazyLock<Regex> =
22 LazyLock::new(|| Regex::new(r"@@[a-zA-Z]+:[^@]*@@").unwrap());
23
24pub struct OrgParser;
25
26impl OrgParser {
27 fn is_block_begin(line: &str) -> bool {
29 let trimmed = line.trim_start();
30 trimmed.to_ascii_uppercase().starts_with("#+BEGIN_")
31 }
32
33 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 let rest = trimmed["#+BEGIN_SRC".len()..].trim_start();
44 if rest.is_empty() {
45 return Some(None);
46 }
47 let lang = rest.split_whitespace().next().map(|s| s.to_string());
49 Some(lang)
50 }
51
52 fn is_block_end(line: &str) -> bool {
54 let trimmed = line.trim_start();
55 trimmed.to_ascii_uppercase().starts_with("#+END_")
56 }
57
58 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 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 fn is_drawer_end(line: &str) -> bool {
72 line.trim().eq_ignore_ascii_case(":END:")
73 }
74
75 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 fn is_comment(line: &str) -> bool {
83 let trimmed = line.trim_start();
84 trimmed.starts_with('#') && !trimmed.starts_with("#+")
85 }
86
87 fn is_table_row(line: &str) -> bool {
89 line.trim_start().starts_with('|')
90 }
91
92 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 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 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 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 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 let mut list_item_indent: Option<usize> = None;
139
140 for line in input.lines() {
141 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 if let Some(Region::Structure(s)) = regions.last() {
342 if s == "\n" {
343 regions.pop(); 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 list_item_indent = None;
355 }
356
357 if !current_prose.is_empty() {
359 current_prose.push(' ');
360 }
361 current_prose.push_str(line.trim());
362 }
363
364 flush_prose(&mut current_prose, &mut regions);
366 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!(®ions[0], Region::Prose(_)));
402 match ®ions[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!(®ions[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!(®ions[0], Region::Structure(_)));
424 assert!(matches!(®ions[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 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 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 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 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 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!(®ions[0], Region::Structure(_))); assert!(matches!(®ions[1], Region::Structure(_))); assert!(matches!(®ions[2], Region::Structure(_))); }
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 assert!(matches!(®ions[0], Region::Prose(_)));
564 assert!(matches!(®ions[1], Region::Structure(s) if s.contains("\\begin{equation}")));
565 assert!(matches!(®ions[2], Region::Structure(s) if s.contains("x = 5")));
566 assert!(matches!(®ions[3], Region::Structure(s) if s.contains("\\end{equation}")));
567 assert!(matches!(®ions[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!(®ions[0], Region::Prose(_)));
575 assert!(matches!(®ions[1], Region::Structure(s) if s.contains("\\[")));
576 assert!(matches!(®ions[2], Region::Structure(s) if s.contains("x = 5")));
577 assert!(matches!(®ions[3], Region::Structure(s) if s.contains("\\]")));
578 assert!(matches!(®ions[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!(®ions[0], Region::Prose(_)));
586 assert!(matches!(®ions[1], Region::Structure(s) if s.contains("@@latex:")));
587 assert!(matches!(®ions[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!(®ions[0], Region::Prose(_)));
595 let struct_count = regions
597 .iter()
598 .filter(|r| matches!(r, Region::Structure(_)))
599 .count();
600 assert!(struct_count >= 4); }
602}