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::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 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 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 #[instrument(level = "trace", skip(self))]
423 fn push_link(&mut self, dest_url: CowStr<'a>) {
424 self.link = Some(dest_url);
425 }
426
427 #[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 #[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 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 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 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 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}