1#![cfg_attr(feature = "document-features", doc = "\n# Features")]
7#![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
8use 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 iter: I,
65
66 text: Text<'a>,
68
69 inline_styles: Vec<Style>,
73
74 line_prefixes: Vec<Span<'a>>,
76
77 line_styles: Vec<Style>,
79
80 #[cfg(feature = "highlight-code")]
82 code_highlighter: Option<HighlightLines<'a>>,
83
84 list_indices: Vec<Option<u64>>,
86
87 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 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 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 #[instrument(level = "trace", skip(self))]
416 fn push_link(&mut self, dest_url: CowStr<'a>) {
417 self.link = Some(dest_url);
418 }
419
420 #[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 #[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 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 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 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 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}