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