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