1use crate::ast::*;
7
8#[derive(Debug, Clone)]
10pub struct ParseError {
11 pub message: String,
12}
13
14impl std::fmt::Display for ParseError {
15 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
16 write!(f, "{}", self.message)
17 }
18}
19
20impl std::error::Error for ParseError {}
21
22pub fn parse_document(input: &str) -> Result<Document, ParseError> {
28 parse_document_with_residue(input).map(|(doc, _residue)| doc)
29}
30
31pub fn parse_document_with_residue(input: &str) -> Result<(Document, Vec<String>), ParseError> {
43 let lines: Vec<&str> = input.lines().collect();
44 let mut residue = Vec::new();
45 let (blocks, _) = parse_blocks(&lines, 0, &mut residue)?;
46 Ok((Document { blocks }, residue))
47}
48
49type ParseResult<T> = Result<(T, usize), ParseError>;
50
51fn parse_blocks(lines: &[&str], pos: usize, residue: &mut Vec<String>) -> ParseResult<Vec<Block>> {
55 let mut blocks = Vec::new();
56 let mut i = pos;
57 while i < lines.len() {
58 let Some(&line) = lines.get(i) else { break };
59 if line.is_empty() {
60 blocks.push(Block::BlankLine);
62 i += 1;
63 continue;
64 }
65
66 if let Some(Ok((block, next))) = try_parse_heading(lines, i, residue)
67 .or_else(|| try_parse_property_drawer(lines, i))
68 .or_else(|| try_parse_logbook_drawer(lines, i))
69 .or_else(|| try_parse_src_block(lines, i))
70 .or_else(|| try_parse_example_block(lines, i))
71 .or_else(|| try_parse_quote_block(lines, i))
72 .or_else(|| try_parse_list(lines, i))
73 .or_else(|| try_parse_table(lines, i))
74 .or_else(|| try_parse_planning(i, line))
75 .or_else(|| try_parse_comment(i, line))
76 .or_else(|| try_parse_keyword(i, line))
77 .or_else(|| try_parse_horizontal_rule(i, line))
78 .or_else(|| try_parse_paragraph(lines, i))
79 {
80 blocks.push(block);
81 i = next;
82 } else {
83 if !line.is_empty() {
85 residue.push(line.to_string());
86 }
87 i += 1;
88 }
89 }
90 Ok((blocks, i))
91}
92
93fn try_parse_heading(
94 lines: &[&str],
95 pos: usize,
96 residue: &mut Vec<String>,
97) -> Option<ParseResult<Block>> {
98 let line = *lines.get(pos)?;
99 if !line.starts_with('*') {
100 return None;
101 }
102
103 let raw_level = line.chars().take_while(|c| *c == '*').count();
104 if raw_level >= line.len() || !line[raw_level..].starts_with(' ') {
106 return None;
107 }
108 let level: u8 = raw_level.min(255) as u8;
109 let rest = line[raw_level..].trim();
110
111 let (title_str, tags) = if let Some(tag_start) = rest.rfind(" :") {
113 let tag_part = &rest[tag_start + 1..];
114 if tag_part.starts_with(':') && tag_part.ends_with(':') && tag_part.len() > 2 {
115 let title = rest[..tag_start].trim();
116 let tags: Vec<Tag> = tag_part[1..tag_part.len() - 1]
117 .split(':')
118 .filter(|t| !t.is_empty())
119 .map(|t| Tag(t.to_string()))
120 .collect();
121 (title, tags)
122 } else {
123 (rest, vec![])
124 }
125 } else {
126 (rest, vec![])
127 };
128
129 let title = Title(title_str.to_string());
130
131 let mut children = Vec::new();
133 let mut next = pos + 1;
134 while next < lines.len() && !lines.get(next).is_some_and(|l| l.starts_with('*')) {
135 let Some(&line) = lines.get(next) else { break };
138 if line.is_empty() {
139 children.push(Block::BlankLine);
140 next += 1;
141 continue;
142 }
143 let mut consumed = false;
145 for child_parser in [
146 try_parse_property_drawer,
147 try_parse_logbook_drawer,
148 try_parse_src_block,
149 try_parse_example_block,
150 try_parse_quote_block,
151 try_parse_list,
152 try_parse_table,
153 ]
154 .iter()
155 {
156 if let Some(Ok((child_block, new_pos))) = child_parser(lines, next) {
157 children.push(child_block);
158 next = new_pos;
159 consumed = true;
160 break;
161 }
162 }
163 if !consumed {
164 if let Some(Ok((para, new_pos))) = try_parse_paragraph(lines, next) {
166 children.push(para);
167 next = new_pos;
168 } else {
169 if let Some(&child) = lines.get(next) {
171 if !child.is_empty() {
172 residue.push(child.to_string());
173 }
174 }
175 next += 1;
176 }
177 }
178 }
179
180 Some(Ok((
181 Block::Heading {
182 level,
183 title,
184 tags,
185 children,
186 },
187 next,
188 )))
189}
190
191fn try_parse_property_drawer(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
192 if lines.get(pos)?.trim() != ":PROPERTIES:" {
193 return None;
194 }
195 let mut entries = Vec::new();
196 let mut i = pos + 1;
197 while i < lines.len() {
198 let Some(line) = lines.get(i).map(|l| l.trim()) else {
199 break;
200 };
201 if line == ":END:" {
202 return Some(Ok((Block::PropertyDrawer { entries }, i + 1)));
203 }
204 if let Some(stripped) = line.strip_prefix(':') {
205 if let Some(colon_pos) = stripped.find(':') {
206 let key = &stripped[..colon_pos];
207 let value = &stripped[colon_pos + 1..];
210 entries.push((key.to_string(), value.to_string()));
211 }
212 }
213 i += 1;
214 }
215 Some(Ok((Block::PropertyDrawer { entries }, i)))
216}
217
218fn try_parse_logbook_drawer(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
219 if lines.get(pos)?.trim() != ":LOGBOOK:" {
220 return None;
221 }
222 let mut entries = Vec::new();
223 let mut i = pos + 1;
224 while i < lines.len() {
225 let Some(line) = lines.get(i).map(|l| l.trim()) else {
226 break;
227 };
228 if line == ":END:" {
229 return Some(Ok((Block::LogbookDrawer { entries }, i + 1)));
230 }
231 if let Some(rest) = line.strip_prefix("- ") {
233 if rest.starts_with('<') {
234 if let Some(close) = rest.find('>') {
235 let ts = &rest[..=close];
236 let note = rest[close + 1..].trim();
237 entries.push(LogEntry {
238 timestamp: Timestamp(ts.to_string()),
239 note: note.to_string(),
240 });
241 }
242 }
243 }
244 i += 1;
245 }
246 Some(Ok((Block::LogbookDrawer { entries }, i)))
247}
248
249fn try_parse_src_block(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
250 let line = lines.get(pos)?.trim();
251 if !line.starts_with("#+begin_src") {
252 return None;
253 }
254 let language = line
255 .strip_prefix("#+begin_src")
256 .expect("line starts with \"#+begin_src\", checked above")
257 .trim()
258 .to_string();
259
260 let mut i = pos + 1;
261 let mut content = String::new();
262 while i < lines.len() {
263 let Some(&cur) = lines.get(i) else { break };
264 if cur.trim() == "#+end_src" {
265 if !content.is_empty() && !content.ends_with('\n') {
267 content.push('\n');
268 }
269 return Some(Ok((Block::SrcBlock { language, content }, i + 1)));
270 }
271 if !content.is_empty() {
272 content.push('\n');
273 }
274 content.push_str(cur);
275 i += 1;
276 }
277 Some(Ok((Block::SrcBlock { language, content }, i)))
279}
280
281fn try_parse_example_block(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
282 if lines.get(pos)?.trim() != "#+begin_example" {
283 return None;
284 }
285 let mut i = pos + 1;
286 let mut content = String::new();
287 while i < lines.len() {
288 let Some(&cur) = lines.get(i) else { break };
289 if cur.trim() == "#+end_example" {
290 if !content.is_empty() && !content.ends_with('\n') {
291 content.push('\n');
292 }
293 return Some(Ok((Block::ExampleBlock { content }, i + 1)));
294 }
295 if !content.is_empty() {
296 content.push('\n');
297 }
298 content.push_str(cur);
299 i += 1;
300 }
301 Some(Ok((Block::ExampleBlock { content }, i)))
303}
304
305fn try_parse_quote_block(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
306 if lines.get(pos)?.trim() != "#+begin_quote" {
307 return None;
308 }
309 let mut i = pos + 1;
310 let mut child_lines = Vec::new();
311 while i < lines.len() {
312 let Some(&cur) = lines.get(i) else { break };
313 if cur.trim() == "#+end_quote" {
314 let (children, _) = parse_blocks(&child_lines, 0, &mut Vec::new()).ok()?;
316 return Some(Ok((Block::QuoteBlock { children }, i + 1)));
317 }
318 child_lines.push(cur);
319 i += 1;
320 }
321 None
322}
323
324fn try_parse_list(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
325 let (list_type, first_checkbox, first_rest) = match_bullet(lines.get(pos)?)?;
326
327 let mut items: Vec<ListItem> = Vec::new();
328 let mut cur_checkbox = first_checkbox;
329 let mut cur_inlines = parse_inlines(first_rest);
330 let mut i = pos + 1;
331
332 while i < lines.len() {
333 let Some(&line) = lines.get(i) else { break };
334 if line.is_empty() {
335 break;
337 }
338 if let Some((_lt, checkbox, rest)) = match_bullet(line) {
339 items.push(ListItem {
341 content: vec![Block::Paragraph {
342 inlines: std::mem::take(&mut cur_inlines),
343 }],
344 checkbox: cur_checkbox,
345 });
346 cur_checkbox = checkbox;
347 cur_inlines = parse_inlines(rest);
348 i += 1;
349 } else if line.starts_with(' ') || line.starts_with('\t') {
350 cur_inlines.push(Inline::LineBreak);
353 cur_inlines.extend(parse_inlines(line));
354 i += 1;
355 } else {
356 break;
358 }
359 }
360 items.push(ListItem {
361 content: vec![Block::Paragraph {
362 inlines: cur_inlines,
363 }],
364 checkbox: cur_checkbox,
365 });
366 Some(Ok((Block::List { list_type, items }, i)))
367}
368
369fn match_bullet(line: &str) -> Option<(ListType, Checkbox, &str)> {
372 if let Some(rest) = line.strip_prefix("- ") {
373 let (checkbox, rest) = strip_checkbox(rest);
374 return Some((ListType::Unordered, checkbox, rest));
375 }
376 let digits = line.chars().take_while(char::is_ascii_digit).count();
378 if digits > 0 {
379 if let Some(rest) = line[digits..].strip_prefix(". ") {
380 let ordinal: u64 = line[..digits].parse().unwrap_or(1);
381 let (checkbox, rest) = strip_checkbox(rest);
382 return Some((ListType::Ordered(ordinal), checkbox, rest));
383 }
384 }
385 None
386}
387
388fn strip_checkbox(s: &str) -> (Checkbox, &str) {
390 if let Some(r) = s.strip_prefix("[X] ") {
391 (Checkbox::Checked, r)
392 } else if let Some(r) = s.strip_prefix("[ ] ") {
393 (Checkbox::Unchecked, r)
394 } else {
395 (Checkbox::NoCheckbox, s)
396 }
397}
398
399fn try_parse_table(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
400 let first = lines.get(pos)?.trim();
401 if !first.starts_with('|') || !first.ends_with('|') {
402 return None;
403 }
404 let mut rows = Vec::new();
405 let mut i = pos;
406 while i < lines.len() {
407 let Some(line) = lines.get(i).map(|l| l.trim()) else {
408 break;
409 };
410 if line.is_empty() {
412 break;
413 }
414 if line.len() < 2 || !line.starts_with('|') || !line.ends_with('|') {
415 break;
416 }
417 let cells: Vec<TableCell> = line[1..line.len() - 1]
420 .split('|')
421 .map(|c| TableCell {
422 inlines: parse_inlines(c),
423 })
424 .collect();
425 rows.push(cells);
426 i += 1;
427 }
428 if rows.is_empty() {
429 return None;
430 }
431 Some(Ok((Block::Table { rows }, i)))
432}
433
434fn try_parse_planning(pos: usize, line: &str) -> Option<ParseResult<Block>> {
435 let trimmed = line.trim();
436 if !trimmed.starts_with("SCHEDULED: ")
437 && !trimmed.starts_with("DEADLINE: ")
438 && !trimmed.starts_with("CLOSED: ")
439 {
440 return None;
441 }
442
443 let mut entries = Vec::new();
444 let mut remaining = trimmed;
446 while !remaining.is_empty() {
447 if let Some(rest) = remaining.strip_prefix("SCHEDULED: ") {
448 if let Some((ts, after)) = extract_timestamp(rest) {
449 entries.push(PlanningEntry::Scheduled(Timestamp(ts)));
450 remaining = after;
451 continue;
452 }
453 }
454 if let Some(rest) = remaining.strip_prefix("DEADLINE: ") {
455 if let Some((ts, after)) = extract_timestamp(rest) {
456 entries.push(PlanningEntry::Deadline(Timestamp(ts)));
457 remaining = after;
458 continue;
459 }
460 }
461 if let Some(rest) = remaining.strip_prefix("CLOSED: ") {
462 if let Some((ts, after)) = extract_timestamp(rest) {
463 entries.push(PlanningEntry::Closed(Timestamp(ts)));
464 remaining = after;
465 continue;
466 }
467 }
468 break;
469 }
470
471 if entries.is_empty() {
472 return None;
473 }
474 Some(Ok((Block::Planning { entries }, pos + 1)))
475}
476
477fn extract_timestamp(s: &str) -> Option<(String, &str)> {
480 let s = s.trim();
481 if !s.starts_with('<') {
482 return None;
483 }
484 let close = s.find('>')?;
485 let ts = s[..=close].to_string();
486 Some((ts, s[close + 1..].trim()))
487}
488
489fn try_parse_comment(pos: usize, line: &str) -> Option<ParseResult<Block>> {
490 let trimmed = line.trim();
491 if let Some(text) = trimmed.strip_prefix("# ") {
492 Some(Ok((
493 Block::Comment {
494 text: text.to_string(),
495 },
496 pos + 1,
497 )))
498 } else {
499 None
500 }
501}
502
503fn try_parse_keyword(pos: usize, line: &str) -> Option<ParseResult<Block>> {
510 let rest = line.strip_prefix("#+")?;
511 let name_len = rest
512 .find(|c: char| c == ':' || c.is_whitespace())
513 .unwrap_or(rest.len());
514 if name_len == 0 || rest.as_bytes().get(name_len) != Some(&b':') {
515 return None;
516 }
517 let name = rest[..name_len].to_string();
518 let value = rest[name_len + 1..].to_string();
519 Some(Ok((Block::Keyword { name, value }, pos + 1)))
520}
521
522fn try_parse_horizontal_rule(pos: usize, line: &str) -> Option<ParseResult<Block>> {
523 let trimmed = line.trim();
524 if trimmed == "-----" {
525 Some(Ok((Block::HorizontalRule, pos + 1)))
526 } else {
527 None
528 }
529}
530
531fn try_parse_paragraph(lines: &[&str], pos: usize) -> Option<ParseResult<Block>> {
532 if !is_paragraph_line(lines.get(pos)?) {
533 return None;
534 }
535 let mut inlines = Vec::new();
538 let mut i = pos;
539 while let Some(&line) = lines.get(i).filter(|l| is_paragraph_line(l)) {
540 if i > pos {
541 inlines.push(Inline::LineBreak);
542 }
543 inlines.extend(parse_inlines(line));
544 i += 1;
545 }
546 Some(Ok((Block::Paragraph { inlines }, i)))
547}
548
549fn is_paragraph_line(line: &str) -> bool {
552 if line.is_empty() {
553 return false;
554 }
555 let trimmed = line.trim();
556 let stars = trimmed.chars().take_while(|c| *c == '*').count();
558 if stars > 0 && trimmed[stars..].starts_with(' ') {
559 return false;
560 }
561 if trimmed.starts_with("# ")
562 || trimmed.starts_with(":PROPERTIES:")
563 || trimmed.starts_with(":LOGBOOK:")
564 || trimmed.starts_with("#+begin_")
565 || trimmed.starts_with("SCHEDULED:")
566 || trimmed.starts_with("DEADLINE:")
567 || trimmed.starts_with("CLOSED:")
568 || trimmed == "-----"
569 || (trimmed.starts_with('|') && trimmed.ends_with('|'))
570 {
571 return false;
572 }
573 if match_bullet(line).is_some() {
576 return false;
577 }
578 if let Some(rest) = trimmed.strip_prefix("#+") {
580 let name_len = rest
581 .find(|c: char| c == ':' || c.is_whitespace())
582 .unwrap_or(rest.len());
583 if name_len > 0 && rest.as_bytes().get(name_len) == Some(&b':') {
584 return false;
585 }
586 }
587 true
588}
589
590fn parse_inlines(input: &str) -> Vec<Inline> {
592 let mut inlines = Vec::new();
593 let mut pos = 0;
594 let chars: Vec<char> = input.chars().collect();
595
596 let slice = |a: usize, b: usize| -> String {
601 chars
602 .get(a..b)
603 .expect("range derived from a verified marker position is in bounds")
604 .iter()
605 .collect()
606 };
607 let slice_from = |a: usize| -> String {
608 chars
609 .get(a..)
610 .expect("`a` never exceeds chars.len()")
611 .iter()
612 .collect()
613 };
614
615 while pos < chars.len() {
616 let Some(&c) = chars.get(pos) else { break };
617 match c {
618 '*' => {
619 if let Some(end) = find_closing(&chars, pos + 1, '*') {
620 let inner = slice(pos + 1, end);
621 inlines.push(Inline::Bold(parse_inlines(&inner)));
622 pos = end + 1;
623 } else {
624 if let Some(end) = next_marker_or_end(&chars, pos) {
626 inlines.push(Inline::Plain(slice(pos, end)));
627 pos = end;
628 } else {
629 inlines.push(Inline::Plain(slice_from(pos)));
630 pos = chars.len();
631 }
632 }
633 }
634 '/' => {
635 if let Some(end) = find_closing(&chars, pos + 1, '/') {
636 let inner = slice(pos + 1, end);
637 inlines.push(Inline::Italic(parse_inlines(&inner)));
638 pos = end + 1;
639 } else {
640 if let Some(end) = next_marker_or_end(&chars, pos) {
641 inlines.push(Inline::Plain(slice(pos, end)));
642 pos = end;
643 } else {
644 inlines.push(Inline::Plain(slice_from(pos)));
645 pos = chars.len();
646 }
647 }
648 }
649 '+' => {
650 if let Some(end) = find_closing(&chars, pos + 1, '+') {
651 let inner = slice(pos + 1, end);
652 inlines.push(Inline::Strikethrough(parse_inlines(&inner)));
653 pos = end + 1;
654 } else if let Some(end) = next_marker_or_end(&chars, pos) {
655 inlines.push(Inline::Plain(slice(pos, end)));
656 pos = end;
657 } else {
658 inlines.push(Inline::Plain(slice_from(pos)));
659 pos = chars.len();
660 }
661 }
662 '=' => {
663 if let Some(end) = find_closing(&chars, pos + 1, '=') {
664 let code = slice(pos + 1, end);
665 inlines.push(Inline::InlineCode(code));
666 pos = end + 1;
667 } else {
668 if let Some(end) = next_marker_or_end(&chars, pos) {
669 inlines.push(Inline::Plain(slice(pos, end)));
670 pos = end;
671 } else {
672 inlines.push(Inline::Plain(slice_from(pos)));
673 pos = chars.len();
674 }
675 }
676 }
677 '~' => {
678 if let Some(end) = find_closing(&chars, pos + 1, '~') {
679 let verb = slice(pos + 1, end);
680 inlines.push(Inline::Verbatim(verb));
681 pos = end + 1;
682 } else {
683 if let Some(end) = next_marker_or_end(&chars, pos) {
684 inlines.push(Inline::Plain(slice(pos, end)));
685 pos = end;
686 } else {
687 inlines.push(Inline::Plain(slice_from(pos)));
688 pos = chars.len();
689 }
690 }
691 }
692 '[' => {
693 let (inline, next) = consume_bracket(&chars, pos);
694 inlines.push(inline);
695 pos = next;
696 }
697 _ => {
698 if let Some(end) = next_marker_or_end(&chars, pos) {
699 inlines.push(Inline::Plain(slice(pos, end)));
700 pos = end;
701 } else {
702 inlines.push(Inline::Plain(slice_from(pos)));
703 pos = chars.len();
704 }
705 }
706 }
707 }
708
709 merge_adjacent_plain(&mut inlines);
711 inlines
712}
713
714fn consume_bracket(chars: &[char], pos: usize) -> (Inline, usize) {
719 let collect = |a: usize, b: usize| -> String {
723 chars
724 .get(a..b)
725 .expect("range derived from a located bracket is in bounds")
726 .iter()
727 .collect()
728 };
729 let collect_from = |a: usize| -> String {
730 chars
731 .get(a..)
732 .expect("`a` never exceeds chars.len()")
733 .iter()
734 .collect()
735 };
736 let plain_to = |end: usize| Inline::Plain(collect(pos, end));
737 let plain_rest = || Inline::Plain(collect_from(pos));
738 let literal = || match next_marker_or_end(chars, pos) {
739 Some(end) => (plain_to(end), end),
740 None => (plain_rest(), chars.len()),
741 };
742
743 if pos + 1 >= chars.len() || chars.get(pos + 1) != Some(&'[') {
745 return literal();
746 }
747 let start = pos + 2;
748 let Some(bracket_end) = chars
749 .get(start..)
750 .and_then(|rest| rest.iter().position(|&c| c == ']'))
751 .map(|p| start + p)
752 else {
753 return (plain_rest(), chars.len());
754 };
755
756 if bracket_end + 1 < chars.len() && chars.get(bracket_end + 1) == Some(&']') {
757 let target = collect(start, bracket_end);
759 return (
760 Inline::Link {
761 target,
762 description: None,
763 },
764 bracket_end + 2,
765 );
766 }
767 if bracket_end + 1 >= chars.len() || chars.get(bracket_end + 1) != Some(&'[') {
768 return literal();
770 }
771 let target: String = collect(start, bracket_end);
773 let desc_start = bracket_end + 2;
774 match chars
775 .get(desc_start..)
776 .and_then(|rest| rest.iter().position(|&c| c == ']'))
777 .map(|p| desc_start + p)
778 {
779 Some(desc_end) if desc_end + 1 < chars.len() && chars.get(desc_end + 1) == Some(&']') => {
780 let description = collect(desc_start, desc_end);
781 (
782 Inline::Link {
783 target,
784 description: Some(description),
785 },
786 desc_end + 2,
787 )
788 }
789 Some(desc_end) => (plain_to(desc_end + 1), desc_end + 1),
791 None => (plain_rest(), chars.len()),
792 }
793}
794
795fn find_closing(chars: &[char], start: usize, marker: char) -> Option<usize> {
796 for i in start..chars.len() {
797 let Some(&c) = chars.get(i) else { break };
798 if c == marker && (i + 1 == chars.len() || chars.get(i + 1) != Some(&marker)) {
799 return Some(i);
800 }
801 }
802 None
803}
804
805fn next_marker_or_end(chars: &[char], pos: usize) -> Option<usize> {
806 for i in pos..chars.len() {
807 let Some(&c) = chars.get(i) else { break };
808 if c == '*' || c == '/' || c == '+' || c == '=' || c == '~' || c == '[' {
809 if i == pos {
810 continue;
812 }
813 return Some(i);
814 }
815 if c == '[' && i + 1 < chars.len() && chars.get(i + 1) == Some(&'[') {
816 if i == pos {
817 continue;
818 }
819 return Some(i);
820 }
821 }
822 None
823}
824
825fn merge_adjacent_plain(inlines: &mut Vec<Inline>) {
826 let mut i = 0;
827 while i + 1 < inlines.len() {
828 if let (Some(Inline::Plain(a)), Some(Inline::Plain(b))) =
829 (inlines.get(i), inlines.get(i + 1))
830 {
831 let merged = format!("{a}{b}");
832 if let Some(slot) = inlines.get_mut(i) {
833 *slot = Inline::Plain(merged);
834 }
835 inlines.remove(i + 1);
836 } else {
837 i += 1;
838 }
839 }
840}
841
842#[cfg(test)]
843mod tests {
844 use super::*;
845
846 #[test]
847 fn parse_heading_level_1() {
848 let doc = parse_document("* Hello\n").unwrap();
849 assert_eq!(
850 doc.blocks[0],
851 Block::Heading {
852 level: 1,
853 title: Title("Hello".into()),
854 tags: vec![],
855 children: vec![]
856 }
857 );
858 }
859
860 #[test]
861 fn parse_heading_with_tags() {
862 let doc = parse_document("** Task :rust:kb:\n").unwrap();
863 if let Block::Heading {
864 level,
865 title,
866 tags,
867 children: _,
868 } = &doc.blocks[0]
869 {
870 assert_eq!(*level, 2);
871 assert_eq!(title.0, "Task");
872 assert_eq!(tags.len(), 2);
873 assert_eq!(tags[0].0, "rust");
874 assert_eq!(tags[1].0, "kb");
875 } else {
876 panic!("expected heading");
877 }
878 }
879
880 #[test]
881 fn parse_paragraph() {
882 let doc = parse_document("some text\n").unwrap();
883 assert_eq!(
884 doc.blocks[0],
885 Block::Paragraph {
886 inlines: vec![Inline::Plain("some text".into())]
887 }
888 );
889 }
890
891 #[test]
892 fn parse_bold() {
893 let doc = parse_document("*bold*\n").unwrap();
894 if let Block::Paragraph { inlines } = &doc.blocks[0] {
895 assert_eq!(inlines.len(), 1);
896 assert_eq!(inlines[0], Inline::Bold(vec![Inline::Plain("bold".into())]));
897 } else {
898 panic!("expected paragraph");
899 }
900 }
901
902 #[test]
903 fn parse_italic() {
904 let doc = parse_document("/italic/\n").unwrap();
905 if let Block::Paragraph { inlines } = &doc.blocks[0] {
906 assert_eq!(
907 inlines[0],
908 Inline::Italic(vec![Inline::Plain("italic".into())])
909 );
910 } else {
911 panic!("expected paragraph");
912 }
913 }
914
915 #[test]
916 fn parse_strikethrough() {
917 let doc = parse_document("+struck+\n").unwrap();
918 if let Block::Paragraph { inlines } = &doc.blocks[0] {
919 assert_eq!(
920 inlines[0],
921 Inline::Strikethrough(vec![Inline::Plain("struck".into())])
922 );
923 } else {
924 panic!("expected paragraph");
925 }
926 }
927
928 #[test]
929 fn parse_link_no_description() {
930 let doc = parse_document("[[https://example.com]]\n").unwrap();
931 if let Block::Paragraph { inlines } = &doc.blocks[0] {
932 assert_eq!(
933 inlines[0],
934 Inline::Link {
935 target: "https://example.com".into(),
936 description: None,
937 }
938 );
939 } else {
940 panic!("expected paragraph");
941 }
942 }
943
944 #[test]
945 fn parse_link_with_description() {
946 let doc = parse_document("[[https://example.com][example]]\n").unwrap();
947 if let Block::Paragraph { inlines } = &doc.blocks[0] {
948 assert_eq!(
949 inlines[0],
950 Inline::Link {
951 target: "https://example.com".into(),
952 description: Some("example".into()),
953 }
954 );
955 } else {
956 panic!("expected paragraph");
957 }
958 }
959
960 #[test]
961 fn parse_src_block() {
962 let input = "#+begin_src rust\nfn main() {}\n#+end_src\n";
963 let doc = parse_document(input).unwrap();
964 if let Block::SrcBlock { language, content } = &doc.blocks[0] {
965 assert_eq!(language, "rust");
966 assert_eq!(content, "fn main() {}\n");
967 } else {
968 panic!("expected src block");
969 }
970 }
971
972 #[test]
973 fn parse_example_block() {
974 let input = "#+begin_example\n$ ls\nfoo\n#+end_example\n";
975 let doc = parse_document(input).unwrap();
976 assert_eq!(
977 doc.blocks[0],
978 Block::ExampleBlock {
979 content: "$ ls\nfoo\n".into(),
980 }
981 );
982 }
983
984 #[test]
985 fn parse_property_drawer() {
986 let input = ":PROPERTIES:\n:ID: abc-123\n:END:\n";
987 let doc = parse_document(input).unwrap();
988 if let Block::PropertyDrawer { entries } = &doc.blocks[0] {
989 assert_eq!(entries.len(), 1);
990 assert_eq!(entries[0].0, "ID");
991 assert_eq!(entries[0].1, " abc-123");
993 } else {
994 panic!("expected property drawer");
995 }
996 }
997
998 #[test]
999 fn parse_list_unordered() {
1000 let input = "- one\n- two\n";
1001 let doc = parse_document(input).unwrap();
1002 if let Block::List { list_type, items } = &doc.blocks[0] {
1003 assert_eq!(*list_type, ListType::Unordered);
1004 assert_eq!(items.len(), 2);
1005 } else {
1006 panic!("expected list");
1007 }
1008 }
1009
1010 #[test]
1011 fn parse_comment() {
1012 let doc = parse_document("# a comment\n").unwrap();
1013 assert_eq!(
1014 doc.blocks[0],
1015 Block::Comment {
1016 text: "a comment".into()
1017 }
1018 );
1019 }
1020
1021 #[test]
1022 fn parse_horizontal_rule() {
1023 let doc = parse_document("-----\n").unwrap();
1024 assert_eq!(doc.blocks[0], Block::HorizontalRule);
1025 }
1026
1027 #[test]
1028 fn parse_single_bracket_run_keeps_trailing_text() {
1029 let doc = parse_document("see [his] notes here\n").unwrap();
1032 assert_eq!(
1033 doc.blocks[0],
1034 Block::Paragraph {
1035 inlines: vec![Inline::Plain("see [his] notes here".into())],
1036 }
1037 );
1038 }
1039
1040 #[test]
1041 fn residue_empty_when_every_line_claimed() {
1042 let input = "* Heading\n\nA paragraph.\n";
1043 let (_, residue) = parse_document_with_residue(input).unwrap();
1044 assert!(
1045 residue.is_empty(),
1046 "no line should be unclaimed: {residue:?}"
1047 );
1048 }
1049
1050 #[test]
1051 fn residue_records_unparseable_block_marker() {
1052 let (_, residue) = parse_document_with_residue("#+begin_verse\n").unwrap();
1055 assert_eq!(residue, vec!["#+begin_verse".to_string()]);
1056 }
1057
1058 #[test]
1059 fn residue_records_unclaimed_line_under_heading() {
1060 let input = "* Heading\n#+begin_verse\n";
1061 let (_, residue) = parse_document_with_residue(input).unwrap();
1062 assert_eq!(residue, vec!["#+begin_verse".to_string()]);
1063 }
1064
1065 #[test]
1066 fn residue_excludes_blank_lines() {
1067 let (_, residue) = parse_document_with_residue("para\n\n\n").unwrap();
1068 assert!(
1069 residue.is_empty(),
1070 "blank lines are not residue: {residue:?}"
1071 );
1072 }
1073}