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