tui_markdown/
lib.rs

1//! A simple markdown renderer widget for Ratatui.
2//!
3//! This module provides a simple markdown renderer widget for Ratatui. It uses the `pulldown-cmark`
4//! crate to parse markdown and convert it to a `Text` widget. The `Text` widget can then be
5//! rendered to the terminal using the 'Ratatui' library.
6#![cfg_attr(feature = "document-features", doc = "\n# Features")]
7#![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
8//! # Example
9//!
10//! ~~~
11//! use ratatui::text::Text;
12//! use tui_markdown::from_str;
13//!
14//! # fn draw(frame: &mut ratatui::Frame) {
15//! let markdown = r#"
16//! This is a simple markdown renderer for Ratatui.
17//!
18//! - List item 1
19//! - List item 2
20//!
21//! ```rust
22//! fn main() {
23//!     println!("Hello, world!");
24//! }
25//! ```
26//! "#;
27//!
28//! let text = from_str(markdown);
29//! frame.render_widget(text, frame.area());
30//! # }
31//! ~~~
32
33use std::sync::LazyLock;
34use std::vec;
35
36#[cfg(feature = "highlight-code")]
37use ansi_to_tui::IntoText;
38use itertools::{Itertools, Position};
39use pulldown_cmark::{
40    BlockQuoteKind, CodeBlockKind, CowStr, Event, HeadingLevel, Options as ParseOptions, Parser,
41    Tag, TagEnd,
42};
43use ratatui_core::style::{Style, Stylize};
44use ratatui_core::text::{Line, Span, Text};
45#[cfg(feature = "highlight-code")]
46use syntect::{
47    easy::HighlightLines,
48    highlighting::ThemeSet,
49    parsing::SyntaxSet,
50    util::{as_24_bit_terminal_escaped, LinesWithEndings},
51};
52use tracing::{debug, instrument, warn};
53
54pub use crate::options::Options;
55pub use crate::style_sheet::{DefaultStyleSheet, StyleSheet};
56
57mod options;
58mod style_sheet;
59
60/// Render Markdown `input` into a [`ratatui::text::Text`] using the default [`Options`].
61///
62/// This is a convenience function that uses the default options, which are defined in
63/// [`Options::default`]. It is suitable for most use cases where you want to render Markdown
64pub fn from_str(input: &str) -> Text<'_> {
65    from_str_with_options(input, &Options::default())
66}
67
68/// Render Markdown `input` into a [`ratatui::text::Text`] using the supplied [`Options`].
69///
70/// This allows you to customize the styles and other rendering options.
71///
72/// # Example
73///
74/// ```
75/// use tui_markdown::{from_str_with_options, DefaultStyleSheet, Options};
76///
77/// let input = "This is a **bold** text.";
78/// let options = Options::default();
79/// let text = from_str_with_options(input, &options);
80/// ```
81pub fn from_str_with_options<'a, S>(input: &'a str, options: &Options<S>) -> Text<'a>
82where
83    S: StyleSheet,
84{
85    let mut parse_opts = ParseOptions::empty();
86    parse_opts.insert(ParseOptions::ENABLE_STRIKETHROUGH);
87    parse_opts.insert(ParseOptions::ENABLE_TASKLISTS);
88    parse_opts.insert(ParseOptions::ENABLE_HEADING_ATTRIBUTES);
89    parse_opts.insert(ParseOptions::ENABLE_YAML_STYLE_METADATA_BLOCKS);
90    parse_opts.insert(ParseOptions::ENABLE_SUPERSCRIPT);
91    parse_opts.insert(ParseOptions::ENABLE_SUBSCRIPT);
92    let parser = Parser::new_ext(input, parse_opts);
93
94    let mut writer = TextWriter::new(parser, options.styles.clone());
95    writer.run();
96    writer.text
97}
98
99// Heading attributes collected from pulldown-cmark to render after the heading text.
100struct HeadingMeta<'a> {
101    id: Option<CowStr<'a>>,
102    classes: Vec<CowStr<'a>>,
103    attrs: Vec<(CowStr<'a>, Option<CowStr<'a>>)>,
104}
105
106impl<'a> HeadingMeta<'a> {
107    fn into_option(self) -> Option<Self> {
108        let has_id = self.id.is_some();
109        let has_classes = !self.classes.is_empty();
110        let has_attrs = !self.attrs.is_empty();
111        if has_id || has_classes || has_attrs {
112            Some(self)
113        } else {
114            None
115        }
116    }
117
118    // Format as a Markdown attribute block suffix, e.g. "{#id .class key=value}".
119    fn to_suffix(&self) -> Option<String> {
120        let mut parts = Vec::new();
121
122        if let Some(id) = &self.id {
123            parts.push(format!("#{}", id));
124        }
125
126        for class in &self.classes {
127            parts.push(format!(".{}", class));
128        }
129
130        for (key, value) in &self.attrs {
131            match value {
132                Some(value) => parts.push(format!("{}={}", key, value)),
133                None => parts.push(key.to_string()),
134            }
135        }
136
137        if parts.is_empty() {
138            None
139        } else {
140            Some(format!(" {{{}}}", parts.join(" ")))
141        }
142    }
143}
144
145struct TextWriter<'a, I, S: StyleSheet> {
146    /// Iterator supplying events.
147    iter: I,
148
149    /// Text to write to.
150    text: Text<'a>,
151
152    /// Current style.
153    ///
154    /// This is a stack of styles, with the top style being the current style.
155    inline_styles: Vec<Style>,
156
157    /// Prefix to add to the start of the each line.
158    line_prefixes: Vec<Span<'a>>,
159
160    /// Stack of line styles.
161    line_styles: Vec<Style>,
162
163    /// Used to highlight code blocks, set when  a codeblock is encountered
164    #[cfg(feature = "highlight-code")]
165    code_highlighter: Option<HighlightLines<'a>>,
166
167    /// Current list index as a stack of indices.
168    list_indices: Vec<Option<u64>>,
169
170    /// A link which will be appended to the current line when the link tag is closed.
171    link: Option<CowStr<'a>>,
172
173    /// The [`StyleSheet`] to use to style the output.
174    styles: S,
175
176    /// Heading attributes to append after heading content.
177    heading_meta: Option<HeadingMeta<'a>>,
178
179    /// Whether we are inside a metadata block.
180    in_metadata_block: bool,
181
182    needs_newline: bool,
183}
184
185#[cfg(feature = "highlight-code")]
186static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
187#[cfg(feature = "highlight-code")]
188static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
189
190impl<'a, I, S> TextWriter<'a, I, S>
191where
192    I: Iterator<Item = Event<'a>>,
193    S: StyleSheet,
194{
195    fn new(iter: I, styles: S) -> Self {
196        Self {
197            iter,
198            text: Text::default(),
199            inline_styles: vec![],
200            line_styles: vec![],
201            line_prefixes: vec![],
202            list_indices: vec![],
203            needs_newline: false,
204            #[cfg(feature = "highlight-code")]
205            code_highlighter: None,
206            link: None,
207            styles,
208            heading_meta: None,
209            in_metadata_block: false,
210        }
211    }
212
213    fn run(&mut self) {
214        debug!("Running text writer");
215        while let Some(event) = self.iter.next() {
216            self.handle_event(event);
217        }
218    }
219
220    #[instrument(level = "debug", skip(self))]
221    fn handle_event(&mut self, event: Event<'a>) {
222        match event {
223            Event::Start(tag) => self.start_tag(tag),
224            Event::End(tag) => self.end_tag(tag),
225            Event::Text(text) => self.text(text),
226            Event::Code(code) => self.code(code),
227            Event::Html(_) => warn!("Html not yet supported"),
228            Event::InlineHtml(_) => warn!("Inline html not yet supported"),
229            Event::FootnoteReference(_) => warn!("Footnote reference not yet supported"),
230            Event::SoftBreak => self.soft_break(),
231            Event::HardBreak => self.hard_break(),
232            Event::Rule => self.rule(),
233            Event::TaskListMarker(checked) => self.task_list_marker(checked),
234            Event::InlineMath(_) => warn!("Inline math not yet supported"),
235            Event::DisplayMath(_) => warn!("Display math not yet supported"),
236        }
237    }
238
239    fn start_tag(&mut self, tag: Tag<'a>) {
240        match tag {
241            Tag::Paragraph => self.start_paragraph(),
242            Tag::Heading {
243                level,
244                id,
245                classes,
246                attrs,
247            } => self.start_heading(level, HeadingMeta { id, classes, attrs }),
248            Tag::BlockQuote(kind) => self.start_blockquote(kind),
249            Tag::CodeBlock(kind) => self.start_codeblock(kind),
250            Tag::HtmlBlock => warn!("Html block not yet supported"),
251            Tag::List(start_index) => self.start_list(start_index),
252            Tag::Item => self.start_item(),
253            Tag::FootnoteDefinition(_) => warn!("Footnote definition not yet supported"),
254            Tag::Table(_) => warn!("Table not yet supported"),
255            Tag::TableHead => warn!("Table head not yet supported"),
256            Tag::TableRow => warn!("Table row not yet supported"),
257            Tag::TableCell => warn!("Table cell not yet supported"),
258            Tag::Emphasis => self.push_inline_style(Style::new().italic()),
259            Tag::Strong => self.push_inline_style(Style::new().bold()),
260            Tag::Strikethrough => self.push_inline_style(Style::new().crossed_out()),
261            Tag::Subscript => self.push_inline_style(Style::new().dim().italic()),
262            Tag::Superscript => self.push_inline_style(Style::new().dim().italic()),
263            Tag::Link { dest_url, .. } => self.push_link(dest_url),
264            Tag::Image { .. } => warn!("Image not yet supported"),
265            Tag::MetadataBlock(_) => self.start_metadata_block(),
266            Tag::DefinitionList => warn!("Definition list not yet supported"),
267            Tag::DefinitionListTitle => warn!("Definition list title not yet supported"),
268            Tag::DefinitionListDefinition => warn!("Definition list definition not yet supported"),
269        }
270    }
271
272    fn end_tag(&mut self, tag: TagEnd) {
273        match tag {
274            TagEnd::Paragraph => self.end_paragraph(),
275            TagEnd::Heading(_) => self.end_heading(),
276            TagEnd::BlockQuote(_) => self.end_blockquote(),
277            TagEnd::CodeBlock => self.end_codeblock(),
278            TagEnd::HtmlBlock => {}
279            TagEnd::List(_is_ordered) => self.end_list(),
280            TagEnd::Item => {}
281            TagEnd::FootnoteDefinition => {}
282            TagEnd::Table => {}
283            TagEnd::TableHead => {}
284            TagEnd::TableRow => {}
285            TagEnd::TableCell => {}
286            TagEnd::Emphasis => self.pop_inline_style(),
287            TagEnd::Strong => self.pop_inline_style(),
288            TagEnd::Strikethrough => self.pop_inline_style(),
289            TagEnd::Subscript => self.pop_inline_style(),
290            TagEnd::Superscript => self.pop_inline_style(),
291            TagEnd::Link => self.pop_link(),
292            TagEnd::Image => {}
293            TagEnd::MetadataBlock(_) => self.end_metadata_block(),
294            TagEnd::DefinitionList => {}
295            TagEnd::DefinitionListTitle => {}
296            TagEnd::DefinitionListDefinition => {}
297        }
298    }
299
300    fn start_paragraph(&mut self) {
301        // Insert an empty line between paragraphs if there is at least one line of text already.
302        if self.needs_newline {
303            self.push_line(Line::default());
304        }
305        self.push_line(Line::default());
306        self.needs_newline = false;
307    }
308
309    fn end_paragraph(&mut self) {
310        self.needs_newline = true
311    }
312
313    fn start_heading(&mut self, level: HeadingLevel, heading_meta: HeadingMeta<'a>) {
314        if self.needs_newline {
315            self.push_line(Line::default());
316        }
317        let heading_level = match level {
318            HeadingLevel::H1 => 1,
319            HeadingLevel::H2 => 2,
320            HeadingLevel::H3 => 3,
321            HeadingLevel::H4 => 4,
322            HeadingLevel::H5 => 5,
323            HeadingLevel::H6 => 6,
324        };
325        let style = self.styles.heading(heading_level);
326        let content = format!("{} ", "#".repeat(heading_level as usize));
327        self.push_line(Line::styled(content, style));
328        self.heading_meta = heading_meta.into_option();
329        self.needs_newline = false;
330    }
331
332    fn end_heading(&mut self) {
333        if let Some(meta) = self.heading_meta.take() {
334            if let Some(suffix) = meta.to_suffix() {
335                self.push_span(Span::styled(suffix, self.styles.heading_meta()));
336            }
337        }
338        self.needs_newline = true
339    }
340
341    fn start_blockquote(&mut self, _kind: Option<BlockQuoteKind>) {
342        if self.needs_newline {
343            self.push_line(Line::default());
344            self.needs_newline = false;
345        }
346        self.line_prefixes.push(Span::from(">"));
347        self.line_styles.push(self.styles.blockquote());
348    }
349
350    fn end_blockquote(&mut self) {
351        self.line_prefixes.pop();
352        self.line_styles.pop();
353        self.needs_newline = true;
354    }
355
356    fn text(&mut self, text: CowStr<'a>) {
357        #[cfg(feature = "highlight-code")]
358        if let Some(highlighter) = &mut self.code_highlighter {
359            let text: Text = LinesWithEndings::from(&text)
360                .filter_map(|line| highlighter.highlight_line(line, &SYNTAX_SET).ok())
361                .filter_map(|part| as_24_bit_terminal_escaped(&part, false).into_text().ok())
362                .flatten()
363                .collect();
364
365            for line in text.lines {
366                self.text.push_line(line);
367            }
368            self.needs_newline = false;
369            return;
370        }
371
372        for (position, line) in text.lines().with_position() {
373            if self.needs_newline {
374                self.push_line(Line::default());
375                self.needs_newline = false;
376            }
377            if matches!(position, Position::Middle | Position::Last) {
378                self.push_line(Line::default());
379            }
380
381            let style = self.inline_styles.last().copied().unwrap_or_default();
382
383            let span = Span::styled(line.to_owned(), style);
384
385            self.push_span(span);
386        }
387        self.needs_newline = false;
388    }
389
390    fn code(&mut self, code: CowStr<'a>) {
391        let span = Span::styled(code, self.styles.code());
392        self.push_span(span);
393    }
394
395    fn hard_break(&mut self) {
396        self.push_line(Line::default());
397    }
398
399    fn start_metadata_block(&mut self) {
400        if self.needs_newline {
401            self.push_line(Line::default());
402        }
403        self.line_styles.push(self.styles.metadata_block());
404        self.push_line(Line::from("---"));
405        self.push_line(Line::default());
406        self.in_metadata_block = true;
407    }
408
409    fn end_metadata_block(&mut self) {
410        if self.in_metadata_block {
411            self.push_line(Line::from("---"));
412            self.line_styles.pop();
413            self.in_metadata_block = false;
414            self.needs_newline = true;
415        }
416    }
417
418    fn rule(&mut self) {
419        if self.needs_newline {
420            self.push_line(Line::default());
421        }
422        self.push_line(Line::from("---"));
423        self.needs_newline = true;
424    }
425
426    fn start_list(&mut self, index: Option<u64>) {
427        if self.list_indices.is_empty() && self.needs_newline {
428            self.push_line(Line::default());
429        }
430        self.list_indices.push(index);
431    }
432
433    fn end_list(&mut self) {
434        self.list_indices.pop();
435        self.needs_newline = true;
436    }
437
438    fn start_item(&mut self) {
439        self.push_line(Line::default());
440        let width = self.list_indices.len() * 4 - 3;
441        if let Some(last_index) = self.list_indices.last_mut() {
442            let span = match last_index {
443                None => Span::from(" ".repeat(width - 1) + "- "),
444                Some(index) => {
445                    *index += 1;
446                    format!("{:width$}. ", *index - 1).light_blue()
447                }
448            };
449            self.push_span(span);
450        }
451        self.needs_newline = false;
452    }
453
454    fn task_list_marker(&mut self, checked: bool) {
455        let marker = if checked { 'x' } else { ' ' };
456        let marker_span = Span::from(format!("[{}] ", marker));
457        if let Some(line) = self.text.lines.last_mut() {
458            if let Some(first_span) = line.spans.first_mut() {
459                let content = first_span.content.to_mut();
460                if content.ends_with("- ") {
461                    let len = content.len();
462                    content.truncate(len - 2);
463                    content.push_str("- [");
464                    content.push(marker);
465                    content.push_str("] ");
466                    return;
467                }
468            }
469            line.spans.insert(1, marker_span);
470        } else {
471            self.push_span(marker_span);
472        }
473    }
474
475    fn soft_break(&mut self) {
476        if self.in_metadata_block {
477            self.hard_break();
478        } else {
479            self.push_span(Span::raw(" "));
480        }
481    }
482
483    fn start_codeblock(&mut self, kind: CodeBlockKind<'_>) {
484        if !self.text.lines.is_empty() {
485            self.push_line(Line::default());
486        }
487        let lang = match kind {
488            CodeBlockKind::Fenced(ref lang) => lang.as_ref(),
489            CodeBlockKind::Indented => "",
490        };
491
492        #[cfg(not(feature = "highlight-code"))]
493        self.line_styles.push(self.styles.code());
494
495        #[cfg(feature = "highlight-code")]
496        self.set_code_highlighter(lang);
497
498        let span = Span::from(format!("```{lang}"));
499        self.push_line(span.into());
500        self.needs_newline = true;
501    }
502
503    fn end_codeblock(&mut self) {
504        let span = Span::from("```");
505        self.push_line(span.into());
506        self.needs_newline = true;
507
508        #[cfg(not(feature = "highlight-code"))]
509        self.line_styles.pop();
510
511        #[cfg(feature = "highlight-code")]
512        self.clear_code_highlighter();
513    }
514
515    #[cfg(feature = "highlight-code")]
516    #[instrument(level = "trace", skip(self))]
517    fn set_code_highlighter(&mut self, lang: &str) {
518        if let Some(syntax) = SYNTAX_SET.find_syntax_by_token(lang) {
519            debug!("Starting code block with syntax: {:?}", lang);
520            let theme = &THEME_SET.themes["base16-ocean.dark"];
521            let highlighter = HighlightLines::new(syntax, theme);
522            self.code_highlighter = Some(highlighter);
523        } else {
524            warn!("Could not find syntax for code block: {:?}", lang);
525        }
526    }
527
528    #[cfg(feature = "highlight-code")]
529    #[instrument(level = "trace", skip(self))]
530    fn clear_code_highlighter(&mut self) {
531        self.code_highlighter = None;
532    }
533
534    #[instrument(level = "trace", skip(self))]
535    fn push_inline_style(&mut self, style: Style) {
536        let current_style = self.inline_styles.last().copied().unwrap_or_default();
537        let style = current_style.patch(style);
538        self.inline_styles.push(style);
539        debug!("Pushed inline style: {:?}", style);
540        debug!("Current inline styles: {:?}", self.inline_styles);
541    }
542
543    #[instrument(level = "trace", skip(self))]
544    fn pop_inline_style(&mut self) {
545        self.inline_styles.pop();
546    }
547
548    #[instrument(level = "trace", skip(self))]
549    fn push_line(&mut self, line: Line<'a>) {
550        let style = self.line_styles.last().copied().unwrap_or_default();
551        let mut line = line.patch_style(style);
552
553        // Add line prefixes to the start of the line.
554        let line_prefixes = self.line_prefixes.iter().cloned().collect_vec();
555        let has_prefixes = !line_prefixes.is_empty();
556        if has_prefixes {
557            line.spans.insert(0, " ".into());
558        }
559        for prefix in line_prefixes.iter().rev().cloned() {
560            line.spans.insert(0, prefix);
561        }
562        self.text.lines.push(line);
563    }
564
565    #[instrument(level = "trace", skip(self))]
566    fn push_span(&mut self, span: Span<'a>) {
567        if let Some(line) = self.text.lines.last_mut() {
568            line.push_span(span);
569        } else {
570            self.push_line(Line::from(vec![span]));
571        }
572    }
573
574    /// Store the link to be appended to the link text
575    #[instrument(level = "trace", skip(self))]
576    fn push_link(&mut self, dest_url: CowStr<'a>) {
577        self.link = Some(dest_url);
578    }
579
580    /// Append the link to the current line
581    #[instrument(level = "trace", skip(self))]
582    fn pop_link(&mut self) {
583        if let Some(link) = self.link.take() {
584            self.push_span(" (".into());
585            self.push_span(Span::styled(link, self.styles.link()));
586            self.push_span(")".into());
587        }
588    }
589}
590
591#[cfg(test)]
592mod tests {
593    use indoc::indoc;
594    use pretty_assertions::assert_eq;
595    use rstest::{fixture, rstest};
596    use tracing::level_filters::LevelFilter;
597    use tracing::subscriber::{self, DefaultGuard};
598    use tracing_subscriber::fmt::format::FmtSpan;
599    use tracing_subscriber::fmt::time::Uptime;
600
601    use super::*;
602
603    #[fixture]
604    fn with_tracing() -> DefaultGuard {
605        let subscriber = tracing_subscriber::fmt()
606            .with_test_writer()
607            .with_timer(Uptime::default())
608            .with_max_level(LevelFilter::TRACE)
609            .with_span_events(FmtSpan::ENTER)
610            .finish();
611        subscriber::set_default(subscriber)
612    }
613
614    #[rstest]
615    fn empty(_with_tracing: DefaultGuard) {
616        assert_eq!(from_str(""), Text::default());
617    }
618
619    #[rstest]
620    fn paragraph_single(_with_tracing: DefaultGuard) {
621        assert_eq!(from_str("Hello, world!"), Text::from("Hello, world!"));
622    }
623
624    #[rstest]
625    fn paragraph_soft_break(_with_tracing: DefaultGuard) {
626        assert_eq!(
627            from_str(indoc! {"
628                Hello
629                World
630            "}),
631            Text::from(Line::from_iter([
632                Span::from("Hello"),
633                Span::from(" "),
634                Span::from("World"),
635            ]))
636        );
637    }
638
639    #[rstest]
640    fn paragraph_multiple(_with_tracing: DefaultGuard) {
641        assert_eq!(
642            from_str(indoc! {"
643                Paragraph 1
644                
645                Paragraph 2
646            "}),
647            Text::from_iter(["Paragraph 1", "", "Paragraph 2",])
648        );
649    }
650
651    #[rstest]
652    fn rule(_with_tracing: DefaultGuard) {
653        assert_eq!(
654            from_str(indoc! {"
655                Paragraph 1
656
657                ---
658
659                Paragraph 2
660            "}),
661            Text::from_iter(["Paragraph 1", "", "---", "", "Paragraph 2"])
662        );
663    }
664
665    #[rstest]
666    fn headings(_with_tracing: DefaultGuard) {
667        let h1 = Style::new().on_cyan().bold().underlined();
668        let h2 = Style::new().cyan().bold();
669        let h3 = Style::new().cyan().bold().italic();
670        let h4 = Style::new().light_cyan().italic();
671        let h5 = Style::new().light_cyan().italic();
672        let h6 = Style::new().light_cyan().italic();
673
674        assert_eq!(
675            from_str(indoc! {"
676                # Heading 1
677                ## Heading 2
678                ### Heading 3
679                #### Heading 4
680                ##### Heading 5
681                ###### Heading 6
682            "}),
683            Text::from_iter([
684                Line::from_iter(["# ", "Heading 1"]).style(h1),
685                Line::default(),
686                Line::from_iter(["## ", "Heading 2"]).style(h2),
687                Line::default(),
688                Line::from_iter(["### ", "Heading 3"]).style(h3),
689                Line::default(),
690                Line::from_iter(["#### ", "Heading 4"]).style(h4),
691                Line::default(),
692                Line::from_iter(["##### ", "Heading 5"]).style(h5),
693                Line::default(),
694                Line::from_iter(["###### ", "Heading 6"]).style(h6),
695            ])
696        );
697    }
698
699    #[rstest]
700    fn heading_attributes(_with_tracing: DefaultGuard) {
701        let h1 = Style::new().on_cyan().bold().underlined();
702        let meta = Style::new().dim();
703
704        assert_eq!(
705            from_str("# Heading {#title .primary data-kind=doc}"),
706            Text::from(
707                Line::from_iter([
708                    Span::from("# "),
709                    Span::from("Heading"),
710                    Span::styled(" {#title .primary data-kind=doc}", meta),
711                ])
712                .style(h1)
713            )
714        );
715    }
716
717    mod blockquote {
718        use pretty_assertions::assert_eq;
719        use ratatui::style::Color;
720
721        use super::*;
722
723        const STYLE: Style = Style::new().fg(Color::Green);
724
725        /// I was having difficulty getting the right number of newlines between paragraphs, so this
726        /// test is to help debug and ensure that.
727        #[rstest]
728        fn after_paragraph(_with_tracing: DefaultGuard) {
729            assert_eq!(
730                from_str(indoc! {"
731                Hello, world!
732
733                > Blockquote
734            "}),
735                Text::from_iter([
736                    Line::from("Hello, world!"),
737                    Line::default(),
738                    Line::from_iter([">", " ", "Blockquote"]).style(STYLE),
739                ])
740            );
741        }
742        #[rstest]
743        fn single(_with_tracing: DefaultGuard) {
744            assert_eq!(
745                from_str("> Blockquote"),
746                Text::from(Line::from_iter([">", " ", "Blockquote"]).style(STYLE))
747            );
748        }
749
750        #[rstest]
751        fn soft_break(_with_tracing: DefaultGuard) {
752            assert_eq!(
753                from_str(indoc! {"
754                > Blockquote 1
755                > Blockquote 2
756            "}),
757                Text::from(
758                    Line::from_iter([">", " ", "Blockquote 1", " ", "Blockquote 2"]).style(STYLE)
759                )
760            );
761        }
762
763        #[rstest]
764        fn multiple(_with_tracing: DefaultGuard) {
765            assert_eq!(
766                from_str(indoc! {"
767                > Blockquote 1
768                >
769                > Blockquote 2
770            "}),
771                Text::from_iter([
772                    Line::from_iter([">", " ", "Blockquote 1"]).style(STYLE),
773                    Line::from_iter([">", " "]).style(STYLE),
774                    Line::from_iter([">", " ", "Blockquote 2"]).style(STYLE),
775                ])
776            );
777        }
778
779        #[rstest]
780        fn multiple_with_break(_with_tracing: DefaultGuard) {
781            assert_eq!(
782                from_str(indoc! {"
783                > Blockquote 1
784
785                > Blockquote 2
786            "}),
787                Text::from_iter([
788                    Line::from_iter([">", " ", "Blockquote 1"]).style(STYLE),
789                    Line::default(),
790                    Line::from_iter([">", " ", "Blockquote 2"]).style(STYLE),
791                ])
792            );
793        }
794
795        #[rstest]
796        fn nested(_with_tracing: DefaultGuard) {
797            assert_eq!(
798                from_str(indoc! {"
799                > Blockquote 1
800                >> Nested Blockquote
801            "}),
802                Text::from_iter([
803                    Line::from_iter([">", " ", "Blockquote 1"]).style(STYLE),
804                    Line::from_iter([">", " "]).style(STYLE),
805                    Line::from_iter([">", ">", " ", "Nested Blockquote"]).style(STYLE),
806                ])
807            );
808        }
809    }
810
811    #[rstest]
812    fn list_single(_with_tracing: DefaultGuard) {
813        assert_eq!(
814            from_str(indoc! {"
815                - List item 1
816            "}),
817            Text::from_iter([Line::from_iter(["- ", "List item 1"])])
818        );
819    }
820
821    #[rstest]
822    fn list_multiple(_with_tracing: DefaultGuard) {
823        assert_eq!(
824            from_str(indoc! {"
825                - List item 1
826                - List item 2
827            "}),
828            Text::from_iter([
829                Line::from_iter(["- ", "List item 1"]),
830                Line::from_iter(["- ", "List item 2"]),
831            ])
832        );
833    }
834
835    #[rstest]
836    fn list_ordered(_with_tracing: DefaultGuard) {
837        assert_eq!(
838            from_str(indoc! {"
839                1. List item 1
840                2. List item 2
841            "}),
842            Text::from_iter([
843                Line::from_iter(["1. ".light_blue(), "List item 1".into()]),
844                Line::from_iter(["2. ".light_blue(), "List item 2".into()]),
845            ])
846        );
847    }
848
849    #[rstest]
850    fn list_nested(_with_tracing: DefaultGuard) {
851        assert_eq!(
852            from_str(indoc! {"
853                - List item 1
854                  - Nested list item 1
855            "}),
856            Text::from_iter([
857                Line::from_iter(["- ", "List item 1"]),
858                Line::from_iter(["    - ", "Nested list item 1"]),
859            ])
860        );
861    }
862
863    #[rstest]
864    fn list_task_items(_with_tracing: DefaultGuard) {
865        assert_eq!(
866            from_str(indoc! {"
867                - [ ] Incomplete
868                - [x] Complete
869            "}),
870            Text::from_iter([
871                Line::from_iter(["- [ ] ", "Incomplete"]),
872                Line::from_iter(["- [x] ", "Complete"]),
873            ])
874        );
875    }
876
877    #[rstest]
878    fn list_task_items_ordered(_with_tracing: DefaultGuard) {
879        assert_eq!(
880            from_str(indoc! {"
881                1. [ ] Incomplete
882                2. [x] Complete
883            "}),
884            Text::from_iter([
885                Line::from_iter(["1. ".light_blue(), "[ ] ".into(), "Incomplete".into(),]),
886                Line::from_iter(["2. ".light_blue(), "[x] ".into(), "Complete".into(),]),
887            ])
888        );
889    }
890
891    #[cfg_attr(not(feature = "highlight-code"), ignore)]
892    #[rstest]
893    fn highlighted_code(_with_tracing: DefaultGuard) {
894        // Assert no extra newlines are added
895        let highlighted_code = from_str(indoc! {"
896            ```rust
897            fn main() {
898                println!(\"Hello, highlighted code!\");
899            }
900            ```"});
901
902        insta::assert_snapshot!(highlighted_code);
903        insta::assert_debug_snapshot!(highlighted_code);
904    }
905
906    #[cfg_attr(not(feature = "highlight-code"), ignore)]
907    #[rstest]
908    fn highlighted_code_with_indentation(_with_tracing: DefaultGuard) {
909        // Assert no extra newlines are added
910        let highlighted_code_indented = from_str(indoc! {"
911            ```rust
912            fn main() {
913                // This is a comment
914                HelloWorldBuilder::new()
915                    .with_text(\"Hello, highlighted code!\")
916                    .build()
917                    .show();
918                            
919            }
920            ```"});
921
922        insta::assert_snapshot!(highlighted_code_indented);
923        insta::assert_debug_snapshot!(highlighted_code_indented);
924    }
925
926    #[cfg_attr(feature = "highlight-code", ignore)]
927    #[rstest]
928    fn unhighlighted_code(_with_tracing: DefaultGuard) {
929        // Assert no extra newlines are added
930        let unhiglighted_code = from_str(indoc! {"
931            ```rust
932            fn main() {
933                println!(\"Hello, unhighlighted code!\");
934            }
935            ```"});
936
937        insta::assert_snapshot!(unhiglighted_code);
938
939        // Code highlighting is complex, assert on on the debug snapshot
940        insta::assert_debug_snapshot!(unhiglighted_code);
941    }
942
943    #[rstest]
944    fn inline_code(_with_tracing: DefaultGuard) {
945        let text = from_str("Example of `Inline code`");
946        insta::assert_snapshot!(text);
947
948        assert_eq!(
949            text,
950            Line::from_iter([
951                Span::from("Example of "),
952                Span::styled("Inline code", Style::new().white().on_black())
953            ])
954            .into()
955        );
956    }
957
958    #[rstest]
959    fn superscript(_with_tracing: DefaultGuard) {
960        assert_eq!(
961            from_str("H ^2^ O"),
962            Text::from(Line::from_iter([
963                Span::from("H "),
964                Span::styled("2", Style::new().dim().italic()),
965                Span::from(" O"),
966            ]))
967        );
968    }
969
970    #[rstest]
971    fn subscript(_with_tracing: DefaultGuard) {
972        assert_eq!(
973            from_str("H ~2~ O"),
974            Text::from(Line::from_iter([
975                Span::from("H "),
976                Span::styled("2", Style::new().dim().italic()),
977                Span::from(" O"),
978            ]))
979        );
980    }
981
982    #[rstest]
983    fn metadata_block(_with_tracing: DefaultGuard) {
984        assert_eq!(
985            from_str(indoc! {"
986                ---
987                title: Demo
988                ---
989
990                Body
991            "}),
992            Text::from_iter([
993                Line::from("---").style(Style::new().light_yellow()),
994                Line::from("title: Demo").style(Style::new().light_yellow()),
995                Line::from("---").style(Style::new().light_yellow()),
996                Line::default(),
997                Line::from("Body"),
998            ])
999        );
1000    }
1001
1002    #[rstest]
1003    fn strong(_with_tracing: DefaultGuard) {
1004        assert_eq!(
1005            from_str("**Strong**"),
1006            Text::from(Line::from("Strong".bold()))
1007        );
1008    }
1009
1010    #[rstest]
1011    fn emphasis(_with_tracing: DefaultGuard) {
1012        assert_eq!(
1013            from_str("*Emphasis*"),
1014            Text::from(Line::from("Emphasis".italic()))
1015        );
1016    }
1017
1018    #[rstest]
1019    fn strikethrough(_with_tracing: DefaultGuard) {
1020        assert_eq!(
1021            from_str("~~Strikethrough~~"),
1022            Text::from(Line::from("Strikethrough".crossed_out()))
1023        );
1024    }
1025
1026    #[rstest]
1027    fn strong_emphasis(_with_tracing: DefaultGuard) {
1028        assert_eq!(
1029            from_str("**Strong *emphasis***"),
1030            Text::from(Line::from_iter([
1031                "Strong ".bold(),
1032                "emphasis".bold().italic()
1033            ]))
1034        );
1035    }
1036
1037    #[rstest]
1038    fn link(_with_tracing: DefaultGuard) {
1039        assert_eq!(
1040            from_str("[Link](https://example.com)"),
1041            Text::from(Line::from_iter([
1042                Span::from("Link"),
1043                Span::from(" ("),
1044                Span::from("https://example.com").blue().underlined(),
1045                Span::from(")")
1046            ]))
1047        );
1048    }
1049}