1#![cfg_attr(feature = "document-features", doc = "\n# Features")]
8#![cfg_attr(feature = "document-features", doc = document_features::document_features!())]
9#![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 iter: I,
76
77 text: Text<'a>,
79
80 inline_styles: Vec<Style>,
84
85 line_prefixes: Vec<Span<'a>>,
87
88 line_styles: Vec<Style>,
90
91 #[cfg(feature = "highlight-code")]
93 code_highlighter: Option<HighlightLines<'a>>,
94
95 list_indices: Vec<Option<u64>>,
97
98 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 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 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 #[instrument(level = "trace", skip(self))]
427 fn push_link(&mut self, dest_url: CowStr<'a>) {
428 self.link = Some(dest_url);
429 }
430
431 #[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 #[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 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 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 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 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}