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, Parser, Tag, TagEnd,
41};
42use ratatui::style::{Style, Stylize};
43use ratatui::text::{Line, Span, Text};
44#[cfg(feature = "highlight-code")]
45use syntect::{
46    easy::HighlightLines,
47    highlighting::ThemeSet,
48    parsing::SyntaxSet,
49    util::{as_24_bit_terminal_escaped, LinesWithEndings},
50};
51use tracing::{debug, instrument, warn};
52
53pub fn from_str(input: &str) -> Text<'_> {
54    let mut options = Options::empty();
55    options.insert(Options::ENABLE_STRIKETHROUGH);
56    options.insert(Options::ENABLE_TASKLISTS);
57    let parser = Parser::new_ext(input, options);
58    let mut writer = TextWriter::new(parser);
59    writer.run();
60    writer.text
61}
62
63struct TextWriter<'a, I> {
64    /// Iterator supplying events.
65    iter: I,
66
67    /// Text to write to.
68    text: Text<'a>,
69
70    /// Current style.
71    ///
72    /// This is a stack of styles, with the top style being the current style.
73    inline_styles: Vec<Style>,
74
75    /// Prefix to add to the start of the each line.
76    line_prefixes: Vec<Span<'a>>,
77
78    /// Stack of line styles.
79    line_styles: Vec<Style>,
80
81    /// Used to highlight code blocks, set when  a codeblock is encountered
82    #[cfg(feature = "highlight-code")]
83    code_highlighter: Option<HighlightLines<'a>>,
84
85    /// Current list index as a stack of indices.
86    list_indices: Vec<Option<u64>>,
87
88    /// A link which will be appended to the current line when the link tag is closed.
89    link: Option<CowStr<'a>>,
90
91    needs_newline: bool,
92}
93
94#[cfg(feature = "highlight-code")]
95static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
96#[cfg(feature = "highlight-code")]
97static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
98
99impl<'a, I> TextWriter<'a, I>
100where
101    I: Iterator<Item = Event<'a>>,
102{
103    fn new(iter: I) -> Self {
104        Self {
105            iter,
106            text: Text::default(),
107            inline_styles: vec![],
108            line_styles: vec![],
109            line_prefixes: vec![],
110            list_indices: vec![],
111            needs_newline: false,
112            #[cfg(feature = "highlight-code")]
113            code_highlighter: None,
114            link: None,
115        }
116    }
117
118    fn run(&mut self) {
119        debug!("Running text writer");
120        while let Some(event) = self.iter.next() {
121            self.handle_event(event);
122        }
123    }
124
125    #[instrument(level = "debug", skip(self))]
126    fn handle_event(&mut self, event: Event<'a>) {
127        match event {
128            Event::Start(tag) => self.start_tag(tag),
129            Event::End(tag) => self.end_tag(tag),
130            Event::Text(text) => self.text(text),
131            Event::Code(code) => self.code(code),
132            Event::Html(_html) => warn!("Html not yet supported"),
133            Event::InlineHtml(_html) => warn!("Inline html not yet supported"),
134            Event::FootnoteReference(_) => warn!("Footnote reference not yet supported"),
135            Event::SoftBreak => self.soft_break(),
136            Event::HardBreak => self.hard_break(),
137            Event::Rule => warn!("Rule not yet supported"),
138            Event::TaskListMarker(checked) => self.task_list_marker(checked),
139            Event::InlineMath(_) => warn!("Inline math not yet supported"),
140            Event::DisplayMath(_) => warn!("Display math not yet supported"),
141        }
142    }
143
144    fn start_tag(&mut self, tag: Tag<'a>) {
145        match tag {
146            Tag::Paragraph => self.start_paragraph(),
147            Tag::Heading { level, .. } => self.start_heading(level),
148            Tag::BlockQuote(kind) => self.start_blockquote(kind),
149            Tag::CodeBlock(kind) => self.start_codeblock(kind),
150            Tag::HtmlBlock => warn!("Html block not yet supported"),
151            Tag::List(start_index) => self.start_list(start_index),
152            Tag::Item => self.start_item(),
153            Tag::FootnoteDefinition(_) => warn!("Footnote definition not yet supported"),
154            Tag::Table(_) => warn!("Table not yet supported"),
155            Tag::TableHead => warn!("Table head not yet supported"),
156            Tag::TableRow => warn!("Table row not yet supported"),
157            Tag::TableCell => warn!("Table cell not yet supported"),
158            Tag::Emphasis => self.push_inline_style(Style::new().italic()),
159            Tag::Strong => self.push_inline_style(Style::new().bold()),
160            Tag::Strikethrough => self.push_inline_style(Style::new().crossed_out()),
161            Tag::Subscript => warn!("Subscript not yet supported"),
162            Tag::Superscript => warn!("Superscript not yet supported"),
163            Tag::Link { dest_url, .. } => self.push_link(dest_url),
164            Tag::Image { .. } => warn!("Image not yet supported"),
165            Tag::MetadataBlock(_) => warn!("Metadata block not yet supported"),
166            Tag::DefinitionList => warn!("Definition list not yet supported"),
167            Tag::DefinitionListTitle => warn!("Definition list title not yet supported"),
168            Tag::DefinitionListDefinition => warn!("Definition list definition not yet supported"),
169        }
170    }
171
172    fn end_tag(&mut self, tag: TagEnd) {
173        match tag {
174            TagEnd::Paragraph => self.end_paragraph(),
175            TagEnd::Heading(_) => self.end_heading(),
176            TagEnd::BlockQuote(_) => self.end_blockquote(),
177            TagEnd::CodeBlock => self.end_codeblock(),
178            TagEnd::HtmlBlock => {}
179            TagEnd::List(_is_ordered) => self.end_list(),
180            TagEnd::Item => {}
181            TagEnd::FootnoteDefinition => {}
182            TagEnd::Table => {}
183            TagEnd::TableHead => {}
184            TagEnd::TableRow => {}
185            TagEnd::TableCell => {}
186            TagEnd::Emphasis => self.pop_inline_style(),
187            TagEnd::Strong => self.pop_inline_style(),
188            TagEnd::Strikethrough => self.pop_inline_style(),
189            TagEnd::Subscript => {}
190            TagEnd::Superscript => {}
191            TagEnd::Link => self.pop_link(),
192            TagEnd::Image => {}
193            TagEnd::MetadataBlock(_) => {}
194            TagEnd::DefinitionList => {}
195            TagEnd::DefinitionListTitle => {}
196            TagEnd::DefinitionListDefinition => {}
197        }
198    }
199
200    fn start_paragraph(&mut self) {
201        // Insert an empty line between paragraphs if there is at least one line of text already.
202        if self.needs_newline {
203            self.push_line(Line::default());
204        }
205        self.push_line(Line::default());
206        self.needs_newline = false;
207    }
208
209    fn end_paragraph(&mut self) {
210        self.needs_newline = true
211    }
212
213    fn start_heading(&mut self, level: HeadingLevel) {
214        if self.needs_newline {
215            self.push_line(Line::default());
216        }
217        let style = match level {
218            HeadingLevel::H1 => styles::H1,
219            HeadingLevel::H2 => styles::H2,
220            HeadingLevel::H3 => styles::H3,
221            HeadingLevel::H4 => styles::H4,
222            HeadingLevel::H5 => styles::H5,
223            HeadingLevel::H6 => styles::H6,
224        };
225        let content = format!("{} ", "#".repeat(level as usize));
226        self.push_line(Line::styled(content, style));
227        self.needs_newline = false;
228    }
229
230    fn end_heading(&mut self) {
231        self.needs_newline = true
232    }
233
234    fn start_blockquote(&mut self, _kind: Option<BlockQuoteKind>) {
235        if self.needs_newline {
236            self.push_line(Line::default());
237            self.needs_newline = false;
238        }
239        self.line_prefixes.push(Span::from(">"));
240        self.line_styles.push(styles::BLOCKQUOTE);
241    }
242
243    fn end_blockquote(&mut self) {
244        self.line_prefixes.pop();
245        self.line_styles.pop();
246        self.needs_newline = true;
247    }
248
249    fn text(&mut self, text: CowStr<'a>) {
250        #[cfg(feature = "highlight-code")]
251        if let Some(highlighter) = &mut self.code_highlighter {
252            let text: Text = LinesWithEndings::from(&text)
253                .filter_map(|line| highlighter.highlight_line(line, &SYNTAX_SET).ok())
254                .filter_map(|part| as_24_bit_terminal_escaped(&part, false).into_text().ok())
255                .flatten()
256                .collect();
257
258            for line in text.lines {
259                self.text.push_line(line);
260            }
261            self.needs_newline = false;
262            return;
263        }
264
265        for (position, line) in text.lines().with_position() {
266            if self.needs_newline {
267                self.push_line(Line::default());
268                self.needs_newline = false;
269            }
270            if matches!(position, Position::Middle | Position::Last) {
271                self.push_line(Line::default());
272            }
273
274            let style = self.inline_styles.last().copied().unwrap_or_default();
275
276            let span = Span::styled(line.to_owned(), style);
277
278            self.push_span(span);
279        }
280        self.needs_newline = false;
281    }
282
283    fn code(&mut self, code: CowStr<'a>) {
284        let span = Span::styled(code, styles::CODE);
285        self.push_span(span);
286    }
287
288    fn hard_break(&mut self) {
289        self.push_line(Line::default());
290    }
291
292    fn start_list(&mut self, index: Option<u64>) {
293        if self.list_indices.is_empty() && self.needs_newline {
294            self.push_line(Line::default());
295        }
296        self.list_indices.push(index);
297    }
298
299    fn end_list(&mut self) {
300        self.list_indices.pop();
301        self.needs_newline = true;
302    }
303
304    fn start_item(&mut self) {
305        self.push_line(Line::default());
306        let width = self.list_indices.len() * 4 - 3;
307        if let Some(last_index) = self.list_indices.last_mut() {
308            let span = match last_index {
309                None => Span::from(" ".repeat(width - 1) + "- "),
310                Some(index) => {
311                    *index += 1;
312                    format!("{:width$}. ", *index - 1).light_blue()
313                }
314            };
315            self.push_span(span);
316        }
317        self.needs_newline = false;
318    }
319
320    fn task_list_marker(&mut self, checked: bool) {
321        let marker = if checked { 'x' } else { ' ' };
322        let marker_span = Span::from(format!("[{}] ", marker));
323        if let Some(line) = self.text.lines.last_mut() {
324            if let Some(first_span) = line.spans.first_mut() {
325                let content = first_span.content.to_mut();
326                if content.ends_with("- ") {
327                    let len = content.len();
328                    content.truncate(len - 2);
329                    content.push_str("- [");
330                    content.push(marker);
331                    content.push_str("] ");
332                    return;
333                }
334            }
335            line.spans.insert(1, marker_span);
336        } else {
337            self.push_span(marker_span);
338        }
339    }
340
341    fn soft_break(&mut self) {
342        self.push_line(Line::default());
343    }
344
345    fn start_codeblock(&mut self, kind: CodeBlockKind<'_>) {
346        if !self.text.lines.is_empty() {
347            self.push_line(Line::default());
348        }
349        let lang = match kind {
350            CodeBlockKind::Fenced(ref lang) => lang.as_ref(),
351            CodeBlockKind::Indented => "",
352        };
353
354        #[cfg(not(feature = "highlight-code"))]
355        self.line_styles.push(styles::CODE);
356
357        #[cfg(feature = "highlight-code")]
358        self.set_code_highlighter(lang);
359
360        let span = Span::from(format!("```{lang}"));
361        self.push_line(span.into());
362        self.needs_newline = true;
363    }
364
365    fn end_codeblock(&mut self) {
366        let span = Span::from("```");
367        self.push_line(span.into());
368        self.needs_newline = true;
369
370        #[cfg(not(feature = "highlight-code"))]
371        self.line_styles.pop();
372
373        #[cfg(feature = "highlight-code")]
374        self.clear_code_highlighter();
375    }
376
377    #[cfg(feature = "highlight-code")]
378    #[instrument(level = "trace", skip(self))]
379    fn set_code_highlighter(&mut self, lang: &str) {
380        if let Some(syntax) = SYNTAX_SET.find_syntax_by_token(lang) {
381            debug!("Starting code block with syntax: {:?}", lang);
382            let theme = &THEME_SET.themes["base16-ocean.dark"];
383            let highlighter = HighlightLines::new(syntax, theme);
384            self.code_highlighter = Some(highlighter);
385        } else {
386            warn!("Could not find syntax for code block: {:?}", lang);
387        }
388    }
389
390    #[cfg(feature = "highlight-code")]
391    #[instrument(level = "trace", skip(self))]
392    fn clear_code_highlighter(&mut self) {
393        self.code_highlighter = None;
394    }
395
396    #[instrument(level = "trace", skip(self))]
397    fn push_inline_style(&mut self, style: Style) {
398        let current_style = self.inline_styles.last().copied().unwrap_or_default();
399        let style = current_style.patch(style);
400        self.inline_styles.push(style);
401        debug!("Pushed inline style: {:?}", style);
402        debug!("Current inline styles: {:?}", self.inline_styles);
403    }
404
405    #[instrument(level = "trace", skip(self))]
406    fn pop_inline_style(&mut self) {
407        self.inline_styles.pop();
408    }
409
410    #[instrument(level = "trace", skip(self))]
411    fn push_line(&mut self, line: Line<'a>) {
412        let style = self.line_styles.last().copied().unwrap_or_default();
413        let mut line = line.patch_style(style);
414
415        // Add line prefixes to the start of the line.
416        let line_prefixes = self.line_prefixes.iter().cloned().collect_vec();
417        let has_prefixes = !line_prefixes.is_empty();
418        if has_prefixes {
419            line.spans.insert(0, " ".into());
420        }
421        for prefix in line_prefixes.iter().rev().cloned() {
422            line.spans.insert(0, prefix);
423        }
424        self.text.lines.push(line);
425    }
426
427    #[instrument(level = "trace", skip(self))]
428    fn push_span(&mut self, span: Span<'a>) {
429        if let Some(line) = self.text.lines.last_mut() {
430            line.push_span(span);
431        } else {
432            self.push_line(Line::from(vec![span]));
433        }
434    }
435
436    /// Store the link to be appended to the link text
437    #[instrument(level = "trace", skip(self))]
438    fn push_link(&mut self, dest_url: CowStr<'a>) {
439        self.link = Some(dest_url);
440    }
441
442    /// Append the link to the current line
443    #[instrument(level = "trace", skip(self))]
444    fn pop_link(&mut self) {
445        if let Some(link) = self.link.take() {
446            self.push_span(" (".into());
447            self.push_span(Span::styled(link, styles::LINK));
448            self.push_span(")".into());
449        }
450    }
451}
452
453mod styles {
454    use ratatui::style::{Color, Modifier, Style};
455
456    pub const H1: Style = Style::new()
457        .bg(Color::Cyan)
458        .add_modifier(Modifier::BOLD)
459        .add_modifier(Modifier::UNDERLINED);
460    pub const H2: Style = Style::new().fg(Color::Cyan).add_modifier(Modifier::BOLD);
461    pub const H3: Style = Style::new()
462        .fg(Color::Cyan)
463        .add_modifier(Modifier::BOLD)
464        .add_modifier(Modifier::ITALIC);
465    pub const H4: Style = Style::new()
466        .fg(Color::LightCyan)
467        .add_modifier(Modifier::ITALIC);
468    pub const H5: Style = Style::new()
469        .fg(Color::LightCyan)
470        .add_modifier(Modifier::ITALIC);
471    pub const H6: Style = Style::new()
472        .fg(Color::LightCyan)
473        .add_modifier(Modifier::ITALIC);
474    pub const BLOCKQUOTE: Style = Style::new().fg(Color::Green);
475    pub const CODE: Style = Style::new().fg(Color::White).bg(Color::Black);
476    pub const LINK: Style = Style::new()
477        .fg(Color::Blue)
478        .add_modifier(Modifier::UNDERLINED);
479}
480
481#[cfg(test)]
482mod tests {
483    use indoc::indoc;
484    use pretty_assertions::assert_eq;
485    use rstest::{fixture, rstest};
486    use tracing::level_filters::LevelFilter;
487    use tracing::subscriber::{self, DefaultGuard};
488    use tracing_subscriber::fmt::format::FmtSpan;
489    use tracing_subscriber::fmt::time::Uptime;
490
491    use super::*;
492
493    #[fixture]
494    fn with_tracing() -> DefaultGuard {
495        let subscriber = tracing_subscriber::fmt()
496            .with_test_writer()
497            .with_timer(Uptime::default())
498            .with_max_level(LevelFilter::TRACE)
499            .with_span_events(FmtSpan::ENTER)
500            .finish();
501        subscriber::set_default(subscriber)
502    }
503
504    #[rstest]
505    fn empty(_with_tracing: DefaultGuard) {
506        assert_eq!(from_str(""), Text::default());
507    }
508
509    #[rstest]
510    fn paragraph_single(_with_tracing: DefaultGuard) {
511        assert_eq!(from_str("Hello, world!"), Text::from("Hello, world!"));
512    }
513
514    #[rstest]
515    fn paragraph_soft_break(_with_tracing: DefaultGuard) {
516        assert_eq!(
517            from_str(indoc! {"
518                Hello
519                World
520            "}),
521            Text::from_iter(["Hello", "World"])
522        );
523    }
524
525    #[rstest]
526    fn paragraph_multiple(_with_tracing: DefaultGuard) {
527        assert_eq!(
528            from_str(indoc! {"
529                Paragraph 1
530                
531                Paragraph 2
532            "}),
533            Text::from_iter(["Paragraph 1", "", "Paragraph 2",])
534        );
535    }
536
537    #[rstest]
538    fn headings(_with_tracing: DefaultGuard) {
539        assert_eq!(
540            from_str(indoc! {"
541                # Heading 1
542                ## Heading 2
543                ### Heading 3
544                #### Heading 4
545                ##### Heading 5
546                ###### Heading 6
547            "}),
548            Text::from_iter([
549                Line::from_iter(["# ", "Heading 1"]).style(styles::H1),
550                Line::default(),
551                Line::from_iter(["## ", "Heading 2"]).style(styles::H2),
552                Line::default(),
553                Line::from_iter(["### ", "Heading 3"]).style(styles::H3),
554                Line::default(),
555                Line::from_iter(["#### ", "Heading 4"]).style(styles::H4),
556                Line::default(),
557                Line::from_iter(["##### ", "Heading 5"]).style(styles::H5),
558                Line::default(),
559                Line::from_iter(["###### ", "Heading 6"]).style(styles::H6),
560            ])
561        );
562    }
563
564    /// I was having difficulty getting the right number of newlines between paragraphs, so this
565    /// test is to help debug and ensure that.
566    #[rstest]
567    fn blockquote_after_paragraph(_with_tracing: DefaultGuard) {
568        assert_eq!(
569            from_str(indoc! {"
570                Hello, world!
571
572                > Blockquote
573            "}),
574            Text::from_iter([
575                Line::from("Hello, world!"),
576                Line::default(),
577                Line::from_iter([">", " ", "Blockquote"]).style(styles::BLOCKQUOTE),
578            ])
579        );
580    }
581    #[rstest]
582    fn blockquote_single(_with_tracing: DefaultGuard) {
583        assert_eq!(
584            from_str("> Blockquote"),
585            Text::from(Line::from_iter([">", " ", "Blockquote"]).style(styles::BLOCKQUOTE))
586        );
587    }
588
589    #[rstest]
590    fn blockquote_soft_break(_with_tracing: DefaultGuard) {
591        assert_eq!(
592            from_str(indoc! {"
593                > Blockquote 1
594                > Blockquote 2
595            "}),
596            Text::from_iter([
597                Line::from_iter([">", " ", "Blockquote 1"]).style(styles::BLOCKQUOTE),
598                Line::from_iter([">", " ", "Blockquote 2"]).style(styles::BLOCKQUOTE),
599            ])
600        );
601    }
602
603    #[rstest]
604    fn blockquote_multiple(_with_tracing: DefaultGuard) {
605        assert_eq!(
606            from_str(indoc! {"
607                > Blockquote 1
608                >
609                > Blockquote 2
610            "}),
611            Text::from_iter([
612                Line::from_iter([">", " ", "Blockquote 1"]).style(styles::BLOCKQUOTE),
613                Line::from_iter([">", " "]).style(styles::BLOCKQUOTE),
614                Line::from_iter([">", " ", "Blockquote 2"]).style(styles::BLOCKQUOTE),
615            ])
616        );
617    }
618
619    #[rstest]
620    fn blockquote_multiple_with_break(_with_tracing: DefaultGuard) {
621        assert_eq!(
622            from_str(indoc! {"
623                > Blockquote 1
624
625                > Blockquote 2
626            "}),
627            Text::from_iter([
628                Line::from_iter([">", " ", "Blockquote 1"]).style(styles::BLOCKQUOTE),
629                Line::default(),
630                Line::from_iter([">", " ", "Blockquote 2"]).style(styles::BLOCKQUOTE),
631            ])
632        );
633    }
634
635    #[rstest]
636    fn blockquote_nested(_with_tracing: DefaultGuard) {
637        assert_eq!(
638            from_str(indoc! {"
639                > Blockquote 1
640                >> Nested Blockquote
641            "}),
642            Text::from_iter([
643                Line::from_iter([">", " ", "Blockquote 1"]).style(styles::BLOCKQUOTE),
644                Line::from_iter([">", " "]).style(styles::BLOCKQUOTE),
645                Line::from_iter([">", ">", " ", "Nested Blockquote"]).style(styles::BLOCKQUOTE),
646            ])
647        );
648    }
649
650    #[rstest]
651    fn list_single(_with_tracing: DefaultGuard) {
652        assert_eq!(
653            from_str(indoc! {"
654                - List item 1
655            "}),
656            Text::from_iter([Line::from_iter(["- ", "List item 1"])])
657        );
658    }
659
660    #[rstest]
661    fn list_multiple(_with_tracing: DefaultGuard) {
662        assert_eq!(
663            from_str(indoc! {"
664                - List item 1
665                - List item 2
666            "}),
667            Text::from_iter([
668                Line::from_iter(["- ", "List item 1"]),
669                Line::from_iter(["- ", "List item 2"]),
670            ])
671        );
672    }
673
674    #[rstest]
675    fn list_ordered(_with_tracing: DefaultGuard) {
676        assert_eq!(
677            from_str(indoc! {"
678                1. List item 1
679                2. List item 2
680            "}),
681            Text::from_iter([
682                Line::from_iter(["1. ".light_blue(), "List item 1".into()]),
683                Line::from_iter(["2. ".light_blue(), "List item 2".into()]),
684            ])
685        );
686    }
687
688    #[rstest]
689    fn list_nested(_with_tracing: DefaultGuard) {
690        assert_eq!(
691            from_str(indoc! {"
692                - List item 1
693                  - Nested list item 1
694            "}),
695            Text::from_iter([
696                Line::from_iter(["- ", "List item 1"]),
697                Line::from_iter(["    - ", "Nested list item 1"]),
698            ])
699        );
700    }
701
702    #[rstest]
703    fn list_task_items(_with_tracing: DefaultGuard) {
704        assert_eq!(
705            from_str(indoc! {"
706                - [ ] Incomplete
707                - [x] Complete
708            "}),
709            Text::from_iter([
710                Line::from_iter(["- [ ] ", "Incomplete"]),
711                Line::from_iter(["- [x] ", "Complete"]),
712            ])
713        );
714    }
715
716    #[rstest]
717    fn list_task_items_ordered(_with_tracing: DefaultGuard) {
718        assert_eq!(
719            from_str(indoc! {"
720                1. [ ] Incomplete
721                2. [x] Complete
722            "}),
723            Text::from_iter([
724                Line::from_iter(["1. ".light_blue(), "[ ] ".into(), "Incomplete".into(),]),
725                Line::from_iter(["2. ".light_blue(), "[x] ".into(), "Complete".into(),]),
726            ])
727        );
728    }
729
730    #[cfg_attr(not(feature = "highlight-code"), ignore)]
731    #[rstest]
732    fn highlighted_code(_with_tracing: DefaultGuard) {
733        // Assert no extra newlines are added
734        let highlighted_code = from_str(indoc! {"
735            ```rust
736            fn main() {
737                println!(\"Hello, highlighted code!\");
738            }
739            ```"});
740
741        insta::assert_snapshot!(highlighted_code);
742        insta::assert_debug_snapshot!(highlighted_code);
743    }
744
745    #[cfg_attr(not(feature = "highlight-code"), ignore)]
746    #[rstest]
747    fn highlighted_code_with_indentation(_with_tracing: DefaultGuard) {
748        // Assert no extra newlines are added
749        let highlighted_code_indented = from_str(indoc! {"
750            ```rust
751            fn main() {
752                // This is a comment
753                HelloWorldBuilder::new()
754                    .with_text(\"Hello, highlighted code!\")
755                    .build()
756                    .show();
757                            
758            }
759            ```"});
760
761        insta::assert_snapshot!(highlighted_code_indented);
762        insta::assert_debug_snapshot!(highlighted_code_indented);
763    }
764
765    #[cfg_attr(feature = "highlight-code", ignore)]
766    #[rstest]
767    fn unhighlighted_code(_with_tracing: DefaultGuard) {
768        // Assert no extra newlines are added
769        let unhiglighted_code = from_str(indoc! {"
770            ```rust
771            fn main() {
772                println!(\"Hello, unhighlighted code!\");
773            }
774            ```"});
775
776        insta::assert_snapshot!(unhiglighted_code);
777
778        // Code highlighting is complex, assert on on the debug snapshot
779        insta::assert_debug_snapshot!(unhiglighted_code);
780    }
781
782    #[rstest]
783    fn inline_code(_with_tracing: DefaultGuard) {
784        let text = from_str("Example of `Inline code`");
785        insta::assert_snapshot!(text);
786
787        assert_eq!(
788            text,
789            Line::from_iter([
790                Span::from("Example of "),
791                Span::styled("Inline code", styles::CODE)
792            ])
793            .into()
794        );
795    }
796
797    #[rstest]
798    fn strong(_with_tracing: DefaultGuard) {
799        assert_eq!(
800            from_str("**Strong**"),
801            Text::from(Line::from("Strong".bold()))
802        );
803    }
804
805    #[rstest]
806    fn emphasis(_with_tracing: DefaultGuard) {
807        assert_eq!(
808            from_str("*Emphasis*"),
809            Text::from(Line::from("Emphasis".italic()))
810        );
811    }
812
813    #[rstest]
814    fn strikethrough(_with_tracing: DefaultGuard) {
815        assert_eq!(
816            from_str("~~Strikethrough~~"),
817            Text::from(Line::from("Strikethrough".crossed_out()))
818        );
819    }
820
821    #[rstest]
822    fn strong_emphasis(_with_tracing: DefaultGuard) {
823        assert_eq!(
824            from_str("**Strong *emphasis***"),
825            Text::from(Line::from_iter([
826                "Strong ".bold(),
827                "emphasis".bold().italic()
828            ]))
829        );
830    }
831
832    #[rstest]
833    fn link(_with_tracing: DefaultGuard) {
834        assert_eq!(
835            from_str("[Link](https://example.com)"),
836            Text::from(Line::from_iter([
837                Span::from("Link"),
838                Span::from(" ("),
839                Span::from("https://example.com").blue().underlined(),
840                Span::from(")")
841            ]))
842        );
843    }
844}