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 as ParseOptions, Parser,
41 Tag, TagEnd,
42};
43use ratatui_core::style::{Style, Stylize};
44use ratatui_core::text::{Line, Span, Text};
45#[cfg(feature = "highlight-code")]
46use syntect::{
47 easy::HighlightLines,
48 highlighting::ThemeSet,
49 parsing::SyntaxSet,
50 util::{as_24_bit_terminal_escaped, LinesWithEndings},
51};
52use tracing::{debug, instrument, warn};
53
54pub use crate::options::Options;
55pub use crate::style_sheet::{DefaultStyleSheet, StyleSheet};
56
57mod options;
58mod style_sheet;
59
60pub fn from_str(input: &str) -> Text<'_> {
65 from_str_with_options(input, &Options::default())
66}
67
68pub fn from_str_with_options<'a, S>(input: &'a str, options: &Options<S>) -> Text<'a>
82where
83 S: StyleSheet,
84{
85 let mut parse_opts = ParseOptions::empty();
86 parse_opts.insert(ParseOptions::ENABLE_STRIKETHROUGH);
87 parse_opts.insert(ParseOptions::ENABLE_TASKLISTS);
88 parse_opts.insert(ParseOptions::ENABLE_HEADING_ATTRIBUTES);
89 parse_opts.insert(ParseOptions::ENABLE_YAML_STYLE_METADATA_BLOCKS);
90 parse_opts.insert(ParseOptions::ENABLE_SUPERSCRIPT);
91 parse_opts.insert(ParseOptions::ENABLE_SUBSCRIPT);
92 let parser = Parser::new_ext(input, parse_opts);
93
94 let mut writer = TextWriter::new(parser, options.styles.clone());
95 writer.run();
96 writer.text
97}
98
99struct HeadingMeta<'a> {
101 id: Option<CowStr<'a>>,
102 classes: Vec<CowStr<'a>>,
103 attrs: Vec<(CowStr<'a>, Option<CowStr<'a>>)>,
104}
105
106impl<'a> HeadingMeta<'a> {
107 fn into_option(self) -> Option<Self> {
108 let has_id = self.id.is_some();
109 let has_classes = !self.classes.is_empty();
110 let has_attrs = !self.attrs.is_empty();
111 if has_id || has_classes || has_attrs {
112 Some(self)
113 } else {
114 None
115 }
116 }
117
118 fn to_suffix(&self) -> Option<String> {
120 let mut parts = Vec::new();
121
122 if let Some(id) = &self.id {
123 parts.push(format!("#{}", id));
124 }
125
126 for class in &self.classes {
127 parts.push(format!(".{}", class));
128 }
129
130 for (key, value) in &self.attrs {
131 match value {
132 Some(value) => parts.push(format!("{}={}", key, value)),
133 None => parts.push(key.to_string()),
134 }
135 }
136
137 if parts.is_empty() {
138 None
139 } else {
140 Some(format!(" {{{}}}", parts.join(" ")))
141 }
142 }
143}
144
145struct TextWriter<'a, I, S: StyleSheet> {
146 iter: I,
148
149 text: Text<'a>,
151
152 inline_styles: Vec<Style>,
156
157 line_prefixes: Vec<Span<'a>>,
159
160 line_styles: Vec<Style>,
162
163 #[cfg(feature = "highlight-code")]
165 code_highlighter: Option<HighlightLines<'a>>,
166
167 list_indices: Vec<Option<u64>>,
169
170 link: Option<CowStr<'a>>,
172
173 styles: S,
175
176 heading_meta: Option<HeadingMeta<'a>>,
178
179 in_metadata_block: bool,
181
182 needs_newline: bool,
183}
184
185#[cfg(feature = "highlight-code")]
186static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
187#[cfg(feature = "highlight-code")]
188static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
189
190impl<'a, I, S> TextWriter<'a, I, S>
191where
192 I: Iterator<Item = Event<'a>>,
193 S: StyleSheet,
194{
195 fn new(iter: I, styles: S) -> Self {
196 Self {
197 iter,
198 text: Text::default(),
199 inline_styles: vec![],
200 line_styles: vec![],
201 line_prefixes: vec![],
202 list_indices: vec![],
203 needs_newline: false,
204 #[cfg(feature = "highlight-code")]
205 code_highlighter: None,
206 link: None,
207 styles,
208 heading_meta: None,
209 in_metadata_block: false,
210 }
211 }
212
213 fn run(&mut self) {
214 debug!("Running text writer");
215 while let Some(event) = self.iter.next() {
216 self.handle_event(event);
217 }
218 }
219
220 #[instrument(level = "debug", skip(self))]
221 fn handle_event(&mut self, event: Event<'a>) {
222 match event {
223 Event::Start(tag) => self.start_tag(tag),
224 Event::End(tag) => self.end_tag(tag),
225 Event::Text(text) => self.text(text),
226 Event::Code(code) => self.code(code),
227 Event::Html(_) => warn!("Html not yet supported"),
228 Event::InlineHtml(_) => warn!("Inline html not yet supported"),
229 Event::FootnoteReference(_) => warn!("Footnote reference not yet supported"),
230 Event::SoftBreak => self.soft_break(),
231 Event::HardBreak => self.hard_break(),
232 Event::Rule => self.rule(),
233 Event::TaskListMarker(checked) => self.task_list_marker(checked),
234 Event::InlineMath(_) => warn!("Inline math not yet supported"),
235 Event::DisplayMath(_) => warn!("Display math not yet supported"),
236 }
237 }
238
239 fn start_tag(&mut self, tag: Tag<'a>) {
240 match tag {
241 Tag::Paragraph => self.start_paragraph(),
242 Tag::Heading {
243 level,
244 id,
245 classes,
246 attrs,
247 } => self.start_heading(level, HeadingMeta { id, classes, attrs }),
248 Tag::BlockQuote(kind) => self.start_blockquote(kind),
249 Tag::CodeBlock(kind) => self.start_codeblock(kind),
250 Tag::HtmlBlock => warn!("Html block not yet supported"),
251 Tag::List(start_index) => self.start_list(start_index),
252 Tag::Item => self.start_item(),
253 Tag::FootnoteDefinition(_) => warn!("Footnote definition not yet supported"),
254 Tag::Table(_) => warn!("Table not yet supported"),
255 Tag::TableHead => warn!("Table head not yet supported"),
256 Tag::TableRow => warn!("Table row not yet supported"),
257 Tag::TableCell => warn!("Table cell not yet supported"),
258 Tag::Emphasis => self.push_inline_style(Style::new().italic()),
259 Tag::Strong => self.push_inline_style(Style::new().bold()),
260 Tag::Strikethrough => self.push_inline_style(Style::new().crossed_out()),
261 Tag::Subscript => self.push_inline_style(Style::new().dim().italic()),
262 Tag::Superscript => self.push_inline_style(Style::new().dim().italic()),
263 Tag::Link { dest_url, .. } => self.push_link(dest_url),
264 Tag::Image { .. } => warn!("Image not yet supported"),
265 Tag::MetadataBlock(_) => self.start_metadata_block(),
266 Tag::DefinitionList => warn!("Definition list not yet supported"),
267 Tag::DefinitionListTitle => warn!("Definition list title not yet supported"),
268 Tag::DefinitionListDefinition => warn!("Definition list definition not yet supported"),
269 }
270 }
271
272 fn end_tag(&mut self, tag: TagEnd) {
273 match tag {
274 TagEnd::Paragraph => self.end_paragraph(),
275 TagEnd::Heading(_) => self.end_heading(),
276 TagEnd::BlockQuote(_) => self.end_blockquote(),
277 TagEnd::CodeBlock => self.end_codeblock(),
278 TagEnd::HtmlBlock => {}
279 TagEnd::List(_is_ordered) => self.end_list(),
280 TagEnd::Item => {}
281 TagEnd::FootnoteDefinition => {}
282 TagEnd::Table => {}
283 TagEnd::TableHead => {}
284 TagEnd::TableRow => {}
285 TagEnd::TableCell => {}
286 TagEnd::Emphasis => self.pop_inline_style(),
287 TagEnd::Strong => self.pop_inline_style(),
288 TagEnd::Strikethrough => self.pop_inline_style(),
289 TagEnd::Subscript => self.pop_inline_style(),
290 TagEnd::Superscript => self.pop_inline_style(),
291 TagEnd::Link => self.pop_link(),
292 TagEnd::Image => {}
293 TagEnd::MetadataBlock(_) => self.end_metadata_block(),
294 TagEnd::DefinitionList => {}
295 TagEnd::DefinitionListTitle => {}
296 TagEnd::DefinitionListDefinition => {}
297 }
298 }
299
300 fn start_paragraph(&mut self) {
301 if self.needs_newline {
303 self.push_line(Line::default());
304 }
305 self.push_line(Line::default());
306 self.needs_newline = false;
307 }
308
309 fn end_paragraph(&mut self) {
310 self.needs_newline = true
311 }
312
313 fn start_heading(&mut self, level: HeadingLevel, heading_meta: HeadingMeta<'a>) {
314 if self.needs_newline {
315 self.push_line(Line::default());
316 }
317 let heading_level = match level {
318 HeadingLevel::H1 => 1,
319 HeadingLevel::H2 => 2,
320 HeadingLevel::H3 => 3,
321 HeadingLevel::H4 => 4,
322 HeadingLevel::H5 => 5,
323 HeadingLevel::H6 => 6,
324 };
325 let style = self.styles.heading(heading_level);
326 let content = format!("{} ", "#".repeat(heading_level as usize));
327 self.push_line(Line::styled(content, style));
328 self.heading_meta = heading_meta.into_option();
329 self.needs_newline = false;
330 }
331
332 fn end_heading(&mut self) {
333 if let Some(meta) = self.heading_meta.take() {
334 if let Some(suffix) = meta.to_suffix() {
335 self.push_span(Span::styled(suffix, self.styles.heading_meta()));
336 }
337 }
338 self.needs_newline = true
339 }
340
341 fn start_blockquote(&mut self, _kind: Option<BlockQuoteKind>) {
342 if self.needs_newline {
343 self.push_line(Line::default());
344 self.needs_newline = false;
345 }
346 self.line_prefixes.push(Span::from(">"));
347 self.line_styles.push(self.styles.blockquote());
348 }
349
350 fn end_blockquote(&mut self) {
351 self.line_prefixes.pop();
352 self.line_styles.pop();
353 self.needs_newline = true;
354 }
355
356 fn text(&mut self, text: CowStr<'a>) {
357 #[cfg(feature = "highlight-code")]
358 if let Some(highlighter) = &mut self.code_highlighter {
359 let text: Text = LinesWithEndings::from(&text)
360 .filter_map(|line| highlighter.highlight_line(line, &SYNTAX_SET).ok())
361 .filter_map(|part| as_24_bit_terminal_escaped(&part, false).into_text().ok())
362 .flatten()
363 .collect();
364
365 for line in text.lines {
366 self.text.push_line(line);
367 }
368 self.needs_newline = false;
369 return;
370 }
371
372 for (position, line) in text.lines().with_position() {
373 if self.needs_newline {
374 self.push_line(Line::default());
375 self.needs_newline = false;
376 }
377 if matches!(position, Position::Middle | Position::Last) {
378 self.push_line(Line::default());
379 }
380
381 let style = self.inline_styles.last().copied().unwrap_or_default();
382
383 let span = Span::styled(line.to_owned(), style);
384
385 self.push_span(span);
386 }
387 self.needs_newline = false;
388 }
389
390 fn code(&mut self, code: CowStr<'a>) {
391 let span = Span::styled(code, self.styles.code());
392 self.push_span(span);
393 }
394
395 fn hard_break(&mut self) {
396 self.push_line(Line::default());
397 }
398
399 fn start_metadata_block(&mut self) {
400 if self.needs_newline {
401 self.push_line(Line::default());
402 }
403 self.line_styles.push(self.styles.metadata_block());
404 self.push_line(Line::from("---"));
405 self.push_line(Line::default());
406 self.in_metadata_block = true;
407 }
408
409 fn end_metadata_block(&mut self) {
410 if self.in_metadata_block {
411 self.push_line(Line::from("---"));
412 self.line_styles.pop();
413 self.in_metadata_block = false;
414 self.needs_newline = true;
415 }
416 }
417
418 fn rule(&mut self) {
419 if self.needs_newline {
420 self.push_line(Line::default());
421 }
422 self.push_line(Line::from("---"));
423 self.needs_newline = true;
424 }
425
426 fn start_list(&mut self, index: Option<u64>) {
427 if self.list_indices.is_empty() && self.needs_newline {
428 self.push_line(Line::default());
429 }
430 self.list_indices.push(index);
431 }
432
433 fn end_list(&mut self) {
434 self.list_indices.pop();
435 self.needs_newline = true;
436 }
437
438 fn start_item(&mut self) {
439 self.push_line(Line::default());
440 let width = self.list_indices.len() * 4 - 3;
441 if let Some(last_index) = self.list_indices.last_mut() {
442 let span = match last_index {
443 None => Span::from(" ".repeat(width - 1) + "- "),
444 Some(index) => {
445 *index += 1;
446 format!("{:width$}. ", *index - 1).light_blue()
447 }
448 };
449 self.push_span(span);
450 }
451 self.needs_newline = false;
452 }
453
454 fn task_list_marker(&mut self, checked: bool) {
455 let marker = if checked { 'x' } else { ' ' };
456 let marker_span = Span::from(format!("[{}] ", marker));
457 if let Some(line) = self.text.lines.last_mut() {
458 if let Some(first_span) = line.spans.first_mut() {
459 let content = first_span.content.to_mut();
460 if content.ends_with("- ") {
461 let len = content.len();
462 content.truncate(len - 2);
463 content.push_str("- [");
464 content.push(marker);
465 content.push_str("] ");
466 return;
467 }
468 }
469 line.spans.insert(1, marker_span);
470 } else {
471 self.push_span(marker_span);
472 }
473 }
474
475 fn soft_break(&mut self) {
476 if self.in_metadata_block {
477 self.hard_break();
478 } else {
479 self.push_span(Span::raw(" "));
480 }
481 }
482
483 fn start_codeblock(&mut self, kind: CodeBlockKind<'_>) {
484 if !self.text.lines.is_empty() {
485 self.push_line(Line::default());
486 }
487 let lang = match kind {
488 CodeBlockKind::Fenced(ref lang) => lang.as_ref(),
489 CodeBlockKind::Indented => "",
490 };
491
492 #[cfg(not(feature = "highlight-code"))]
493 self.line_styles.push(self.styles.code());
494
495 #[cfg(feature = "highlight-code")]
496 self.set_code_highlighter(lang);
497
498 let span = Span::from(format!("```{lang}"));
499 self.push_line(span.into());
500 self.needs_newline = true;
501 }
502
503 fn end_codeblock(&mut self) {
504 let span = Span::from("```");
505 self.push_line(span.into());
506 self.needs_newline = true;
507
508 #[cfg(not(feature = "highlight-code"))]
509 self.line_styles.pop();
510
511 #[cfg(feature = "highlight-code")]
512 self.clear_code_highlighter();
513 }
514
515 #[cfg(feature = "highlight-code")]
516 #[instrument(level = "trace", skip(self))]
517 fn set_code_highlighter(&mut self, lang: &str) {
518 if let Some(syntax) = SYNTAX_SET.find_syntax_by_token(lang) {
519 debug!("Starting code block with syntax: {:?}", lang);
520 let theme = &THEME_SET.themes["base16-ocean.dark"];
521 let highlighter = HighlightLines::new(syntax, theme);
522 self.code_highlighter = Some(highlighter);
523 } else {
524 warn!("Could not find syntax for code block: {:?}", lang);
525 }
526 }
527
528 #[cfg(feature = "highlight-code")]
529 #[instrument(level = "trace", skip(self))]
530 fn clear_code_highlighter(&mut self) {
531 self.code_highlighter = None;
532 }
533
534 #[instrument(level = "trace", skip(self))]
535 fn push_inline_style(&mut self, style: Style) {
536 let current_style = self.inline_styles.last().copied().unwrap_or_default();
537 let style = current_style.patch(style);
538 self.inline_styles.push(style);
539 debug!("Pushed inline style: {:?}", style);
540 debug!("Current inline styles: {:?}", self.inline_styles);
541 }
542
543 #[instrument(level = "trace", skip(self))]
544 fn pop_inline_style(&mut self) {
545 self.inline_styles.pop();
546 }
547
548 #[instrument(level = "trace", skip(self))]
549 fn push_line(&mut self, line: Line<'a>) {
550 let style = self.line_styles.last().copied().unwrap_or_default();
551 let mut line = line.patch_style(style);
552
553 let line_prefixes = self.line_prefixes.iter().cloned().collect_vec();
555 let has_prefixes = !line_prefixes.is_empty();
556 if has_prefixes {
557 line.spans.insert(0, " ".into());
558 }
559 for prefix in line_prefixes.iter().rev().cloned() {
560 line.spans.insert(0, prefix);
561 }
562 self.text.lines.push(line);
563 }
564
565 #[instrument(level = "trace", skip(self))]
566 fn push_span(&mut self, span: Span<'a>) {
567 if let Some(line) = self.text.lines.last_mut() {
568 line.push_span(span);
569 } else {
570 self.push_line(Line::from(vec![span]));
571 }
572 }
573
574 #[instrument(level = "trace", skip(self))]
576 fn push_link(&mut self, dest_url: CowStr<'a>) {
577 self.link = Some(dest_url);
578 }
579
580 #[instrument(level = "trace", skip(self))]
582 fn pop_link(&mut self) {
583 if let Some(link) = self.link.take() {
584 self.push_span(" (".into());
585 self.push_span(Span::styled(link, self.styles.link()));
586 self.push_span(")".into());
587 }
588 }
589}
590
591#[cfg(test)]
592mod tests {
593 use indoc::indoc;
594 use pretty_assertions::assert_eq;
595 use rstest::{fixture, rstest};
596 use tracing::level_filters::LevelFilter;
597 use tracing::subscriber::{self, DefaultGuard};
598 use tracing_subscriber::fmt::format::FmtSpan;
599 use tracing_subscriber::fmt::time::Uptime;
600
601 use super::*;
602
603 #[fixture]
604 fn with_tracing() -> DefaultGuard {
605 let subscriber = tracing_subscriber::fmt()
606 .with_test_writer()
607 .with_timer(Uptime::default())
608 .with_max_level(LevelFilter::TRACE)
609 .with_span_events(FmtSpan::ENTER)
610 .finish();
611 subscriber::set_default(subscriber)
612 }
613
614 #[rstest]
615 fn empty(_with_tracing: DefaultGuard) {
616 assert_eq!(from_str(""), Text::default());
617 }
618
619 #[rstest]
620 fn paragraph_single(_with_tracing: DefaultGuard) {
621 assert_eq!(from_str("Hello, world!"), Text::from("Hello, world!"));
622 }
623
624 #[rstest]
625 fn paragraph_soft_break(_with_tracing: DefaultGuard) {
626 assert_eq!(
627 from_str(indoc! {"
628 Hello
629 World
630 "}),
631 Text::from(Line::from_iter([
632 Span::from("Hello"),
633 Span::from(" "),
634 Span::from("World"),
635 ]))
636 );
637 }
638
639 #[rstest]
640 fn paragraph_multiple(_with_tracing: DefaultGuard) {
641 assert_eq!(
642 from_str(indoc! {"
643 Paragraph 1
644
645 Paragraph 2
646 "}),
647 Text::from_iter(["Paragraph 1", "", "Paragraph 2",])
648 );
649 }
650
651 #[rstest]
652 fn rule(_with_tracing: DefaultGuard) {
653 assert_eq!(
654 from_str(indoc! {"
655 Paragraph 1
656
657 ---
658
659 Paragraph 2
660 "}),
661 Text::from_iter(["Paragraph 1", "", "---", "", "Paragraph 2"])
662 );
663 }
664
665 #[rstest]
666 fn headings(_with_tracing: DefaultGuard) {
667 let h1 = Style::new().on_cyan().bold().underlined();
668 let h2 = Style::new().cyan().bold();
669 let h3 = Style::new().cyan().bold().italic();
670 let h4 = Style::new().light_cyan().italic();
671 let h5 = Style::new().light_cyan().italic();
672 let h6 = Style::new().light_cyan().italic();
673
674 assert_eq!(
675 from_str(indoc! {"
676 # Heading 1
677 ## Heading 2
678 ### Heading 3
679 #### Heading 4
680 ##### Heading 5
681 ###### Heading 6
682 "}),
683 Text::from_iter([
684 Line::from_iter(["# ", "Heading 1"]).style(h1),
685 Line::default(),
686 Line::from_iter(["## ", "Heading 2"]).style(h2),
687 Line::default(),
688 Line::from_iter(["### ", "Heading 3"]).style(h3),
689 Line::default(),
690 Line::from_iter(["#### ", "Heading 4"]).style(h4),
691 Line::default(),
692 Line::from_iter(["##### ", "Heading 5"]).style(h5),
693 Line::default(),
694 Line::from_iter(["###### ", "Heading 6"]).style(h6),
695 ])
696 );
697 }
698
699 #[rstest]
700 fn heading_attributes(_with_tracing: DefaultGuard) {
701 let h1 = Style::new().on_cyan().bold().underlined();
702 let meta = Style::new().dim();
703
704 assert_eq!(
705 from_str("# Heading {#title .primary data-kind=doc}"),
706 Text::from(
707 Line::from_iter([
708 Span::from("# "),
709 Span::from("Heading"),
710 Span::styled(" {#title .primary data-kind=doc}", meta),
711 ])
712 .style(h1)
713 )
714 );
715 }
716
717 mod blockquote {
718 use pretty_assertions::assert_eq;
719 use ratatui::style::Color;
720
721 use super::*;
722
723 const STYLE: Style = Style::new().fg(Color::Green);
724
725 #[rstest]
728 fn after_paragraph(_with_tracing: DefaultGuard) {
729 assert_eq!(
730 from_str(indoc! {"
731 Hello, world!
732
733 > Blockquote
734 "}),
735 Text::from_iter([
736 Line::from("Hello, world!"),
737 Line::default(),
738 Line::from_iter([">", " ", "Blockquote"]).style(STYLE),
739 ])
740 );
741 }
742 #[rstest]
743 fn single(_with_tracing: DefaultGuard) {
744 assert_eq!(
745 from_str("> Blockquote"),
746 Text::from(Line::from_iter([">", " ", "Blockquote"]).style(STYLE))
747 );
748 }
749
750 #[rstest]
751 fn soft_break(_with_tracing: DefaultGuard) {
752 assert_eq!(
753 from_str(indoc! {"
754 > Blockquote 1
755 > Blockquote 2
756 "}),
757 Text::from(
758 Line::from_iter([">", " ", "Blockquote 1", " ", "Blockquote 2"]).style(STYLE)
759 )
760 );
761 }
762
763 #[rstest]
764 fn multiple(_with_tracing: DefaultGuard) {
765 assert_eq!(
766 from_str(indoc! {"
767 > Blockquote 1
768 >
769 > Blockquote 2
770 "}),
771 Text::from_iter([
772 Line::from_iter([">", " ", "Blockquote 1"]).style(STYLE),
773 Line::from_iter([">", " "]).style(STYLE),
774 Line::from_iter([">", " ", "Blockquote 2"]).style(STYLE),
775 ])
776 );
777 }
778
779 #[rstest]
780 fn multiple_with_break(_with_tracing: DefaultGuard) {
781 assert_eq!(
782 from_str(indoc! {"
783 > Blockquote 1
784
785 > Blockquote 2
786 "}),
787 Text::from_iter([
788 Line::from_iter([">", " ", "Blockquote 1"]).style(STYLE),
789 Line::default(),
790 Line::from_iter([">", " ", "Blockquote 2"]).style(STYLE),
791 ])
792 );
793 }
794
795 #[rstest]
796 fn nested(_with_tracing: DefaultGuard) {
797 assert_eq!(
798 from_str(indoc! {"
799 > Blockquote 1
800 >> Nested Blockquote
801 "}),
802 Text::from_iter([
803 Line::from_iter([">", " ", "Blockquote 1"]).style(STYLE),
804 Line::from_iter([">", " "]).style(STYLE),
805 Line::from_iter([">", ">", " ", "Nested Blockquote"]).style(STYLE),
806 ])
807 );
808 }
809 }
810
811 #[rstest]
812 fn list_single(_with_tracing: DefaultGuard) {
813 assert_eq!(
814 from_str(indoc! {"
815 - List item 1
816 "}),
817 Text::from_iter([Line::from_iter(["- ", "List item 1"])])
818 );
819 }
820
821 #[rstest]
822 fn list_multiple(_with_tracing: DefaultGuard) {
823 assert_eq!(
824 from_str(indoc! {"
825 - List item 1
826 - List item 2
827 "}),
828 Text::from_iter([
829 Line::from_iter(["- ", "List item 1"]),
830 Line::from_iter(["- ", "List item 2"]),
831 ])
832 );
833 }
834
835 #[rstest]
836 fn list_ordered(_with_tracing: DefaultGuard) {
837 assert_eq!(
838 from_str(indoc! {"
839 1. List item 1
840 2. List item 2
841 "}),
842 Text::from_iter([
843 Line::from_iter(["1. ".light_blue(), "List item 1".into()]),
844 Line::from_iter(["2. ".light_blue(), "List item 2".into()]),
845 ])
846 );
847 }
848
849 #[rstest]
850 fn list_nested(_with_tracing: DefaultGuard) {
851 assert_eq!(
852 from_str(indoc! {"
853 - List item 1
854 - Nested list item 1
855 "}),
856 Text::from_iter([
857 Line::from_iter(["- ", "List item 1"]),
858 Line::from_iter([" - ", "Nested list item 1"]),
859 ])
860 );
861 }
862
863 #[rstest]
864 fn list_task_items(_with_tracing: DefaultGuard) {
865 assert_eq!(
866 from_str(indoc! {"
867 - [ ] Incomplete
868 - [x] Complete
869 "}),
870 Text::from_iter([
871 Line::from_iter(["- [ ] ", "Incomplete"]),
872 Line::from_iter(["- [x] ", "Complete"]),
873 ])
874 );
875 }
876
877 #[rstest]
878 fn list_task_items_ordered(_with_tracing: DefaultGuard) {
879 assert_eq!(
880 from_str(indoc! {"
881 1. [ ] Incomplete
882 2. [x] Complete
883 "}),
884 Text::from_iter([
885 Line::from_iter(["1. ".light_blue(), "[ ] ".into(), "Incomplete".into(),]),
886 Line::from_iter(["2. ".light_blue(), "[x] ".into(), "Complete".into(),]),
887 ])
888 );
889 }
890
891 #[cfg_attr(not(feature = "highlight-code"), ignore)]
892 #[rstest]
893 fn highlighted_code(_with_tracing: DefaultGuard) {
894 let highlighted_code = from_str(indoc! {"
896 ```rust
897 fn main() {
898 println!(\"Hello, highlighted code!\");
899 }
900 ```"});
901
902 insta::assert_snapshot!(highlighted_code);
903 insta::assert_debug_snapshot!(highlighted_code);
904 }
905
906 #[cfg_attr(not(feature = "highlight-code"), ignore)]
907 #[rstest]
908 fn highlighted_code_with_indentation(_with_tracing: DefaultGuard) {
909 let highlighted_code_indented = from_str(indoc! {"
911 ```rust
912 fn main() {
913 // This is a comment
914 HelloWorldBuilder::new()
915 .with_text(\"Hello, highlighted code!\")
916 .build()
917 .show();
918
919 }
920 ```"});
921
922 insta::assert_snapshot!(highlighted_code_indented);
923 insta::assert_debug_snapshot!(highlighted_code_indented);
924 }
925
926 #[cfg_attr(feature = "highlight-code", ignore)]
927 #[rstest]
928 fn unhighlighted_code(_with_tracing: DefaultGuard) {
929 let unhiglighted_code = from_str(indoc! {"
931 ```rust
932 fn main() {
933 println!(\"Hello, unhighlighted code!\");
934 }
935 ```"});
936
937 insta::assert_snapshot!(unhiglighted_code);
938
939 insta::assert_debug_snapshot!(unhiglighted_code);
941 }
942
943 #[rstest]
944 fn inline_code(_with_tracing: DefaultGuard) {
945 let text = from_str("Example of `Inline code`");
946 insta::assert_snapshot!(text);
947
948 assert_eq!(
949 text,
950 Line::from_iter([
951 Span::from("Example of "),
952 Span::styled("Inline code", Style::new().white().on_black())
953 ])
954 .into()
955 );
956 }
957
958 #[rstest]
959 fn superscript(_with_tracing: DefaultGuard) {
960 assert_eq!(
961 from_str("H ^2^ O"),
962 Text::from(Line::from_iter([
963 Span::from("H "),
964 Span::styled("2", Style::new().dim().italic()),
965 Span::from(" O"),
966 ]))
967 );
968 }
969
970 #[rstest]
971 fn subscript(_with_tracing: DefaultGuard) {
972 assert_eq!(
973 from_str("H ~2~ O"),
974 Text::from(Line::from_iter([
975 Span::from("H "),
976 Span::styled("2", Style::new().dim().italic()),
977 Span::from(" O"),
978 ]))
979 );
980 }
981
982 #[rstest]
983 fn metadata_block(_with_tracing: DefaultGuard) {
984 assert_eq!(
985 from_str(indoc! {"
986 ---
987 title: Demo
988 ---
989
990 Body
991 "}),
992 Text::from_iter([
993 Line::from("---").style(Style::new().light_yellow()),
994 Line::from("title: Demo").style(Style::new().light_yellow()),
995 Line::from("---").style(Style::new().light_yellow()),
996 Line::default(),
997 Line::from("Body"),
998 ])
999 );
1000 }
1001
1002 #[rstest]
1003 fn strong(_with_tracing: DefaultGuard) {
1004 assert_eq!(
1005 from_str("**Strong**"),
1006 Text::from(Line::from("Strong".bold()))
1007 );
1008 }
1009
1010 #[rstest]
1011 fn emphasis(_with_tracing: DefaultGuard) {
1012 assert_eq!(
1013 from_str("*Emphasis*"),
1014 Text::from(Line::from("Emphasis".italic()))
1015 );
1016 }
1017
1018 #[rstest]
1019 fn strikethrough(_with_tracing: DefaultGuard) {
1020 assert_eq!(
1021 from_str("~~Strikethrough~~"),
1022 Text::from(Line::from("Strikethrough".crossed_out()))
1023 );
1024 }
1025
1026 #[rstest]
1027 fn strong_emphasis(_with_tracing: DefaultGuard) {
1028 assert_eq!(
1029 from_str("**Strong *emphasis***"),
1030 Text::from(Line::from_iter([
1031 "Strong ".bold(),
1032 "emphasis".bold().italic()
1033 ]))
1034 );
1035 }
1036
1037 #[rstest]
1038 fn link(_with_tracing: DefaultGuard) {
1039 assert_eq!(
1040 from_str("[Link](https://example.com)"),
1041 Text::from(Line::from_iter([
1042 Span::from("Link"),
1043 Span::from(" ("),
1044 Span::from("https://example.com").blue().underlined(),
1045 Span::from(")")
1046 ]))
1047 );
1048 }
1049}