basalt_tui/note_editor/markdown_parser.rs
1//! A Markdown parser that transforms Markdown input into a custom abstract syntax tree (AST)
2//! intended to be rendered with [basalt](https://github.com/erikjuhani/basalt)—a TUI application
3//! for Obsidian.
4//!
5//! This module provides a [`Parser`] type, which processes raw Markdown input into a [`Vec`] of
6//! [`Node`]s. These [`Node`]s represent semantic elements such as headings, paragraphs, block
7//! quotes, and code blocks.
8//!
9//! The parser is built on top of [`pulldown_cmark`].
10//!
11//! ## Simple usage
12//!
13//! At the simplest level, you can parse a Markdown string by calling the [`from_str`] function:
14//!
15//! ```
16//! use basalt_core::markdown::{from_str, Range, Node, MarkdownNode, HeadingLevel, Text};
17//!
18//! let markdown = "# My Heading\n\nSome text.";
19//! let nodes = from_str(markdown);
20//!
21//! assert_eq!(nodes, vec![
22//! Node {
23//! markdown_node: MarkdownNode::Heading {
24//! level: HeadingLevel::H1,
25//! text: Text::from("My Heading"),
26//! },
27//! source_range: Range { start: 0, end: 13 },
28//! },
29//! Node {
30//! markdown_node: MarkdownNode::Paragraph {
31//! text: Text::from("Some text."),
32//! },
33//! source_range: Range { start: 14, end: 24 }
34//! },
35//! ])
36//! ```
37//!
38//! ## Implementation details
39//!
40//! The [`Parser`] processes [`pulldown_cmark::Event`]s one by one, building up the current
41//! [`Node`] in `current_node`. When an event indicates the start of a new structure (e.g.,
42//! `Event::Start(Tag::Heading {..})`), the [`Parser`] pushes or replaces the current node
43//! with a new one. When an event indicates the end of that structure, the node is finalized
44//! and pushed into [`Parser::output`].
45//!
46//! Unrecognized events (such as [`InlineHtml`](pulldown_cmark::Event::InlineHtml)) are simply
47//! ignored for the time being.
48//!
49//! ## Not yet implemented
50//!
51//! - Handling of inline HTML, math blocks, etc.
52//! - Tracking code block language (`lang`) properly (currently set to [`None`]).
53use std::{iter::Peekable, vec::IntoIter};
54
55use pulldown_cmark::{Event, Options, Tag, TagEnd};
56
57/// A style that can be applied to [`TextNode`] (code, emphasis, strikethrough, strong).
58#[derive(Clone, Debug, PartialEq)]
59pub enum Style {
60 /// Inline code style (e.g. `code`).
61 Code,
62 // TODO: Additional style variants
63 //
64 // Italic/emphasis style (e.g. `*emphasis*` or `_emphasis_`).
65 // Emphasis,
66 // Strikethrough style (e.g. `~~strikethrough~~`).
67 // Strikethrough,
68 // Bold/strong style (e.g. `**strong**`).
69 // Strong,
70}
71
72/// Represents the variant of a list or task item (checked, unchecked, etc.).
73#[derive(Clone, Debug, PartialEq)]
74pub enum ItemKind {
75 // An ordered list item (e.g., `1. item`), storing the numeric index.
76 Ordered(u64),
77 /// An unordered list item (e.g., `- item`).
78 Unordered,
79}
80
81/// Represents the variant of a list or task item (checked, unchecked, etc.).
82#[derive(Clone, Debug, PartialEq)]
83pub enum TaskListItemKind {
84 /// A checkbox item that is marked as done using `- [x]`.
85 Checked,
86 /// A checkbox item that is unchecked using `- [ ]`.
87 Unchecked,
88 /// A checkbox item that is checked, but not explicitly recognized as
89 /// `Checked` (e.g., `- [?]`).
90 LooselyChecked,
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Eq, Ord)]
94#[allow(missing_docs)]
95pub enum HeadingLevel {
96 H1 = 1,
97 H2,
98 H3,
99 H4,
100 H5,
101 H6,
102}
103
104impl From<pulldown_cmark::HeadingLevel> for HeadingLevel {
105 fn from(value: pulldown_cmark::HeadingLevel) -> Self {
106 match value {
107 pulldown_cmark::HeadingLevel::H1 => HeadingLevel::H1,
108 pulldown_cmark::HeadingLevel::H2 => HeadingLevel::H2,
109 pulldown_cmark::HeadingLevel::H3 => HeadingLevel::H3,
110 pulldown_cmark::HeadingLevel::H4 => HeadingLevel::H4,
111 pulldown_cmark::HeadingLevel::H5 => HeadingLevel::H5,
112 pulldown_cmark::HeadingLevel::H6 => HeadingLevel::H6,
113 }
114 }
115}
116
117/// Represents specialized block quote kind variants (tip, note, warning, etc.).
118///
119/// Currently, the underlying [`pulldown_cmark`] parser distinguishes these via syntax like `">
120/// [!NOTE] Some note"`.
121#[derive(Clone, Debug, PartialEq)]
122#[allow(missing_docs)]
123pub enum BlockQuoteKind {
124 Note,
125 Tip,
126 Important,
127 Warning,
128 Caution,
129}
130
131impl From<pulldown_cmark::BlockQuoteKind> for BlockQuoteKind {
132 fn from(value: pulldown_cmark::BlockQuoteKind) -> Self {
133 match value {
134 pulldown_cmark::BlockQuoteKind::Tip => BlockQuoteKind::Tip,
135 pulldown_cmark::BlockQuoteKind::Note => BlockQuoteKind::Note,
136 pulldown_cmark::BlockQuoteKind::Warning => BlockQuoteKind::Warning,
137 pulldown_cmark::BlockQuoteKind::Caution => BlockQuoteKind::Caution,
138 pulldown_cmark::BlockQuoteKind::Important => BlockQuoteKind::Important,
139 }
140 }
141}
142
143/// Denotes whether a list is ordered or unordered.
144#[derive(Clone, Debug, PartialEq)]
145pub enum ListKind {
146 /// An ordered list item (e.g., `1. item`), storing the numeric index.
147 Ordered(u64),
148 /// An unordered list item (e.g., `- item`).
149 Unordered,
150}
151
152/// A single unit of text that is optionally styled (e.g., code).
153///
154/// [`TextNode`] can be any combination of sentence, words or characters.
155///
156/// Usually styled text will be contained in a single [`TextNode`] with the given [`Style`]
157/// property.
158#[derive(Clone, Debug, PartialEq, Default)]
159pub struct TextNode {
160 /// The literal text content.
161 pub content: String,
162 /// Optional inline style of the text.
163 pub style: Option<Style>,
164}
165
166impl From<&str> for TextNode {
167 fn from(value: &str) -> Self {
168 value.to_string().into()
169 }
170}
171
172impl From<String> for TextNode {
173 fn from(value: String) -> Self {
174 Self {
175 content: value.replace("\t", " "),
176 ..Default::default()
177 }
178 }
179}
180
181impl TextNode {
182 /// Creates a new [`TextNode`] from `content` and optional [`Style`].
183 pub fn new(content: String, style: Option<Style>) -> Self {
184 Self { content, style }
185 }
186}
187
188/// A wrapper type holding a list of [`TextNode`]s.
189#[derive(Clone, Debug, PartialEq, Default)]
190pub struct Text(Vec<TextNode>);
191
192impl From<&Text> for String {
193 fn from(value: &Text) -> Self {
194 value.clone().into_iter().map(|node| node.content).collect()
195 }
196}
197
198impl From<Text> for String {
199 fn from(value: Text) -> Self {
200 value
201 .into_iter()
202 .map(|node| node.content)
203 .collect::<String>()
204 }
205}
206
207impl From<&str> for Text {
208 fn from(value: &str) -> Self {
209 TextNode::from(value).into()
210 }
211}
212
213impl From<String> for Text {
214 fn from(value: String) -> Self {
215 TextNode::from(value).into()
216 }
217}
218
219impl From<TextNode> for Text {
220 fn from(value: TextNode) -> Self {
221 Self([value].to_vec())
222 }
223}
224
225impl From<Vec<TextNode>> for Text {
226 fn from(value: Vec<TextNode>) -> Self {
227 Self(value)
228 }
229}
230
231impl From<&[TextNode]> for Text {
232 fn from(value: &[TextNode]) -> Self {
233 Self(value.to_vec())
234 }
235}
236
237impl IntoIterator for Text {
238 type Item = TextNode;
239 type IntoIter = IntoIter<Self::Item>;
240 fn into_iter(self) -> Self::IntoIter {
241 self.0.into_iter()
242 }
243}
244
245impl Text {
246 /// Appends a [`TextNode`] to the inner text list.
247 fn push(&mut self, node: TextNode) {
248 self.0.push(node);
249 }
250}
251
252/// A [`std::ops::Range`] type for depicting range in [`crate::markdown`].
253///
254/// # Examples
255///
256/// ```
257/// use basalt_core::markdown::{Node, MarkdownNode, Range, Text};
258///
259/// let node = Node {
260/// markdown_node: MarkdownNode::Paragraph {
261/// text: Text::default(),
262/// },
263/// source_range: Range::default(),
264/// };
265/// ```
266pub type Range<Idx> = std::ops::Range<Idx>;
267
268/// A node in the Markdown AST.
269///
270/// Each `Node` contains a [`MarkdownNode`] variant representing a specific kind of Markdown
271/// element (paragraph, heading, code block, etc.), along with a `source_range` indicating where in
272/// the source text this node occurs.
273///
274/// # Examples
275///
276/// ```
277/// use basalt_core::markdown::{Node, MarkdownNode, Range, Text};
278///
279/// let node = Node::new(
280/// MarkdownNode::Paragraph {
281/// text: Text::default(),
282/// },
283/// 0..10,
284/// );
285///
286/// assert_eq!(node.markdown_node, MarkdownNode::Paragraph { text: Text::default() });
287/// assert_eq!(node.source_range, Range { start: 0, end: 10 });
288/// ```
289#[derive(Clone, Debug, PartialEq)]
290pub struct Node {
291 /// The specific Markdown node represented by this node.
292 pub markdown_node: MarkdownNode,
293
294 /// The range in the original source text that this node covers.
295 pub source_range: Range<usize>,
296}
297
298impl Node {
299 /// Creates a new `Node` from the provided [`MarkdownNode`] and source range.
300 pub fn new(markdown_node: MarkdownNode, source_range: Range<usize>) -> Self {
301 Self {
302 markdown_node,
303 source_range,
304 }
305 }
306
307 /// Pushes a [`TextNode`] into the markdown node, if it contains a text buffer.
308 ///
309 /// If the markdown node is a [`MarkdownNode::BlockQuote`], the [`TextNode`] will be pushed
310 /// into the last child [`Node`], if any.
311 /// ```
312 pub(crate) fn push_text_node(&mut self, node: TextNode) {
313 match &mut self.markdown_node {
314 MarkdownNode::Paragraph { text, .. }
315 | MarkdownNode::Heading { text, .. }
316 | MarkdownNode::CodeBlock { text, .. }
317 | MarkdownNode::TaskListItem { text, .. }
318 | MarkdownNode::Item { text, .. } => text.push(node),
319 MarkdownNode::List { nodes, .. } | MarkdownNode::BlockQuote { nodes, .. } => {
320 if let Some(last_node) = nodes.last_mut() {
321 last_node.push_text_node(node);
322 }
323 }
324 }
325 }
326}
327
328/// The Markdown AST node enumeration.
329#[derive(Clone, Debug, PartialEq)]
330#[allow(missing_docs)]
331pub enum MarkdownNode {
332 /// A heading node that represents different heading levels.
333 ///
334 /// The level is controlled with the [`HeadingLevel`] definition.
335 Heading {
336 level: HeadingLevel,
337 text: Text,
338 },
339
340 /// A paragraph
341 Paragraph {
342 text: Text,
343 },
344
345 /// A block quote node that represents different quote block variants including callout blocks.
346 ///
347 /// The variant is controlled with the [`BlockQuoteKind`] definition. When [`BlockQuoteKind`]
348 /// is [`None`] the block quote should be interpreted as a regular block quote:
349 /// `"> Block quote"`.
350 BlockQuote {
351 kind: Option<BlockQuoteKind>,
352 nodes: Vec<Node>,
353 },
354
355 /// A fenced code block, optionally with a language identifier.
356 CodeBlock {
357 lang: Option<String>,
358 text: Text,
359 },
360
361 /// A block for list items.
362 ///
363 /// The list variant is controlled with the [`ListKind`] definition.
364 List {
365 kind: ListKind,
366 nodes: Vec<Node>,
367 },
368
369 /// A list item node that represents different list item variants including task items.
370 ///
371 /// The variant is controlled with the [`ItemKind`] definition. When [`ItemKind`] is [`None`]
372 /// the item should be interpreted as unordered list item: `"- Item"`.
373 Item {
374 text: Text,
375 },
376
377 TaskListItem {
378 kind: TaskListItemKind,
379 text: Text,
380 },
381}
382
383/// Returns `true` if the [`Tag`] should be closed upon encountering the given [`TagEnd`].
384fn matches_tag_end(tag: &Tag, tag_end: &TagEnd) -> bool {
385 matches!(
386 (tag, tag_end),
387 (Tag::Paragraph { .. }, TagEnd::Paragraph)
388 | (Tag::Heading { .. }, TagEnd::Heading(..))
389 | (Tag::BlockQuote { .. }, TagEnd::BlockQuote(..))
390 | (Tag::CodeBlock { .. }, TagEnd::CodeBlock)
391 | (Tag::List { .. }, TagEnd::List(..))
392 | (Tag::Item { .. }, TagEnd::Item)
393 )
394}
395
396/// Parses the given Markdown input into a list of [`Node`]s.
397///
398/// This is a convenience function for constructing a [`Parser`] and calling [`Parser::parse`].
399///
400/// # Examples
401///
402/// ```
403/// use basalt_core::markdown::{from_str, Range, Node, MarkdownNode, HeadingLevel, Text};
404///
405/// let markdown = "# My Heading\n\nSome text.";
406/// let nodes = from_str(markdown);
407///
408/// assert_eq!(nodes, vec![
409/// Node {
410/// markdown_node: MarkdownNode::Heading {
411/// level: HeadingLevel::H1,
412/// text: Text::from("My Heading"),
413/// },
414/// source_range: Range { start: 0, end: 13 },
415/// },
416/// Node {
417/// markdown_node: MarkdownNode::Paragraph {
418/// text: Text::from("Some text."),
419/// },
420/// source_range: Range { start: 14, end: 24 },
421/// },
422/// ])
423/// ```
424pub fn from_str(text: &str) -> Vec<Node> {
425 Parser::new(text).parse()
426}
427
428/// A parser that consumes [`pulldown_cmark::Event`]s and produces a [`Vec`] of [`Node`].
429///
430/// # Examples
431///
432/// ```
433/// use basalt_core::markdown::{Parser, Range, Node, MarkdownNode, HeadingLevel, Text};
434///
435/// let markdown = "# My Heading\n\nSome text.";
436/// let parser = Parser::new(markdown);
437/// let nodes = parser.parse();
438///
439/// assert_eq!(nodes, vec![
440/// Node {
441/// markdown_node: MarkdownNode::Heading {
442/// level: HeadingLevel::H1,
443/// text: Text::from("My Heading"),
444/// },
445/// source_range: Range { start: 0, end: 13 },
446/// },
447/// Node {
448/// markdown_node: MarkdownNode::Paragraph {
449/// text: Text::from("Some text."),
450/// },
451/// source_range: Range { start: 14, end: 24 },
452/// },
453/// ])
454/// ```
455pub struct Parser<'a>(pulldown_cmark::TextMergeWithOffset<'a, pulldown_cmark::OffsetIter<'a>>);
456
457impl<'a> Iterator for Parser<'a> {
458 type Item = (Event<'a>, Range<usize>);
459 fn next(&mut self) -> Option<Self::Item> {
460 self.0.next()
461 }
462}
463
464impl<'a> Parser<'a> {
465 /// Creates a new [`Parser`] from a Markdown input string.
466 ///
467 /// The parser uses [`pulldown_cmark::Parser::new_ext`] with [`Options::all()`] and
468 /// [`pulldown_cmark::TextMergeWithOffset`] internally.
469 ///
470 /// The offset is required to know where the node appears in the provided source text.
471 pub fn new(text: &'a str) -> Self {
472 let parser = pulldown_cmark::TextMergeWithOffset::new(
473 pulldown_cmark::Parser::new_ext(text, Options::all()).into_offset_iter(),
474 );
475
476 Self(parser)
477 }
478
479 fn parse_tag(
480 tag: Tag,
481 events: &mut Peekable<Parser<'a>>,
482 source_range: Range<usize>,
483 ) -> Option<Node> {
484 match tag {
485 Tag::BlockQuote(kind) => Some(Node::new(
486 MarkdownNode::BlockQuote {
487 kind: kind.map(|kind| kind.into()),
488 nodes: Parser::parse_events(events, Some(tag)),
489 },
490 source_range,
491 )),
492 Tag::List(start) => Some(Node::new(
493 MarkdownNode::List {
494 kind: start.map(ListKind::Ordered).unwrap_or(ListKind::Unordered),
495 nodes: Parser::parse_events(events, Some(tag)),
496 },
497 source_range,
498 )),
499 Tag::Heading { level, .. } => Some(Node::new(
500 MarkdownNode::Heading {
501 level: level.into(),
502 text: Text::default(),
503 },
504 source_range,
505 )),
506 Tag::CodeBlock(_) => Some(Node::new(
507 MarkdownNode::CodeBlock {
508 lang: None,
509 text: Text::default(),
510 },
511 source_range,
512 )),
513 Tag::Paragraph => Some(Node::new(
514 MarkdownNode::Paragraph {
515 text: Text::default(),
516 },
517 source_range,
518 )),
519 Tag::Item => Some(Node::new(
520 MarkdownNode::Item {
521 text: Text::default(),
522 },
523 source_range,
524 )),
525 // NOTE: After all tags have been implemented the Option wrapper can be removed.
526 //
527 // Missing tags:
528 //
529 // | Tag::HtmlBlock
530 // | Tag::FootnoteDefinition(_)
531 // | Tag::Table(_)
532 // | Tag::TableHead
533 // | Tag::TableRow
534 // | Tag::TableCell
535 // | Tag::Emphasis
536 // | Tag::Strong
537 // | Tag::Strikethrough
538 // | Tag::Link { .. }
539 // | Tag::Image { .. }
540 // | Tag::MetadataBlock(_)
541 // | Tag::DefinitionList
542 // | Tag::DefinitionListTitle
543 // | Tag::Subscript
544 // | Tag::Superscript
545 // | Tag::DefinitionListDefinition
546 _ => None,
547 }
548 }
549
550 fn parse_events(events: &mut Peekable<Parser<'a>>, current_tag: Option<Tag>) -> Vec<Node> {
551 let mut nodes = Vec::new();
552
553 while let Some((event, range)) = events.peek().cloned() {
554 events.next();
555 match event {
556 Event::Start(tag) => {
557 if let Some(node) = Parser::parse_tag(tag, events, range) {
558 nodes.push(node);
559 }
560 }
561 Event::End(tag_end) => {
562 if let Some(ref tag) = current_tag {
563 if matches_tag_end(tag, &tag_end) {
564 return nodes;
565 }
566 }
567 }
568 Event::Text(text) => {
569 if let Some(node) = nodes.last_mut() {
570 // Matches any character in place of x. `- [x]` to match for loosely
571 // checked task items.
572 //
573 // There is no support in pulldown-cmark for this feature so this needs
574 // to be manually parsed from the text event.
575 //
576 // We read the first 4 character bytes that needs to match `[x] `
577 // exactly, x being any character.
578 let is_loosely_checked_task = text
579 .get(0..4)
580 .map(|str| str.as_bytes())
581 .map(|chars| matches!(chars, &[b'[', _, b']', b' ']))
582 .unwrap_or_default();
583
584 if is_loosely_checked_task {
585 let source_range = node.clone().source_range;
586 *node = Node::new(
587 MarkdownNode::TaskListItem {
588 kind: TaskListItemKind::LooselyChecked,
589 text: Text::from(text.get(4..).unwrap_or_default()),
590 },
591 source_range,
592 );
593 } else {
594 node.push_text_node(text.to_string().into())
595 }
596 }
597 }
598 Event::Code(text) => {
599 if let Some(node) = nodes.last_mut() {
600 node.push_text_node(TextNode::new(text.to_string(), Some(Style::Code)))
601 }
602 }
603 Event::TaskListMarker(checked) => {
604 if let Some(node) = nodes.last_mut() {
605 let source_range = node.clone().source_range;
606
607 if checked {
608 *node = Node::new(
609 MarkdownNode::TaskListItem {
610 kind: TaskListItemKind::Checked,
611 text: Text::default(),
612 },
613 source_range,
614 );
615 } else {
616 *node = Node::new(
617 MarkdownNode::TaskListItem {
618 kind: TaskListItemKind::Unchecked,
619 text: Text::default(),
620 },
621 source_range,
622 );
623 }
624 }
625 }
626 // Missing events:
627 //
628 // | Event::InlineMath(_)
629 // | Event::DisplayMath(_)
630 // | Event::Html(_)
631 // | Event::InlineHtml(_)
632 // | Event::SoftBreak
633 // | Event::HardBreak
634 // | Event::Rule
635 // | Event::FootnoteReference(_)
636 _ => {}
637 }
638 }
639
640 nodes
641 }
642
643 /// Consumes the parser, processing all remaining events from the stream into a list of
644 /// [`Node`]s.
645 ///
646 /// # Examples
647 ///
648 /// ```
649 /// # use basalt_core::markdown::{Parser, Node, MarkdownNode, Range, Text};
650 /// let parser = Parser::new("Hello world");
651 ///
652 /// let nodes = parser.parse();
653 ///
654 /// assert_eq!(nodes, vec![
655 /// Node {
656 /// markdown_node: MarkdownNode::Paragraph {
657 /// text: Text::from("Hello world"),
658 /// },
659 /// source_range: Range { start: 0, end: 11 },
660 /// },
661 /// ]);
662 /// ```
663 pub fn parse(self) -> Vec<Node> {
664 Parser::parse_events(&mut self.peekable(), None)
665 }
666}
667
668#[cfg(test)]
669mod tests {
670 use indoc::indoc;
671
672 fn p(str: &str, range: Range<usize>) -> Node {
673 Node::new(MarkdownNode::Paragraph { text: str.into() }, range)
674 }
675
676 fn blockquote(nodes: Vec<Node>, range: Range<usize>) -> Node {
677 Node::new(MarkdownNode::BlockQuote { kind: None, nodes }, range)
678 }
679
680 fn list(kind: ListKind, nodes: Vec<Node>, range: Range<usize>) -> Node {
681 Node::new(MarkdownNode::List { kind, nodes }, range)
682 }
683
684 fn item(str: &str, range: Range<usize>) -> Node {
685 Node::new(MarkdownNode::Item { text: str.into() }, range)
686 }
687
688 fn unchecked_task(str: &str, range: Range<usize>) -> Node {
689 Node::new(
690 MarkdownNode::TaskListItem {
691 kind: TaskListItemKind::Unchecked,
692 text: str.into(),
693 },
694 range,
695 )
696 }
697
698 fn checked_task(str: &str, range: Range<usize>) -> Node {
699 Node::new(
700 MarkdownNode::TaskListItem {
701 kind: TaskListItemKind::Checked,
702 text: str.into(),
703 },
704 range,
705 )
706 }
707
708 fn loosely_checked_task(str: &str, range: Range<usize>) -> Node {
709 Node::new(
710 MarkdownNode::TaskListItem {
711 kind: TaskListItemKind::LooselyChecked,
712 text: str.into(),
713 },
714 range,
715 )
716 }
717
718 fn heading(level: HeadingLevel, str: &str, range: Range<usize>) -> Node {
719 Node::new(
720 MarkdownNode::Heading {
721 level,
722 text: str.into(),
723 },
724 range,
725 )
726 }
727
728 fn h1(str: &str, range: Range<usize>) -> Node {
729 heading(HeadingLevel::H1, str, range)
730 }
731
732 fn h2(str: &str, range: Range<usize>) -> Node {
733 heading(HeadingLevel::H2, str, range)
734 }
735
736 fn h3(str: &str, range: Range<usize>) -> Node {
737 heading(HeadingLevel::H3, str, range)
738 }
739
740 fn h4(str: &str, range: Range<usize>) -> Node {
741 heading(HeadingLevel::H4, str, range)
742 }
743
744 fn h5(str: &str, range: Range<usize>) -> Node {
745 heading(HeadingLevel::H5, str, range)
746 }
747
748 fn h6(str: &str, range: Range<usize>) -> Node {
749 heading(HeadingLevel::H6, str, range)
750 }
751
752 use super::*;
753
754 #[test]
755 fn test_parse() {
756 let tests = [
757 (
758 indoc! {r#"# Heading 1
759
760 ## Heading 2
761
762 ### Heading 3
763
764 #### Heading 4
765
766 ##### Heading 5
767
768 ###### Heading 6
769 "#},
770 vec![
771 h1("Heading 1", 0..12),
772 h2("Heading 2", 13..26),
773 h3("Heading 3", 27..41),
774 h4("Heading 4", 42..57),
775 h5("Heading 5", 58..74),
776 h6("Heading 6", 75..92),
777 ],
778 ),
779 // // TODO: Implement correct test case when `- [?] ` task item syntax is supported
780 // // Now we interpret it as a regular item
781 (
782 indoc! { r#"- [ ] Task
783 - [x] Completed task
784 - [?] Completed task
785 - [-] Completed task
786 "#},
787 vec![list(
788 ListKind::Unordered,
789 vec![
790 unchecked_task("Task", 0..11),
791 checked_task("Completed task", 11..32),
792 loosely_checked_task("Completed task", 32..53),
793 loosely_checked_task("Completed task", 53..74),
794 ],
795 0..74,
796 )],
797 ),
798 (
799 indoc! {r#"You _can_ quote text by adding a `>` symbols before the text.
800 > Human beings face ever more complex and urgent problems, and their effectiveness in dealing with these problems is a matter that is critical to the stability and continued progress of society.
801 > > > Deep Quote
802 >
803 > - Doug Engelbart, 1961
804 "#},
805 vec![
806 Node::new(MarkdownNode::Paragraph {
807 text: vec![
808 TextNode::new("You ".into(), None),
809 TextNode::new("can".into(), None),
810 TextNode::new(" quote text by adding a ".into(), None),
811 TextNode::new(">".into(), Some(Style::Code)),
812 TextNode::new(" symbols before the text.".into(), None),
813 ]
814 .into(),
815 }, 0..62),
816 blockquote(
817 vec![
818 p("Human beings face ever more complex and urgent problems, and their effectiveness in dealing with these problems is a matter that is critical to the stability and continued progress of society.", 64..257),
819 blockquote(
820 vec![blockquote(vec![p("Deep Quote", 263..274)], 261..274)],
821 259..274,
822 ),
823 list(
824 ListKind::Unordered,
825 vec![item("Doug Engelbart, 1961", 278..301)],
826 278..301,
827 ),
828 ],
829 62..301,
830 ),
831 ],
832 ),
833 ];
834
835 tests
836 .iter()
837 .for_each(|test| assert_eq!(from_str(test.0), test.1));
838 }
839}