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}