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