1use std::sync::Arc;
15
16use pulldown_cmark::{
17 Alignment, CodeBlockKind, CowStr, Event, HeadingLevel, LinkType, Options, Parser, Tag, TagEnd,
18};
19
20use crate::cells::cell_len;
21use crate::console::{Console, ConsoleOptions, Justify, Overflow};
22use crate::markdown_url::{normalize_link, normalize_link_text, validate_link};
23use crate::protocol::{CodeHighlighter, FenceRenderer, Renderable};
24use crate::r#box::SIMPLE;
25use crate::segment::Segment;
26use crate::style::Style;
27use crate::syntax::Syntax;
28use crate::table::Table;
29use crate::text::Text;
30
31const CODE_STYLE: &str = "bold cyan on black"; const QUOTE_STYLE: &str = "magenta"; const IMAGE_MARKER: &str = "\u{1f306} ";
36const BULLET: &str = " \u{2022} "; const QUOTE_PREFIX: &str = "\u{258c} "; const LINK_STYLE: &str = "bright_blue"; const LINK_URL_STYLE: &str = "underline blue"; const TABLE_BORDER_STYLE: &str = "cyan"; const TABLE_HEADER_STYLE: &str = "not bold cyan"; struct ListEntry {
49 number: Option<u64>,
50 blocks: Vec<Block>,
51}
52
53enum Frame {
61 List {
62 ordered: bool,
63 start: u64,
64 entries: Vec<ListEntry>,
65 },
66 Item {
67 blocks: Vec<Block>,
68 },
69 Quote {
70 blocks: Vec<Block>,
71 },
72}
73
74enum Block {
76 Text(Text, Option<u8>),
79 List { items: Vec<ListEntry> },
82 Quote {
84 blocks: Vec<Block>,
85 leading_break: bool,
86 },
87 Html,
89 Code {
91 language: String,
92 code: String,
93 theme: Option<String>,
95 highlighter: Option<Arc<dyn CodeHighlighter>>,
97 fences: Vec<Arc<dyn FenceRenderer>>,
100 },
101 Rule,
103 Image {
116 text: Text,
117 joins_next: bool,
118 leading_break: bool,
119 },
120 Table {
123 alignments: Vec<Justify>,
124 headers: Vec<Text>,
125 rows: Vec<Vec<Text>>,
126 },
127}
128
129#[derive(Default)]
131struct TableAccum {
132 alignments: Vec<Justify>,
133 headers: Vec<Text>,
134 rows: Vec<Vec<Text>>,
135 in_head: bool,
136 in_cell: bool,
137 cur_row: Vec<Text>,
138 cur_cell: Text,
141}
142
143fn inline_target<'a>(
146 current: &'a mut Option<Text>,
147 table: &'a mut Option<TableAccum>,
148) -> &'a mut Text {
149 match table.as_mut().filter(|acc| acc.in_cell) {
150 Some(acc) => &mut acc.cur_cell,
151 None => current.get_or_insert_with(|| Text::new("")),
152 }
153}
154
155fn alignment_justify(alignment: Alignment) -> Justify {
156 match alignment {
157 Alignment::Right => Justify::Right,
158 Alignment::Center => Justify::Center,
159 Alignment::Left | Alignment::None => Justify::Left,
161 }
162}
163
164pub struct Markdown {
166 source: String,
167 options: MarkdownOptions,
168 blocks: Vec<Block>,
169}
170
171#[derive(Clone, Default)]
174struct MarkdownOptions {
175 no_hyperlinks: bool,
178 justify: Option<Justify>,
180 style: Option<Style>,
182 code_theme: Option<String>,
184 inline_code_lexer: Option<String>,
186 inline_code_theme: Option<String>,
188 highlighter: Option<Arc<dyn CodeHighlighter>>,
190 fences: Vec<Arc<dyn FenceRenderer>>,
192}
193
194impl Markdown {
195 pub fn new(source: &str) -> Self {
200 let options = MarkdownOptions::default();
201 Markdown {
202 source: source.to_string(),
203 blocks: parse(source, &options),
204 options,
205 }
206 }
207
208 pub fn hyperlinks(mut self, hyperlinks: bool) -> Self {
228 self.options.no_hyperlinks = !hyperlinks;
231 self.reparse()
232 }
233
234 pub fn justify(mut self, justify: Justify) -> Self {
237 self.options.justify = Some(justify);
238 self.reparse()
239 }
240
241 pub fn style(mut self, style: Style) -> Self {
244 self.options.style = Some(style).filter(|style| !style.is_null());
245 self.reparse()
246 }
247
248 pub fn code_theme(mut self, theme: impl Into<String>) -> Self {
252 self.options.code_theme = Some(theme.into());
253 self.reparse()
254 }
255
256 pub fn inline_code_lexer(mut self, lexer: impl Into<String>) -> Self {
259 self.options.inline_code_lexer = Some(lexer.into());
260 self.reparse()
261 }
262
263 pub fn inline_code_theme(mut self, theme: impl Into<String>) -> Self {
266 self.options.inline_code_theme = Some(theme.into());
267 self.reparse()
268 }
269
270 pub fn highlighter(mut self, highlighter: Arc<dyn CodeHighlighter>) -> Self {
275 self.options.highlighter = Some(highlighter);
276 self.reparse()
277 }
278
279 pub fn fence_renderer(mut self, renderer: Arc<dyn FenceRenderer>) -> Self {
284 self.options.fences.push(renderer);
285 self.reparse()
286 }
287
288 fn reparse(mut self) -> Self {
289 self.blocks = parse(&self.source, &self.options);
290 self
291 }
292}
293
294fn heading_level(level: HeadingLevel) -> usize {
295 match level {
296 HeadingLevel::H1 => 1,
297 HeadingLevel::H2 => 2,
298 HeadingLevel::H3 => 3,
299 HeadingLevel::H4 => 4,
300 HeadingLevel::H5 => 5,
301 HeadingLevel::H6 => 6,
302 }
303}
304
305fn heading_format(level: usize) -> (Style, Justify) {
308 let (spec, justify) = match level {
309 1 => ("bold underline", Justify::Center),
310 2 => ("underline magenta", Justify::Left),
311 3 => ("bold magenta", Justify::Left),
312 4 => ("italic magenta", Justify::Left),
313 5 => ("italic", Justify::Left),
314 _ => ("dim", Justify::Left),
315 };
316 (Style::parse(spec).unwrap_or_default(), justify)
317}
318
319fn inline_style(strong: usize, emphasis: usize, strike: usize) -> Option<Style> {
320 if strong == 0 && emphasis == 0 && strike == 0 {
321 return None;
322 }
323 let mut style = Style::new();
324 if strong > 0 {
325 style = style.combine(&Style::parse("bold").expect("valid style"));
326 }
327 if emphasis > 0 {
328 style = style.combine(&Style::parse("italic").expect("valid style"));
329 }
330 if strike > 0 {
331 style = style.combine(&Style::parse("strike").expect("valid style"));
333 }
334 Some(style)
335}
336
337fn link_style(url: &str) -> Style {
340 Style::parse(LINK_URL_STYLE)
341 .expect("valid style")
342 .with_link(url.to_string())
343}
344
345fn quote_root(md: &MarkdownOptions, stack: &[Frame]) -> Option<Style> {
360 let mut root = md.style.clone();
361 if stack
362 .iter()
363 .any(|frame| matches!(frame, Frame::Quote { .. }))
364 {
365 let quote = Style::parse(QUOTE_STYLE).expect("valid style");
366 root = Some(match root {
367 Some(root) => root.combine("e),
368 None => quote,
369 });
370 }
371 root
372}
373
374fn stack_style(
375 root: Option<&Style>,
376 heading: Option<&Style>,
377 inline: Option<Style>,
378 link: Option<&str>,
379 extra: Option<Style>,
380) -> Option<Style> {
381 let mut current: Option<Style> = None;
382 for layer in [
383 root.cloned(),
384 heading.cloned(),
385 inline,
386 link.map(link_style),
387 extra,
388 ] {
389 let Some(next) = layer else { continue };
390 current = Some(match current {
391 Some(previous) => previous.combine(&next),
392 None => next,
393 });
394 }
395 current
396}
397
398fn image_fallback_title(destination: &str) -> &str {
404 let trimmed = destination.trim_matches('/');
405 match trimmed.rsplit_once('/') {
406 Some((_, last)) => last,
407 None => trimmed,
408 }
409}
410
411fn image_text(
421 destination: &str,
422 alt: Text,
423 link: Option<&str>,
424 outer: Option<Style>,
425 hyperlinks: bool,
426) -> Text {
427 let mut title = if alt.plain().is_empty() {
428 Text::new(image_fallback_title(destination))
429 } else {
430 alt
431 };
432 let end = title.plain().len();
433 if let Some(style) = outer {
437 title.stylize(style, 0, end);
438 }
439 if hyperlinks {
443 let target = link.unwrap_or(destination);
444 if !target.is_empty() {
445 title.stylize(Style::new().with_link(target.to_string()), 0, end);
446 }
447 }
448 let mut text = Text::new(IMAGE_MARKER).append_text(&title);
449 text.append(" ", None);
450 text
451}
452
453fn sink<'a>(document: &'a mut Vec<Block>, stack: &'a mut [Frame]) -> &'a mut Vec<Block> {
457 match stack
458 .iter()
459 .rposition(|frame| matches!(frame, Frame::Item { .. } | Frame::Quote { .. }))
460 {
461 Some(index) => match &mut stack[index] {
462 Frame::Item { blocks } | Frame::Quote { blocks } => blocks,
463 Frame::List { .. } => unreachable!("rposition matched Item or Quote"),
464 },
465 None => document,
466 }
467}
468
469const MAX_NESTING: usize = 20;
479
480fn flush_pending(
488 current: &mut Option<Text>,
489 blocks: &mut Vec<Block>,
490 stack: &mut [Frame],
491 justify: Justify,
492) {
493 let Some(mut text) = current.take() else {
494 return;
495 };
496 if text.plain().is_empty() {
499 return;
500 }
501 text.set_justify(justify);
502 sink(blocks, stack).push(Block::Text(text, None));
503}
504
505fn push_tilde(
514 current: &mut Option<Text>,
515 table: &mut Option<TableAccum>,
516 link_label: &mut Option<String>,
517) {
518 if let Some(label) = link_label.as_mut() {
519 label.push('~');
520 } else {
521 inline_target(current, table).append("~", None);
522 }
523}
524
525fn append_break(
528 current: Option<&mut Text>,
529 link_label: Option<&mut String>,
530 text: &str,
531 style: Option<Style>,
532) {
533 if let Some(label) = link_label {
534 label.push_str(text);
535 } else if let Some(block) = current {
536 block.append(text, style.map(Into::into));
537 }
538}
539
540enum Piece<'a> {
543 Event(Event<'a>, std::ops::Range<usize>),
544 Literal(std::ops::Range<usize>),
545 Tilde(std::ops::Range<usize>),
546 Open(std::ops::Range<usize>),
547 Close(std::ops::Range<usize>),
548}
549
550struct Delimiter {
553 piece: usize,
554 open: bool,
555 close: bool,
556 end: Option<usize>,
557 emphasis: usize,
562}
563
564fn is_md_ascii_punct(c: char) -> bool {
565 c.is_ascii_punctuation()
566}
567
568fn is_punct_char(c: char) -> bool {
572 c.is_ascii_punctuation() || (!c.is_alphanumeric() && !c.is_whitespace() && !c.is_control())
573}
574
575fn scan_delims(last: char, next: char) -> (bool, bool) {
578 let last_punct = is_md_ascii_punct(last) || is_punct_char(last);
579 let next_punct = is_md_ascii_punct(next) || is_punct_char(next);
580 let last_space = last.is_whitespace();
581 let next_space = next.is_whitespace();
582 let left_flanking = !(next_space || (next_punct && !(last_space || last_punct)));
583 let right_flanking = !(last_space || (last_punct && !(next_space || next_punct)));
584 (left_flanking, right_flanking)
585}
586
587fn process_delimiters(delimiters: &mut [Delimiter]) {
590 if delimiters.is_empty() {
591 return;
592 }
593 let mut openers_bottom = [-1isize; 6];
595 let mut header = 0usize;
596 let mut last_piece: isize = -2;
597 let mut jumps: Vec<usize> = Vec::with_capacity(delimiters.len());
598 for closer_index in 0..delimiters.len() {
599 jumps.push(0);
600 if last_piece != delimiters[closer_index].piece as isize - 1 {
601 header = closer_index;
602 }
603 last_piece = delimiters[closer_index].piece as isize;
604 if !delimiters[closer_index].close {
605 continue;
606 }
607 let slot = if delimiters[closer_index].open { 3 } else { 0 };
608 let min_opener = openers_bottom[slot];
609 let mut opener_index = header as isize - jumps[header] as isize - 1;
610 let mut new_min = opener_index;
611 while opener_index > min_opener {
612 let i = opener_index as usize;
613 let usable = delimiters[i].open
614 && delimiters[i].end.is_none()
615 && delimiters[i].emphasis == delimiters[closer_index].emphasis;
616 if usable {
617 let last_jump = if i > 0 && !delimiters[i - 1].open {
618 jumps[i - 1] + 1
619 } else {
620 0
621 };
622 jumps[closer_index] = closer_index - i + last_jump;
623 jumps[i] = last_jump;
624 delimiters[closer_index].open = false;
625 delimiters[i].end = Some(closer_index);
626 delimiters[i].close = false;
627 new_min = -1;
628 last_piece = -2;
629 break;
630 }
631 opener_index -= jumps[i] as isize + 1;
632 }
633 if new_min != -1 {
634 openers_bottom[slot] = new_min;
635 }
636 }
637}
638
639enum Rejected {
641 Autolink,
643 Bracket {
647 range: std::ops::Range<usize>,
648 last_end: usize,
649 },
650}
651
652fn reject_invalid_links<'a>(
662 source: &'a str,
663 events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
664) -> Vec<(Event<'a>, std::ops::Range<usize>)> {
665 let literal = |range: std::ops::Range<usize>| {
666 (Event::Text(CowStr::Borrowed(&source[range.clone()])), range)
667 };
668 let mut out = Vec::new();
669 let mut open: Vec<Option<Rejected>> = Vec::new();
671 for (event, range) in events {
672 let href = match &event {
673 Event::Start(Tag::Link {
674 link_type: LinkType::Email,
675 dest_url,
676 ..
677 }) => Some(normalize_link(&format!("mailto:{dest_url}"))),
678 Event::Start(Tag::Link { dest_url, .. } | Tag::Image { dest_url, .. }) => {
679 Some(normalize_link(dest_url))
680 }
681 _ => None,
682 };
683 let pushed = match (&event, href) {
684 (_, Some(href)) if validate_link(&href) => {
685 open.push(None);
686 vec![(event, range.clone())]
687 }
688 (
689 Event::Start(Tag::Link {
690 link_type: LinkType::Autolink | LinkType::Email,
691 ..
692 }),
693 Some(_),
694 ) => {
695 open.push(Some(Rejected::Autolink));
696 vec![literal(range.clone())]
697 }
698 (Event::Start(tag), Some(_)) => {
699 let opener = if matches!(tag, Tag::Image { .. }) {
700 2
701 } else {
702 1
703 };
704 let opener = range.start..(range.start + opener).min(range.end);
705 open.push(Some(Rejected::Bracket {
706 range: range.clone(),
707 last_end: opener.end,
708 }));
709 vec![literal(opener)]
710 }
711 (Event::End(TagEnd::Link | TagEnd::Image), _) => match open.pop() {
712 Some(Some(Rejected::Autolink)) => Vec::new(),
713 Some(Some(Rejected::Bracket { range, last_end })) => {
714 vec![literal(last_end.min(range.end)..range.end)]
715 }
716 Some(None) | None => vec![(event, range.clone())],
717 },
718 _ if matches!(open.last(), Some(Some(Rejected::Autolink))) => Vec::new(),
720 _ => vec![(event, range.clone())],
721 };
722 for (_, pushed_range) in &pushed {
723 for entry in open.iter_mut() {
724 if let Some(Rejected::Bracket { last_end, .. }) = entry {
725 *last_end = (*last_end).max(pushed_range.end);
726 }
727 }
728 }
729 out.extend(pushed);
730 }
731 out
732}
733
734fn reject_invalid_tables<'a>(
746 source: &'a str,
747 events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
748) -> Vec<(Event<'a>, std::ops::Range<usize>)> {
749 let mut out = Vec::new();
750 let mut depth = 0usize;
751 let mut skipping = false;
752 for (event, range) in events {
753 if skipping {
754 if matches!(event, Event::End(TagEnd::Table)) {
755 skipping = false;
756 }
757 continue;
758 }
759 match &event {
760 Event::Start(Tag::BlockQuote(_) | Tag::List(_) | Tag::FootnoteDefinition(_)) => {
761 depth += 1
762 }
763 Event::End(TagEnd::BlockQuote(_) | TagEnd::List(_) | TagEnd::FootnoteDefinition) => {
764 depth = depth.saturating_sub(1)
765 }
766 Event::Start(Tag::Table(_)) if depth == 0 => {
767 let table = &source[range.clone()];
768 let mut lines = table.split('\n');
769 let header = lines.next().unwrap_or("");
770 let delimiter = lines.next().unwrap_or("");
771 if !markdown_it_table_start(header, delimiter) {
772 let offset = range.start;
773 out.extend(
774 Parser::new_ext(table, Options::empty())
775 .into_offset_iter()
776 .map(|(event, inner)| {
777 (event, inner.start + offset..inner.end + offset)
778 }),
779 );
780 skipping = true;
781 continue;
782 }
783 }
784 _ => {}
785 }
786 out.push((event, range));
787 }
788 out
789}
790
791fn markdown_it_table_start(header: &str, delimiter: &str) -> bool {
794 let is_space = |c: char| c == ' ' || c == '\t';
795 let is_delimiter_char = |c: char| matches!(c, '|' | '-' | ':');
796 let delimiter = delimiter
797 .trim_end_matches('\r')
798 .trim_start_matches(is_space);
799 let mut chars = delimiter.chars();
800 let (Some(first), Some(second)) = (chars.next(), chars.next()) else {
801 return false;
802 };
803 if !is_delimiter_char(first)
804 || !(is_delimiter_char(second) || is_space(second))
805 || (first == '-' && is_space(second))
806 || !chars.all(|c| is_delimiter_char(c) || is_space(c))
807 {
808 return false;
809 }
810 let columns: Vec<&str> = delimiter.split('|').collect();
811 let mut aligns = 0usize;
812 for (index, column) in columns.iter().enumerate() {
813 let cell = column.trim_matches(crate::text::is_python_space);
814 if cell.is_empty() {
815 if index == 0 || index == columns.len() - 1 {
816 continue;
817 }
818 return false;
819 }
820 let dashes = cell.trim_start_matches(':');
821 let dashes = dashes.strip_suffix(':').unwrap_or(dashes);
822 let leading = cell.len() - cell.trim_start_matches(':').len();
823 if leading > 1 || dashes.is_empty() || !dashes.bytes().all(|b| b == b'-') {
824 return false;
825 }
826 aligns += 1;
827 }
828 let header = header
829 .trim_end_matches('\r')
830 .trim_matches(crate::text::is_python_space);
831 if !header.contains('|') {
832 return false;
833 }
834 let mut cells: Vec<String> = vec![String::new()];
836 let mut escaped = false;
837 for c in header.chars() {
838 if c == '|' && !escaped {
839 cells.push(String::new());
840 } else {
841 let cell = cells.last_mut().expect("a cell");
842 if c == '|' {
843 cell.pop();
844 }
845 cell.push(c);
846 }
847 escaped = c == '\\';
848 }
849 if cells.first().is_some_and(String::is_empty) {
850 cells.remove(0);
851 }
852 if cells.last().is_some_and(String::is_empty) {
853 cells.pop();
854 }
855 !cells.is_empty() && cells.len() == aligns
856}
857
858fn pair_strikethrough<'a>(
869 source: &'a str,
870 events: impl Iterator<Item = (Event<'a>, std::ops::Range<usize>)>,
871) -> Vec<(Event<'a>, std::ops::Range<usize>)> {
872 let mut pieces: Vec<Piece<'a>> = Vec::new();
873 let mut scopes: Vec<Vec<Delimiter>> = vec![Vec::new()];
875 let mut finished: Vec<Vec<Delimiter>> = Vec::new();
876 let mut emphasis_stack: Vec<usize> = Vec::new();
877 let mut next_emphasis = 1usize;
878 let mut in_code = false;
879 let mut in_cell = false;
880 let mut image_depth = 0usize;
881 let mut in_autolink = false;
884
885 let neighbour = |c: Option<char>, in_cell: bool| match c {
886 None => ' ',
887 Some('|') if in_cell => ' ',
889 Some(c) => c,
890 };
891
892 for (event, range) in events {
893 if image_depth > 0 {
894 match &event {
895 Event::Start(Tag::Image { .. }) => image_depth += 1,
896 Event::End(TagEnd::Image) => image_depth -= 1,
897 _ => {}
898 }
899 pieces.push(Piece::Event(event, range));
900 continue;
901 }
902 match &event {
903 Event::Text(text) if !in_code && !in_autolink && **text == source[range.clone()] => {
904 let bytes = source.as_bytes();
907 let mut literal_from = range.start;
908 let mut at = range.start;
909 if let Some(Piece::Literal(previous)) = pieces.last() {
910 if previous.end == range.start {
911 literal_from = previous.start;
912 at = previous.end;
918 while at > previous.start && bytes[at - 1] == b'~' {
919 at -= 1;
920 }
921 pieces.pop();
922 }
923 }
924 let end = range.end;
925 while at < end {
926 if bytes[at] != b'~' {
927 at += 1;
928 continue;
929 }
930 let run_start = at;
931 while at < end && bytes[at] == b'~' {
932 at += 1;
933 }
934 let length = at - run_start;
935 if length < 2 {
936 continue;
937 }
938 if literal_from < run_start {
939 pieces.push(Piece::Literal(literal_from..run_start));
940 }
941 let last = neighbour(source[..run_start].chars().next_back(), in_cell);
942 let next = neighbour(source[at..].chars().next(), in_cell);
943 let (open, close) = scan_delims(last, next);
944 let mut from = run_start;
945 if length % 2 == 1 {
946 pieces.push(Piece::Literal(from..from + 1));
947 from += 1;
948 }
949 let emphasis = emphasis_stack.last().copied().unwrap_or(0);
950 while from < at {
951 pieces.push(Piece::Tilde(from..from + 2));
952 scopes.last_mut().expect("scope").push(Delimiter {
953 piece: pieces.len() - 1,
954 open,
955 close,
956 end: None,
957 emphasis,
958 });
959 from += 2;
960 }
961 literal_from = at;
962 }
963 if literal_from < end {
964 pieces.push(Piece::Literal(literal_from..end));
965 }
966 continue;
967 }
968 Event::Start(Tag::Emphasis | Tag::Strong) => {
969 emphasis_stack.push(next_emphasis);
970 next_emphasis += 1;
971 }
972 Event::End(TagEnd::Emphasis | TagEnd::Strong) => {
973 emphasis_stack.pop();
974 }
975 Event::Start(Tag::Link { link_type, .. }) => {
976 in_autolink = matches!(link_type, LinkType::Autolink | LinkType::Email);
977 scopes.push(Vec::new());
978 }
979 Event::End(TagEnd::Link) => {
980 in_autolink = false;
981 if scopes.len() > 1 {
982 finished.push(scopes.pop().expect("link scope"));
983 }
984 }
985 Event::Start(Tag::Image { .. }) => image_depth = 1,
986 Event::Text(_)
987 | Event::Code(_)
988 | Event::InlineHtml(_)
989 | Event::SoftBreak
990 | Event::HardBreak
991 | Event::FootnoteReference(_)
992 | Event::InlineMath(_) => {}
993 _ => {
995 match &event {
996 Event::Start(Tag::CodeBlock(_)) => in_code = true,
997 Event::End(TagEnd::CodeBlock) => in_code = false,
998 Event::Start(Tag::TableCell) => in_cell = true,
999 Event::End(TagEnd::TableCell) => in_cell = false,
1000 _ => {}
1001 }
1002 finished.append(&mut scopes);
1003 scopes.push(Vec::new());
1004 emphasis_stack.clear();
1005 }
1006 }
1007 pieces.push(Piece::Event(event, range));
1008 }
1009 finished.append(&mut scopes);
1010
1011 let mut lone_markers: Vec<usize> = Vec::new();
1013 for mut delimiters in finished {
1014 process_delimiters(&mut delimiters);
1015 for delimiter in &delimiters {
1016 let Some(end) = delimiter.end else { continue };
1017 let closer = delimiters[end].piece;
1018 if let Piece::Tilde(range) = &pieces[delimiter.piece] {
1019 pieces[delimiter.piece] = Piece::Open(range.clone());
1020 }
1021 if let Piece::Tilde(range) = &pieces[closer] {
1022 pieces[closer] = Piece::Close(range.clone());
1023 }
1024 if let Some(Piece::Literal(range)) = closer.checked_sub(1).map(|i| &pieces[i]) {
1025 if &source[range.clone()] == "~" {
1026 lone_markers.push(closer - 1);
1027 }
1028 }
1029 }
1030 }
1031 while let Some(i) = lone_markers.pop() {
1034 let mut j = i + 1;
1035 while j < pieces.len() && matches!(pieces[j], Piece::Close(_)) {
1036 j += 1;
1037 }
1038 j -= 1;
1039 if i != j {
1040 pieces.swap(i, j);
1041 }
1042 }
1043
1044 let mut out: Vec<(Event<'a>, std::ops::Range<usize>)> = Vec::with_capacity(pieces.len());
1047 for piece in pieces {
1048 let (event, range) = match piece {
1049 Piece::Event(event, range) => (event, range),
1050 Piece::Literal(range) | Piece::Tilde(range) => {
1051 (Event::Text(CowStr::Borrowed(&source[range.clone()])), range)
1052 }
1053 Piece::Open(range) => (Event::Start(Tag::Strikethrough), range),
1054 Piece::Close(range) => (Event::End(TagEnd::Strikethrough), range),
1055 };
1056 if let (Event::Text(text), Some((Event::Text(previous), previous_range))) =
1057 (&event, out.last_mut())
1058 {
1059 let mut joined = previous.to_string();
1060 joined.push_str(text);
1061 *previous = CowStr::Boxed(joined.into_boxed_str());
1062 *previous_range =
1063 previous_range.start.min(range.start)..previous_range.end.max(range.end);
1064 continue;
1065 }
1066 out.push((event, range));
1067 }
1068 out
1069}
1070
1071fn parse(source: &str, md: &MarkdownOptions) -> Vec<Block> {
1072 let hyperlinks = !md.no_hyperlinks;
1073 let paragraph_justify = md.justify.unwrap_or(Justify::Left);
1074 let mut blocks: Vec<Block> = Vec::new();
1075 let mut current: Option<Text> = None;
1076 let mut heading_style: Option<Style> = None;
1077 let mut heading_open: Option<u8> = None;
1079 let mut justify = Justify::Left;
1080 let mut strong = 0usize;
1081 let mut emphasis = 0usize;
1082 let mut strike = 0usize;
1083 let mut single_tilde = 0usize;
1086 let mut stack: Vec<Frame> = Vec::new();
1090 let mut suppressed = 0usize;
1093 let mut item_suppressed = 0usize;
1094 let mut code: Option<(String, String)> = None;
1096 let mut link: Option<String> = None;
1098 let mut autolink = false;
1101 let mut link_label: Option<String> = None;
1109 let mut image: Option<String> = None;
1111 let mut image_span: Option<(usize, usize)> = None;
1112 let mut new_line = false;
1117 let mut table: Option<TableAccum> = None;
1119
1120 let options = Options::ENABLE_TABLES;
1127 let events = Parser::new_ext(source, options).into_offset_iter();
1128 let events = reject_invalid_tables(source, events);
1129 let events = reject_invalid_links(source, events.into_iter());
1130 for (event, range) in pair_strikethrough(source, events.into_iter()) {
1131 if image.is_some() && !matches!(event, Event::End(TagEnd::Image)) {
1137 image_span = Some(match image_span {
1138 Some((start, end)) => (start.min(range.start), end.max(range.end)),
1139 None => (range.start, range.end),
1140 });
1141 continue;
1142 }
1143 let preceding_new_line = new_line;
1148 match &event {
1149 Event::End(
1150 TagEnd::Paragraph
1151 | TagEnd::Heading(_)
1152 | TagEnd::List(_)
1153 | TagEnd::Item
1154 | TagEnd::BlockQuote(_)
1155 | TagEnd::CodeBlock
1156 | TagEnd::Table
1157 | TagEnd::TableHead
1158 | TagEnd::TableRow
1159 | TagEnd::TableCell
1160 | TagEnd::HtmlBlock,
1161 ) => new_line = true,
1162 Event::Rule => new_line = false,
1163 _ => {}
1164 }
1165 match event {
1166 Event::End(TagEnd::HtmlBlock) => {
1167 sink(&mut blocks, &mut stack).push(Block::Html);
1168 }
1169 Event::Rule => {
1170 flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
1171 sink(&mut blocks, &mut stack).push(Block::Rule);
1172 }
1173 Event::Start(Tag::Link {
1174 link_type,
1175 dest_url,
1176 ..
1177 }) => {
1178 link = Some(normalize_link(&match link_type {
1189 LinkType::Email => format!("mailto:{dest_url}"),
1190 _ => dest_url.to_string(),
1191 }));
1192 autolink = matches!(link_type, LinkType::Autolink | LinkType::Email);
1195 if !hyperlinks {
1196 link_label = Some(String::new());
1197 }
1198 }
1199 Event::End(TagEnd::Link) => {
1200 autolink = false;
1201 let url = link.take();
1202 let label = link_label.take();
1203 if let Some(url) = url.filter(|_| !hyperlinks) {
1213 let label = label.unwrap_or_default();
1214 let inline = inline_style(strong, emphasis, strike);
1215 let block = inline_target(&mut current, &mut table);
1218 let layer = |style: Option<Style>| {
1219 stack_style(
1220 quote_root(md, &stack).as_ref(),
1221 heading_style.as_ref(),
1222 inline.clone(),
1223 None,
1224 style,
1225 )
1226 };
1227 if !label.is_empty() {
1230 block.append(&label, layer(Style::parse(LINK_STYLE).ok()).map(Into::into));
1231 }
1232 block.append(" (", layer(None).map(Into::into));
1233 block.append(
1234 &url,
1235 layer(Style::parse(LINK_URL_STYLE).ok()).map(Into::into),
1236 );
1237 block.append(")", layer(None).map(Into::into));
1238 }
1239 }
1240 Event::Start(Tag::Image { dest_url, .. }) => {
1245 image = Some(normalize_link(&dest_url));
1246 image_span = None;
1247 }
1248 Event::End(TagEnd::Image) => {
1249 if let Some(destination) = image.take() {
1250 let alt = image_span
1251 .take()
1252 .map(|(start, end)| Text::new(&source[start..end]))
1253 .unwrap_or_default();
1254 blocks.push(Block::Image {
1266 text: image_text(
1267 &destination,
1268 alt,
1269 link.as_deref(),
1270 stack_style(
1271 quote_root(md, &stack).as_ref(),
1272 heading_style.as_ref(),
1273 inline_style(strong, emphasis, strike),
1274 link.as_deref().filter(|_| hyperlinks),
1275 None,
1276 ),
1277 hyperlinks,
1278 ),
1279 joins_next: stack.is_empty() && table.is_none(),
1283 leading_break: new_line,
1284 });
1285 new_line = false;
1286 }
1287 }
1288 Event::Start(Tag::CodeBlock(kind)) => {
1289 flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
1290 let language = match kind {
1291 CodeBlockKind::Fenced(info) => {
1292 info.split_whitespace().next().unwrap_or("").to_string()
1294 }
1295 CodeBlockKind::Indented => String::new(),
1296 };
1297 code = Some((language, String::new()));
1298 }
1299 Event::End(TagEnd::CodeBlock) => {
1300 if let Some((language, mut source)) = code.take() {
1301 if source.ends_with('\n') {
1303 source.pop();
1304 }
1305 sink(&mut blocks, &mut stack).push(Block::Code {
1306 language,
1307 code: source,
1308 theme: md.code_theme.clone(),
1309 highlighter: md.highlighter.clone(),
1310 fences: md.fences.clone(),
1311 });
1312 }
1313 }
1314 Event::Start(Tag::Table(aligns)) => {
1315 flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
1316 table = Some(TableAccum {
1317 alignments: aligns.into_iter().map(alignment_justify).collect(),
1318 ..TableAccum::default()
1319 });
1320 }
1321 Event::End(TagEnd::Table) => {
1322 if let Some(acc) = table.take() {
1323 sink(&mut blocks, &mut stack).push(Block::Table {
1324 alignments: acc.alignments,
1325 headers: acc.headers,
1326 rows: acc.rows,
1327 });
1328 }
1329 }
1330 Event::Start(Tag::TableHead) => {
1331 if let Some(acc) = table.as_mut() {
1332 acc.in_head = true;
1333 acc.cur_row = Vec::new();
1334 }
1335 }
1336 Event::End(TagEnd::TableHead) => {
1337 if let Some(acc) = table.as_mut() {
1338 acc.headers = std::mem::take(&mut acc.cur_row);
1339 acc.in_head = false;
1340 }
1341 }
1342 Event::Start(Tag::TableRow) => {
1343 if let Some(acc) = table.as_mut() {
1344 acc.cur_row = Vec::new();
1345 }
1346 }
1347 Event::End(TagEnd::TableRow) => {
1348 if let Some(acc) = table.as_mut() {
1349 let row = std::mem::take(&mut acc.cur_row);
1350 acc.rows.push(row);
1351 }
1352 }
1353 Event::Start(Tag::TableCell) => {
1354 if let Some(acc) = table.as_mut() {
1355 acc.in_cell = true;
1356 acc.cur_cell = Text::new("");
1357 }
1358 }
1359 Event::End(TagEnd::TableCell) => {
1360 if let Some(acc) = table.as_mut() {
1361 let cell = std::mem::take(&mut acc.cur_cell);
1362 acc.cur_row.push(cell);
1363 acc.in_cell = false;
1364 }
1365 }
1366 Event::Start(Tag::BlockQuote(_)) => {
1367 flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
1368 if stack.len() >= MAX_NESTING {
1369 suppressed += 1;
1370 } else {
1371 stack.push(Frame::Quote { blocks: Vec::new() });
1372 }
1373 }
1374 Event::End(TagEnd::BlockQuote(_)) => {
1375 if suppressed > 0 {
1376 suppressed -= 1;
1377 } else if let Some(Frame::Quote { blocks: quoted }) = stack.pop() {
1378 sink(&mut blocks, &mut stack).push(Block::Quote {
1379 blocks: quoted,
1380 leading_break: preceding_new_line,
1381 });
1382 }
1383 }
1384 Event::Start(Tag::List(first)) => {
1385 flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
1386 if stack.len() >= MAX_NESTING {
1387 suppressed += 1;
1388 } else {
1389 stack.push(Frame::List {
1390 ordered: first.is_some(),
1391 start: first.unwrap_or(1),
1392 entries: Vec::new(),
1393 });
1394 }
1395 }
1396 Event::End(TagEnd::List(_)) => {
1397 if suppressed > 0 {
1398 suppressed -= 1;
1399 } else if let Some(Frame::List { entries, .. }) = stack.pop() {
1400 sink(&mut blocks, &mut stack).push(Block::List { items: entries });
1401 }
1402 }
1403 Event::Start(Tag::Item) => {
1404 if stack.len() >= MAX_NESTING {
1405 item_suppressed += 1;
1406 } else {
1407 stack.push(Frame::Item { blocks: Vec::new() });
1408 }
1409 current = Some(Text::new(""));
1413 heading_style = None;
1414 heading_open = None;
1415 justify = paragraph_justify;
1416 }
1417 Event::End(TagEnd::Item) => {
1418 if let Some(mut text) = current.take() {
1423 text.set_justify(paragraph_justify);
1424 sink(&mut blocks, &mut stack).push(Block::Text(text, None));
1425 }
1426 if item_suppressed > 0 {
1427 item_suppressed -= 1;
1428 } else if let Some(Frame::Item {
1429 blocks: item_blocks,
1430 }) = stack.pop()
1431 {
1432 if let Some(Frame::List {
1433 ordered,
1434 start,
1435 entries,
1436 }) = stack.last_mut()
1437 {
1438 let number = ordered.then(|| *start + entries.len() as u64);
1439 entries.push(ListEntry {
1440 number,
1441 blocks: item_blocks,
1442 });
1443 }
1444 }
1445 }
1446 Event::Start(Tag::Paragraph) => {
1447 flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
1448 current = Some(Text::new(""));
1449 heading_style = None;
1450 heading_open = None;
1451 justify = paragraph_justify;
1453 }
1454 Event::Start(Tag::Heading { level, .. }) => {
1455 flush_pending(&mut current, &mut blocks, &mut stack, paragraph_justify);
1456 let (style, heading_justify) = heading_format(heading_level(level));
1457 current = Some(Text::new(""));
1458 heading_style = Some(style);
1459 heading_open = Some(heading_level(level) as u8);
1460 justify = heading_justify;
1461 }
1462 Event::End(TagEnd::Paragraph) | Event::End(TagEnd::Heading(_)) => {
1463 if let Some(mut text) = current.take() {
1464 let in_quote = stack
1465 .iter()
1466 .rposition(|f| matches!(f, Frame::Item { .. } | Frame::Quote { .. }))
1467 .is_some_and(|i| matches!(stack[i], Frame::Quote { .. }));
1468 if in_quote {
1469 if let Some(root) = quote_root(md, &stack) {
1472 text.set_base_style(root);
1473 }
1474 }
1475 text.set_justify(justify);
1483 sink(&mut blocks, &mut stack).push(Block::Text(text, heading_open));
1484 }
1485 heading_style = None;
1486 heading_open = None;
1487 justify = Justify::Left;
1488 strong = 0;
1489 emphasis = 0;
1490 }
1491 Event::Start(Tag::Strong) => strong += 1,
1492 Event::End(TagEnd::Strong) => strong = strong.saturating_sub(1),
1493 Event::Start(Tag::Strikethrough) => {
1494 if source[range.clone()].starts_with("~~") {
1495 strike += 1;
1496 } else {
1497 single_tilde += 1;
1506 push_tilde(&mut current, &mut table, &mut link_label);
1507 }
1508 }
1509 Event::End(TagEnd::Strikethrough) => {
1510 if single_tilde > 0 {
1511 single_tilde -= 1;
1512 push_tilde(&mut current, &mut table, &mut link_label);
1513 } else {
1514 strike = strike.saturating_sub(1);
1515 }
1516 }
1517 Event::Start(Tag::Emphasis) => emphasis += 1,
1518 Event::End(TagEnd::Emphasis) => emphasis = emphasis.saturating_sub(1),
1519 Event::Text(text) => {
1520 let text = if autolink {
1521 CowStr::from(normalize_link_text(&text))
1522 } else {
1523 text
1524 };
1525 if let Some(label) = link_label.as_mut() {
1526 label.push_str(&text);
1527 } else if let Some((_, source)) = code.as_mut() {
1528 source.push_str(&text);
1529 } else {
1530 let block = inline_target(&mut current, &mut table);
1536 let style = stack_style(
1537 quote_root(md, &stack).as_ref(),
1538 heading_style.as_ref(),
1539 inline_style(strong, emphasis, strike),
1540 link.as_deref().filter(|_| hyperlinks),
1541 None,
1542 );
1543 block.append(&text, style.map(Into::into));
1544 }
1545 }
1546 Event::Code(text) => {
1547 if let Some(label) = link_label.as_mut() {
1548 label.push_str(&text);
1549 } else {
1550 let block = inline_target(&mut current, &mut table);
1552 if let Some(lexer) = &md.inline_code_lexer {
1557 let theme = md.inline_code_theme.as_ref().or(md.code_theme.as_ref());
1561 let mut syntax = Syntax::new(text.to_string(), lexer.as_str());
1562 if let Some(theme) = theme {
1563 syntax = syntax.theme(theme.as_str());
1564 }
1565 if let Some(highlighter) = &md.highlighter {
1566 syntax = syntax.highlighter(highlighter.clone());
1567 }
1568 let mut highlighted = syntax.highlight();
1569 highlighted.rstrip();
1570 let style = stack_style(
1571 quote_root(md, &stack).as_ref(),
1572 heading_style.as_ref(),
1573 inline_style(strong, emphasis, strike),
1574 link.as_deref().filter(|_| hyperlinks),
1575 None,
1576 );
1577 let mut fragment = Text::new("");
1578 if let Some(style) = style {
1579 fragment.set_base_style(style);
1580 }
1581 let fragment = fragment.append_text(&highlighted);
1582 *block = std::mem::take(block).append_text(&fragment);
1583 continue;
1584 }
1585 let style = stack_style(
1586 quote_root(md, &stack).as_ref(),
1587 heading_style.as_ref(),
1588 inline_style(strong, emphasis, strike),
1589 link.as_deref().filter(|_| hyperlinks),
1590 Style::parse(CODE_STYLE).ok(),
1591 );
1592 block.append(&text, style.map(Into::into));
1593 }
1594 }
1595 Event::SoftBreak => append_break(
1599 current.as_mut(),
1600 link_label.as_mut(),
1601 " ",
1602 stack_style(
1603 quote_root(md, &stack).as_ref(),
1604 heading_style.as_ref(),
1605 inline_style(strong, emphasis, strike),
1606 link.as_deref().filter(|_| hyperlinks),
1607 None,
1608 ),
1609 ),
1610 Event::HardBreak => append_break(
1611 current.as_mut(),
1612 link_label.as_mut(),
1613 "\n",
1614 stack_style(
1615 quote_root(md, &stack).as_ref(),
1616 heading_style.as_ref(),
1617 inline_style(strong, emphasis, strike),
1618 link.as_deref().filter(|_| hyperlinks),
1619 None,
1620 ),
1621 ),
1622 _ => {}
1623 }
1624 }
1625 blocks
1626}
1627
1628impl Renderable for Markdown {
1629 fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
1630 use crate::protocol::ConsoleCodeHighlighting;
1635 let reparsed;
1636 let blocks = match (&self.options.highlighter, console.code_highlighting()) {
1637 (None, Some(default)) if self.options.inline_code_lexer.is_some() => {
1638 let mut with_default = self.options.clone();
1639 with_default.highlighter = Some(default.highlighter.clone());
1640 if with_default.code_theme.is_none() {
1641 with_default.code_theme = default.theme.clone();
1642 }
1643 reparsed = parse(&self.source, &with_default);
1644 &reparsed
1645 }
1646 _ => &self.blocks,
1647 };
1648 let mut lines = render_blocks(
1649 blocks,
1650 console,
1651 options,
1652 options.max_width,
1653 true,
1654 self.options.style.as_ref(),
1655 );
1656
1657 if matches!(blocks.last(), Some(Block::Rule)) {
1662 lines.push(Vec::new());
1663 }
1664
1665 let mut segments = Vec::new();
1666 let last = lines.len().saturating_sub(1);
1667 for (index, line) in lines.into_iter().enumerate() {
1668 segments.extend(line);
1669 if index != last {
1670 segments.push(Segment::line());
1671 }
1672 }
1673 segments
1674 }
1675}
1676
1677fn pad_lines(lines: &mut [Vec<Segment>], width: usize) {
1686 for line in lines.iter_mut() {
1687 let len: usize = line.iter().map(Segment::cell_length).sum();
1688 if len < width {
1689 line.push(Segment::new(" ".repeat(width - len), None));
1690 }
1691 }
1692}
1693
1694fn render_text(
1703 text: &Text,
1704 console: &Console,
1705 options: &ConsoleOptions,
1706 width: usize,
1707) -> Vec<Vec<Segment>> {
1708 let justify = text.get_justify_option().unwrap_or(options.justify);
1709 text.render_lines_wrapped_tabs(
1710 console.theme(),
1711 console.base_style(),
1712 Some(width),
1713 justify,
1714 text.get_overflow()
1715 .or(options.overflow)
1716 .unwrap_or(Overflow::Fold),
1717 text.get_no_wrap().or(options.no_wrap).unwrap_or(false),
1718 text.console_tab_size(console),
1719 )
1720}
1721
1722fn render_blocks(
1723 blocks: &[Block],
1724 console: &Console,
1725 options: &ConsoleOptions,
1726 width: usize,
1727 top_level: bool,
1728 root: Option<&Style>,
1729) -> Vec<Vec<Segment>> {
1730 let mut lines: Vec<Vec<Segment>> = Vec::new();
1731 let mut join_previous = false;
1734
1735 for (index, block) in blocks.iter().enumerate() {
1736 let mut merge = std::mem::take(&mut join_previous);
1737 if matches!(
1740 block,
1741 Block::Image {
1742 leading_break: false,
1743 ..
1744 }
1745 ) && index > 0
1746 && matches!(blocks[index - 1], Block::Image { .. })
1747 {
1748 merge = true;
1749 }
1750 if matches!(
1755 block,
1756 Block::Image {
1757 leading_break: true,
1758 ..
1759 }
1760 ) {
1761 merge = false;
1762 }
1763 let after_rule = index > 0 && matches!(blocks[index - 1], Block::Rule);
1773 let own_gap = matches!(block, Block::List { .. } | Block::Table { .. });
1776 let after_image = index > 0 && matches!(blocks[index - 1], Block::Image { .. });
1780 let separator = match block {
1781 Block::Quote { leading_break, .. } => top_level && *leading_break && !after_image,
1782 Block::Image { leading_break, .. } => top_level && *leading_break && !after_image,
1786 _ if after_image => false,
1787 _ => top_level && (own_gap || (index > 0 && !after_rule)),
1788 };
1789 if separator {
1790 lines.push(Vec::new());
1791 }
1792 let start = lines.len();
1793 match block {
1794 Block::Text(text, None) => lines.extend(render_text(text, console, options, width)),
1795 Block::Text(text, Some(level)) => {
1796 let region = crate::protocol::enter_region(console, || {
1799 crate::protocol::RegionInfo::new(crate::protocol::RegionRole::Heading {
1800 level: *level,
1801 })
1802 .label(text.plain())
1803 });
1804 let mut rendered = render_text(text, console, options, width);
1805 if let Some(region) = region {
1806 for line in rendered.iter_mut() {
1807 region.tag(line);
1808 }
1809 }
1810 lines.extend(rendered);
1811 }
1812 Block::Image {
1813 text, joins_next, ..
1814 } => {
1815 lines.extend(render_text(text, console, options, width));
1818 join_previous = *joins_next;
1819 }
1820 Block::List { items } => {
1821 for item in items {
1822 let (prefix, prefix_style) = match item.number {
1823 Some(number) => (
1824 format!(" {number} "),
1825 Style::parse("cyan").expect("valid style"),
1826 ),
1827 None => (
1828 BULLET.to_string(),
1829 Style::parse("bold").expect("valid style"),
1830 ),
1831 };
1832 let prefix_width = cell_len(&prefix);
1833 let item_lines = render_blocks(
1837 &item.blocks,
1838 console,
1839 options,
1840 width.saturating_sub(prefix_width),
1841 false,
1842 root,
1843 );
1844 let mut item_lines: Vec<Vec<Segment>> = item_lines
1846 .into_iter()
1847 .skip_while(|line| line.is_empty())
1848 .collect();
1849 pad_lines(&mut item_lines, width.saturating_sub(prefix_width));
1850 for (line_index, line) in item_lines.into_iter().enumerate() {
1851 let mut row = Vec::new();
1852 if line_index == 0 {
1855 row.push(Segment::new(prefix.clone(), Some(prefix_style.clone())));
1856 } else {
1857 row.push(Segment::new(
1858 " ".repeat(prefix_width),
1859 Some(prefix_style.clone()),
1860 ));
1861 }
1862 match root {
1866 Some(root) => row.extend(Segment::apply_style(&line, root)),
1867 None => row.extend(line),
1868 }
1869 lines.push(row);
1870 }
1871 }
1872 }
1873 Block::Html => {}
1874 Block::Quote { blocks: quoted, .. } => {
1875 let quote = Style::parse(QUOTE_STYLE).expect("valid style");
1878 let prefix_style = match root {
1879 Some(root) => root.combine("e),
1880 None => quote,
1881 };
1882 let content_width = width.saturating_sub(4);
1884 let quoted_lines = render_blocks(
1885 quoted,
1886 console,
1887 options,
1888 content_width,
1889 false,
1890 Some(&prefix_style),
1891 );
1892 let mut quoted_lines: Vec<Vec<Segment>> = quoted_lines
1893 .into_iter()
1894 .skip_while(|line| line.is_empty())
1895 .collect();
1896 pad_lines(&mut quoted_lines, content_width);
1897 for line in quoted_lines {
1898 let mut row = vec![Segment::new(
1899 QUOTE_PREFIX.to_string(),
1900 Some(prefix_style.clone()),
1901 )];
1902 row.extend(Segment::apply_style(&line, &prefix_style));
1907 lines.push(row);
1908 }
1909 }
1910 Block::Code {
1911 language,
1912 code,
1913 theme,
1914 highlighter,
1915 fences,
1916 } => {
1917 let inner = options.update_width(width);
1918 let region = crate::protocol::enter_region(console, || {
1923 crate::protocol::RegionInfo::new(crate::protocol::RegionRole::Code)
1924 .label(language)
1925 });
1926 let drawn = if language.is_empty() {
1927 None
1928 } else {
1929 fences
1930 .iter()
1931 .find_map(|fence| fence.render_fence(language, code, console, &inner))
1932 };
1933 let segments = match drawn {
1934 Some(segments) => segments,
1935 None => {
1936 let mut syntax = Syntax::new(code.as_str(), language.as_str())
1944 .word_wrap(true)
1945 .padding(1);
1946 if let Some(theme) = theme {
1947 syntax = syntax.theme(theme.as_str());
1948 }
1949 if let Some(highlighter) = highlighter {
1950 syntax = syntax.highlighter(highlighter.clone());
1951 }
1952 syntax.rich_render(console, &inner)
1953 }
1954 };
1955 let mut segments = segments;
1956 if let Some(region) = region {
1957 region.tag(&mut segments);
1958 }
1959 lines.extend(Segment::split_lines(&segments));
1960 }
1961 Block::Rule => {
1962 let style = Style::parse("dim").expect("valid style");
1963 lines.push(vec![Segment::new("-".repeat(width), Some(style))]);
1964 if index + 1 < blocks.len() || !top_level {
1974 lines.push(Vec::new());
1975 }
1976 }
1977 Block::Table {
1978 alignments,
1979 headers,
1980 rows,
1981 } => {
1982 let mut table = Table::new()
1987 .box_set(SIMPLE)
1988 .pad_edge(false)
1989 .collapse_padding(true)
1990 .style(Style::parse(TABLE_BORDER_STYLE).expect("valid style"));
1991 let header_style = Style::parse(TABLE_HEADER_STYLE).expect("valid style");
1992 for (col, header) in headers.iter().enumerate() {
1993 let justify = alignments.get(col).copied().unwrap_or(Justify::Left);
1994 table.add_column_text(header.clone(), justify);
1997 table.column_header_style(header_style.clone());
1998 }
1999 for row in rows {
2000 table.add_row_text(row.clone());
2001 }
2002 let inner = options.update_width(width);
2003 lines.extend(Segment::split_lines(&table.rich_render(console, &inner)));
2004 }
2005 }
2006 if merge && lines.len() > start {
2010 let first = lines.remove(start);
2011 lines[start - 1].extend(first);
2012 }
2013 }
2014 lines
2015}
2016
2017#[cfg(test)]
2018mod tests {
2019 use super::*;
2020 use crate::color::ColorSystem;
2021
2022 fn render(source: &str) -> String {
2023 let console = Console::builder()
2024 .force_terminal(true)
2025 .color_system(Some(ColorSystem::Truecolor))
2026 .width(20)
2027 .build();
2028 console.render_to_string(&Markdown::new(source))
2029 }
2030
2031 fn render_with(markdown: &Markdown) -> String {
2032 let console = Console::builder()
2033 .force_terminal(true)
2034 .color_system(Some(ColorSystem::Truecolor))
2035 .width(30)
2036 .build();
2037 console.render_to_string(markdown)
2038 }
2039
2040 #[test]
2041 fn code_theme_changes_the_code_block_colours() {
2042 let source = "```rust\nfn main() {}\n```";
2043 let default = render_with(&Markdown::new(source));
2044 let themed = render_with(&Markdown::new(source).code_theme("InspiredGitHub"));
2045 assert_ne!(default, themed);
2046 assert_eq!(
2048 default,
2049 render_with(&Markdown::new(source).code_theme("no-such-theme"))
2050 );
2051 }
2052
2053 #[test]
2057 fn fence_renderers_draw_accepted_fences_and_decline_the_rest() {
2058 use crate::protocol::FenceRenderer;
2059 struct Boxed;
2060 impl FenceRenderer for Boxed {
2061 fn render_fence(
2062 &self,
2063 language: &str,
2064 code: &str,
2065 _console: &Console,
2066 options: &ConsoleOptions,
2067 ) -> Option<Vec<Segment>> {
2068 (language == "shout").then(|| {
2069 let line = format!("<{}>", code.to_uppercase());
2070 assert!(options.max_width >= line.len());
2071 vec![Segment::new(line, None), Segment::line()]
2072 })
2073 }
2074 }
2075 let fences = Arc::new(Boxed);
2076 let source = "# T\n\n```shout\nhi\n```\n\n- item\n\n ```shout\n nested\n ```\n\n```rust\nfn x() {}\n```\n\n indented\n";
2077 let plain = render_with(&Markdown::new(source));
2078 let drawn = render_with(&Markdown::new(source).fence_renderer(fences.clone()));
2079 assert!(
2080 drawn.contains("<HI>") && drawn.contains("<NESTED>"),
2081 "{drawn}"
2082 );
2083 assert!(!plain.contains("<HI>"));
2084 let rust = "```rust\nfn x() {}\n```\n\n indented\n";
2086 assert_eq!(
2087 render_with(&Markdown::new(rust)),
2088 render_with(&Markdown::new(rust).fence_renderer(fences))
2089 );
2090 }
2091
2092 #[test]
2095 fn a_custom_highlighter_reaches_code_blocks_and_inline_code() {
2096 use crate::protocol::{
2097 CodeHighlighter, HighlightError, HighlightSpan, HighlightedCode, HighlightedLine,
2098 };
2099 struct Underline;
2100 impl CodeHighlighter for Underline {
2101 fn highlight(
2102 &self,
2103 code: &str,
2104 _language: Option<&str>,
2105 _theme: &str,
2106 ) -> Result<HighlightedCode, HighlightError> {
2107 let underline = Style::parse("underline").unwrap();
2108 let lines = code
2109 .split('\n')
2110 .map(|line| HighlightedLine {
2111 spans: (!line.is_empty())
2112 .then(|| HighlightSpan {
2113 range: 0..line.len(),
2114 style: underline.clone(),
2115 })
2116 .into_iter()
2117 .collect(),
2118 newline_style: None,
2119 })
2120 .collect();
2121 Ok(HighlightedCode {
2122 lines,
2123 ..Default::default()
2124 })
2125 }
2126 fn default_theme(&self) -> &str {
2127 "underline"
2128 }
2129 fn themes(&self) -> Vec<String> {
2130 vec!["underline".into()]
2131 }
2132 fn languages(&self) -> Vec<String> {
2133 Vec::new()
2134 }
2135 }
2136 let block = render_with(
2137 &Markdown::new("```rust\nfn main() {}\n```").highlighter(Arc::new(Underline)),
2138 );
2139 assert!(block.contains("\x1b[4mfn main() {}"), "{block:?}");
2140 let inline = render_with(
2141 &Markdown::new("Call `go()` now.")
2142 .inline_code_lexer("rust")
2143 .highlighter(Arc::new(Underline)),
2144 );
2145 assert!(inline.contains("\x1b[4mgo()"), "{inline:?}");
2146 }
2147
2148 #[test]
2149 fn inline_code_lexer_highlights_instead_of_the_code_style() {
2150 let source = "Call `fn main() {}` now.";
2151 let plain = render_with(&Markdown::new(source));
2152 assert!(plain.contains("\x1b[1;36;40m"), "{plain:?}");
2154 let highlighted = render_with(&Markdown::new(source).inline_code_lexer("rust"));
2155 assert!(!highlighted.contains("\x1b[1;36;40m"), "{highlighted:?}");
2156 assert_ne!(plain, highlighted);
2157 let text = Console::builder().width(30).color_system(None).build();
2158 assert_eq!(
2159 text.render_to_string(&Markdown::new(source).inline_code_lexer("rust")),
2160 text.render_to_string(&Markdown::new(source)),
2161 "highlighting changes colours only, never the text"
2162 );
2163 let by_code_theme = render_with(
2165 &Markdown::new(source)
2166 .inline_code_lexer("rust")
2167 .code_theme("InspiredGitHub"),
2168 );
2169 let by_inline_theme = render_with(
2170 &Markdown::new(source)
2171 .inline_code_lexer("rust")
2172 .inline_code_theme("InspiredGitHub"),
2173 );
2174 assert_ne!(highlighted, by_code_theme);
2175 assert_eq!(by_code_theme, by_inline_theme);
2176 }
2177
2178 #[test]
2179 fn a_code_only_list_item_keeps_the_bullet_on_its_padding_row() {
2180 let console = Console::builder().width(30).color_system(None).build();
2181 assert_eq!(console.render_export(&Markdown::new("- ```\n code\n ```")),
2182 "\n โข \n code \n \n");
2183 }
2184
2185 #[test]
2186 fn table_cell_images_share_a_row_until_the_cell_closes() {
2187 let console = Console::builder().width(30).color_system(None).build();
2188 let output = console.render_to_string(&Markdown::new(
2189 "| h |\n|---|\n|   |\n|  |",
2190 ));
2191 assert!(output.starts_with("\n๐ a ๐ b \n๐ c \n"), "{output:?}");
2192 }
2193
2194 #[test]
2195 fn quoted_rule_spacing_uses_the_last_closed_child() {
2196 let console = Console::builder().width(30).color_system(None).build();
2197 assert_eq!(
2198 console.render_to_string(&Markdown::new("> ---")),
2199 "โ --------------------------\nโ "
2200 );
2201 let output = console.render_to_string(&Markdown::new("> ---\n>\n> text"));
2202 assert!(
2203 output.starts_with("\nโ --------------------------\n"),
2204 "{output:?}"
2205 );
2206 }
2207
2208 #[test]
2209 fn ignored_html_blocks_keep_upstream_paragraph_spacing() {
2210 let console = Console::builder().width(30).color_system(None).build();
2211 for (source, expected) in [
2212 (
2213 "<div>hidden</div>\n\nParagraph",
2214 "\nParagraph ",
2215 ),
2216 ("<div>hidden</div>", ""),
2217 (
2218 "A\n\n<div>x</div>\n\nB",
2219 "A \n\n\nB ",
2220 ),
2221 ] {
2222 assert_eq!(console.render_to_string(&Markdown::new(source)), expected);
2223 }
2224 }
2225
2226 #[test]
2227 fn paragraph_inline_styles() {
2228 assert_eq!(
2229 render("a `x` b"),
2230 "a \x1b[1;36;40mx\x1b[0m b "
2231 );
2232 }
2233
2234 #[test]
2235 fn link_renders_osc8_hyperlink() {
2236 let out = render("See [the site](https://example.com) now.");
2240 assert!(
2241 out.contains(
2242 "\x1b]8;;https://example.com\x1b\\\x1b[4;34mthe site\x1b[0m\x1b]8;;\x1b\\"
2243 ),
2244 "got {out:?}"
2245 );
2246 assert!(!out.contains("id="), "we omit the random link id");
2247 }
2248
2249 #[test]
2250 fn fenced_code_block_is_highlighted() {
2251 let console = Console::builder()
2254 .force_terminal(true)
2255 .color_system(Some(ColorSystem::Truecolor))
2256 .width(24)
2257 .no_color(false)
2258 .build();
2259 let out = console.render_to_string(&Markdown::new("```rust\nfn main() {}\n```"));
2260 assert!(out.contains("fn"), "got {out:?}");
2261 assert!(out.contains("main"));
2262 assert!(out.contains('\x1b'), "code block should be colored");
2263 }
2264
2265 #[test]
2266 fn headings() {
2267 assert_eq!(render("# Head"), " \x1b[1;4mHead\x1b[0m ");
2268 assert_eq!(render("## Sub"), "\x1b[4;35mSub\x1b[0m ");
2269 }
2270
2271 #[test]
2272 fn two_paragraphs_separated_by_blank_line() {
2273 assert_eq!(
2274 render("First para.\n\nSecond para."),
2275 "First para. \n\nSecond para. "
2276 );
2277 }
2278
2279 #[test]
2280 fn bullet_list() {
2281 assert_eq!(
2282 render("- one\n- two"),
2283 "\n\x1b[1m \u{2022} \x1b[0mone \n\x1b[1m \u{2022} \x1b[0mtwo "
2284 );
2285 }
2286
2287 #[test]
2288 fn ordered_list() {
2289 assert_eq!(
2290 render("1. first\n2. second"),
2291 "\n\x1b[36m 1 \x1b[0mfirst \n\x1b[36m 2 \x1b[0msecond "
2292 );
2293 }
2294
2295 #[test]
2296 fn block_quote() {
2297 assert_eq!(
2298 render("> quoted text"),
2299 "\n\x1b[35m\u{258c} \x1b[0m\x1b[35mquoted text\x1b[0m\x1b[35m \x1b[0m"
2300 );
2301 }
2302
2303 #[test]
2304 fn gfm_table() {
2305 let console = Console::builder()
2308 .force_terminal(true)
2309 .color_system(Some(ColorSystem::Truecolor))
2310 .width(40)
2311 .no_color(false)
2312 .build();
2313 let md = "| Name | Age |\n| :--- | ---: |\n| Alice | 30 |\n| Bob | 7 |\n";
2314 let out = console.render_to_string(&Markdown::new(md));
2315 assert!(out.contains("Name"), "header present: {out:?}");
2316 assert!(out.contains("Alice"), "body cell present");
2317 assert!(out.contains('\u{2500}'), "SIMPLE box head rule present");
2318 assert!(out.contains(" 30"), "right-justified 30");
2320 assert!(out.contains(" 7"), "right-justified 7");
2321 }
2322
2323 #[test]
2324 fn thematic_break() {
2325 assert_eq!(
2326 render("a\n\n---\n\nb"),
2327 "a \n\n\x1b[2m--------------------\x1b[0m\n\nb "
2328 );
2329 }
2330
2331 #[test]
2332 fn thematic_break_at_end_adds_trailing_blank() {
2333 assert_eq!(
2337 render("a\n\n---"),
2338 "a \n\n\x1b[2m--------------------\x1b[0m\n"
2339 );
2340 }
2341}
2342
2343#[cfg(test)]
2344mod container_tests {
2345 use super::*;
2346
2347 fn plain(source: &str, width: usize) -> String {
2348 let console = Console::builder().width(width).color_system(None).build();
2349 console.render_to_string(&Markdown::new(source))
2350 }
2351
2352 fn assert_all_present(source: &str, expected: &[&str]) {
2356 let out = plain(source, 44);
2357 for item in expected {
2358 assert!(out.contains(item), "{item:?} missing from:\n{out}");
2359 }
2360 }
2361
2362 #[test]
2363 fn a_nested_list_keeps_every_item() {
2364 assert_all_present("- one\n- two\n - nested\n", &["one", "two", "nested"]);
2365 }
2366
2367 #[test]
2368 fn nesting_three_deep_keeps_every_item() {
2369 assert_all_present("- top\n - mid\n - deep\n", &["top", "mid", "deep"]);
2370 }
2371
2372 #[test]
2373 fn an_item_following_a_sublist_keeps_its_place() {
2374 let out = plain("- one\n - nested\n- two\n", 44);
2375 let (a, b, c) = (
2376 out.find("one").expect("one"),
2377 out.find("nested").expect("nested"),
2378 out.find("two").expect("two"),
2379 );
2380 assert!(a < b && b < c, "order was wrong:\n{out}");
2381 }
2382
2383 #[test]
2384 fn each_level_of_an_ordered_list_numbers_independently() {
2385 let out = plain("1. first\n2. second\n 1. sub\n", 44);
2386 for expected in ["1 first", "2 second", "1 sub"] {
2387 assert!(out.contains(expected), "expected {expected:?} in:\n{out}");
2388 }
2389 }
2390
2391 #[test]
2392 fn nested_items_are_indented_under_their_parent() {
2393 let out = plain("- top\n - child\n", 44);
2394 let indent = |needle: &str| {
2395 let line = out.lines().find(|l| l.contains(needle)).expect(needle);
2396 line.len() - line.trim_start().len()
2397 };
2398 assert!(indent("child") > indent("top"), "not indented:\n{out}");
2399 }
2400
2401 #[test]
2404 fn a_heading_inside_an_item_keeps_the_item_text() {
2405 assert_all_present(
2406 "- ITEMTEXT\n\n ## HEADTEXT\n\n- NEXTTEXT\n",
2407 &["ITEMTEXT", "HEADTEXT", "NEXTTEXT"],
2408 );
2409 }
2410
2411 #[test]
2414 fn a_code_block_inside_an_item_stays_in_the_item() {
2415 let out = plain("- FIRSTITEM\n\n ```\n CODETEXT\n ```\n", 44);
2416 let (item, code) = (
2417 out.find("FIRSTITEM").expect("item"),
2418 out.find("CODETEXT").expect("code"),
2419 );
2420 assert!(item < code, "the code was hoisted above its item:\n{out}");
2421 }
2422
2423 #[test]
2425 fn two_paragraphs_in_one_item_stay_separate() {
2426 let out = plain("- AAA\n\n BBB\n", 44);
2427 assert!(!out.contains("AAABBB"), "paragraphs were fused:\n{out}");
2428 assert!(out.contains("AAA") && out.contains("BBB"), "{out}");
2429 }
2430
2431 #[test]
2433 fn a_nested_quote_keeps_the_outer_text() {
2434 assert_all_present(
2435 "> OUTERTEXT\n>\n> > INNERTEXT\n",
2436 &["OUTERTEXT", "INNERTEXT"],
2437 );
2438 }
2439
2440 #[test]
2443 fn a_list_inside_a_quote_stays_quoted_and_in_order() {
2444 let out = plain("> intro\n>\n> - item one\n> - item two\n", 44);
2445 for line in out
2446 .lines()
2447 .filter(|l| l.contains("item one") || l.contains("intro"))
2448 {
2449 assert!(
2450 line.trim_start().starts_with(QUOTE_PREFIX.trim_end()),
2451 "lost the quote bar: {line:?}\n{out}"
2452 );
2453 }
2454 let (intro, one) = (
2455 out.find("intro").expect("intro"),
2456 out.find("item one").expect("item one"),
2457 );
2458 assert!(intro < one, "quote content was reordered:\n{out}");
2459 }
2460
2461 #[test]
2462 fn a_quote_inside_an_item_stays_inside_it() {
2463 let out = plain("- alpha\n\n > quoted\n", 44);
2464 assert!(!out.contains("alphaquoted"), "fused:\n{out}");
2465 let quoted = out.lines().find(|l| l.contains("quoted")).expect("quoted");
2466 assert!(
2467 quoted.contains(QUOTE_PREFIX.trim_end()),
2468 "lost the quote bar:\n{out}"
2469 );
2470 }
2471
2472 #[test]
2476 fn a_tight_item_keeps_its_text_before_a_heading() {
2477 assert_all_present(
2478 "- P1_text\n ## H1_head\n- P2_text\n",
2479 &["P1_text", "H1_head", "P2_text"],
2480 );
2481 }
2482
2483 #[test]
2484 fn a_tight_item_keeps_its_text_before_a_quote() {
2485 assert_all_present("- Q1_text\n > Q1_quote\n", &["Q1_text", "Q1_quote"]);
2486 }
2487
2488 #[test]
2489 fn a_tight_ordered_item_keeps_its_text_before_a_quote() {
2490 assert_all_present("1. C_num_text\n > C_quote\n", &["C_num_text", "C_quote"]);
2491 }
2492
2493 #[test]
2494 fn a_nested_tight_item_keeps_its_text_before_a_heading() {
2495 assert_all_present(
2496 "- A\n - B_inner\n ## B_head\n",
2497 &["A", "B_inner", "B_head"],
2498 );
2499 }
2500
2501 #[test]
2505 fn a_tight_code_block_renders_after_the_text_that_introduces_it() {
2506 let out = plain("- F1_text\n ```\n F1_code\n ```\n- F2_text\n", 55);
2507 let (text, code) = (
2508 out.find("F1_text").expect("F1_text"),
2509 out.find("F1_code").expect("F1_code"),
2510 );
2511 assert!(text < code, "the code block overtook its paragraph:\n{out}");
2512 }
2513
2514 #[test]
2518 fn deeply_nested_input_does_not_overflow_the_stack() {
2519 for depth in [50usize, 400, 2000] {
2520 let quotes = ">".repeat(depth) + " x\n";
2521 let _ = plain("es, 80);
2522
2523 let list: String = (0..depth)
2524 .map(|i| format!("{}- L{i}\n", " ".repeat(i)))
2525 .collect();
2526 let _ = plain(&list, 80);
2527 }
2528 }
2530
2531 #[test]
2535 fn a_tight_item_keeps_text_that_follows_a_nested_block() {
2536 assert_all_present(
2537 "- ITEM\n ```\n FIRST code\n ```\n SECOND para\n",
2538 &["ITEM", "FIRST code", "SECOND para"],
2539 );
2540 assert_all_present(
2541 "- ITEM\n ## HEAD\n TAIL para\n",
2542 &["ITEM", "HEAD", "TAIL para"],
2543 );
2544 assert_all_present("- ITEM\n ---\n TAIL para\n", &["ITEM", "TAIL para"]);
2545 }
2546
2547 #[test]
2550 fn a_heading_inside_a_quote_keeps_its_alignment() {
2551 let out = plain("> # Heading in quote\n", 50);
2552 let line = out
2553 .lines()
2554 .find(|l| l.contains("Heading in quote"))
2555 .expect("heading line");
2556 let after_bar = line.split(QUOTE_PREFIX.trim_end()).nth(1).expect("bar");
2558 assert!(
2559 after_bar.starts_with(" "),
2560 "heading was left-aligned inside the quote: {line:?}"
2561 );
2562 }
2563
2564 #[test]
2567 fn strikethrough_is_rendered_rather_than_leaked() {
2568 let out = plain("~~Deprecated~~ text\n", 50);
2569 assert!(!out.contains("~~"), "tildes leaked into output: {out:?}");
2570 assert!(out.contains("Deprecated"), "content lost: {out:?}");
2571 }
2572
2573 #[test]
2577 fn nested_blocks_gain_no_phantom_blank_row() {
2578 let out = plain("- a\n - b\n - c\n- d\n", 50);
2579 let rows: Vec<&str> = out
2580 .lines()
2581 .map(str::trim_end)
2582 .filter(|l| !l.is_empty())
2583 .collect();
2584 assert_eq!(
2585 rows.len(),
2586 4,
2587 "expected exactly four content rows, got {rows:?}"
2588 );
2589 }
2590
2591 #[test]
2596 fn nesting_does_not_narrow_each_level() {
2597 let source = "> d1\n\n>> d2\n\n>>> d3\n\n>>>> d4\n";
2598 let out = plain(source, 70);
2599 let widths: Vec<usize> = out
2600 .lines()
2601 .filter(|l| {
2602 l.contains("d1") || l.contains("d2") || l.contains("d3") || l.contains("d4")
2603 })
2604 .map(|l| l.chars().count())
2605 .collect();
2606 assert_eq!(widths.len(), 4, "expected one row per depth: {widths:?}");
2607 assert!(
2608 widths.iter().all(|w| *w == widths[0]),
2609 "each nesting level lost width: {widths:?}"
2610 );
2611 }
2612
2613 #[test]
2617 fn a_single_tilde_is_literal_text() {
2618 let out = plain("a ~struck~ b and ~~gone~~ here", 60);
2619 assert!(
2620 out.contains("~struck~"),
2621 "single tildes were eaten: {out:?}"
2622 );
2623 assert!(!out.contains("~~gone~~"), "double tildes leaked: {out:?}");
2624 assert!(out.contains("gone"), "struck content lost: {out:?}");
2625 }
2626
2627 #[test]
2631 fn a_code_block_is_inset_by_one_cell() {
2632 let out = plain("intro para\n\n```\nCODEWORD\n```\n", 40);
2633 let rows: Vec<&str> = out.lines().collect();
2634 let index = rows
2635 .iter()
2636 .position(|r| r.contains("CODEWORD"))
2637 .expect("code row present");
2638 assert!(
2639 rows[index].starts_with(' '),
2640 "no left gutter on the code row: {:?}",
2641 rows[index]
2642 );
2643 assert!(
2644 rows[index - 1].trim().is_empty(),
2645 "no blank inset row above the code: {:?}",
2646 rows[index - 1]
2647 );
2648 assert!(
2649 rows.get(index + 1).is_some_and(|r| r.trim().is_empty()),
2650 "no blank inset row below the code"
2651 );
2652 }
2653
2654 #[test]
2658 fn a_rule_is_followed_by_exactly_one_blank_row() {
2659 let out = plain("before\n\n---\n\nafter\n", 40);
2660 let rows: Vec<&str> = out.lines().collect();
2661 let rule = rows
2662 .iter()
2663 .position(|r| r.trim_end().ends_with('-') && r.trim().len() > 3)
2664 .expect("rule row present");
2665 let after = rows
2666 .iter()
2667 .position(|r| r.contains("after"))
2668 .expect("following row present");
2669 assert_eq!(
2670 after - rule,
2671 2,
2672 "expected one blank row between rule and next block: {rows:?}"
2673 );
2674 }
2675
2676 #[test]
2691 fn an_image_is_marked_and_hoisted() {
2692 let row = |source: &str| {
2693 plain(source, 40)
2694 .lines()
2695 .next()
2696 .expect("a row")
2697 .trim_end()
2698 .to_string()
2699 };
2700 assert_eq!(
2701 row(""),
2702 "๐ alt text"
2703 );
2704 assert_eq!(row(""), "๐ pic.png");
2705 assert_eq!(row(""), "๐ img");
2706 assert_eq!(
2708 row("Before  after."),
2709 "๐ alt text Before after."
2710 );
2711 assert_eq!(row(""), "๐ alt *em*");
2714 }
2715
2716 #[test]
2732 fn an_image_is_lifted_out_of_a_list_or_quote() {
2733 let rows = |source: &str| -> Vec<String> {
2734 plain(source, 40)
2735 .lines()
2736 .map(|line| line.trim_end().to_string())
2737 .collect()
2738 };
2739 assert_eq!(
2740 rows("- item with  inside"),
2741 vec!["๐ pic", " โข item with inside"]
2742 );
2743 assert_eq!(
2744 rows("> quoted  end"),
2745 vec!["๐ pic", "โ quoted end"]
2746 );
2747 }
2748
2749 #[test]
2753 fn a_long_code_line_keeps_its_tail() {
2754 let source = "```bash\npip install some-package another-package \
2755yet-another-package --upgrade --no-cache-dir\n```\n";
2756 let out = plain(source, 80);
2757 assert!(
2758 out.contains("no-cache-dir"),
2759 "the tail of the code line was discarded: {out:?}"
2760 );
2761 }
2762
2763 #[test]
2768 fn a_fenced_block_expands_its_tabs() {
2769 let out = plain("```python\ndef f():\n\tif x:\n\t\treturn 1\n```", 30);
2771 assert_eq!(
2772 out.split('\n').collect::<Vec<_>>(),
2773 [
2774 " ",
2775 " def f(): ",
2776 " if x: ",
2777 " return 1 ",
2778 " ",
2779 ]
2780 );
2781 }
2782}
2783
2784#[cfg(test)]
2788mod hyperlink_tests {
2789 use super::*;
2790 use crate::color::ColorSystem;
2791
2792 fn plain(source: &str, width: usize, hyperlinks: bool) -> String {
2793 Console::builder()
2794 .width(width)
2795 .color_system(None)
2796 .build()
2797 .render_to_string(&Markdown::new(source).hyperlinks(hyperlinks))
2798 }
2799
2800 fn ansi(source: &str, width: usize, hyperlinks: bool) -> String {
2801 Console::builder()
2802 .force_terminal(true)
2803 .color_system(Some(ColorSystem::Truecolor))
2804 .width(width)
2805 .no_color(false)
2806 .build()
2807 .render_to_string(&Markdown::new(source).hyperlinks(hyperlinks))
2808 }
2809
2810 #[test]
2815 fn hyperlinks_off_writes_the_url_out_after_the_label() {
2816 assert_eq!(
2817 plain("A [link](https://example.com) here.", 40, false),
2818 "A link (https://example.com) here. "
2819 );
2820 }
2821
2822 #[test]
2823 fn hyperlinks_on_keeps_the_label_alone() {
2824 assert_eq!(
2825 plain("A [link](https://example.com) here.", 40, true),
2826 "A link here. "
2827 );
2828 }
2829
2830 #[test]
2834 fn hyperlinks_off_widens_a_table_column_to_fit_the_url() {
2835 let source = "| T | W |\n| :-- | --: |\n| r | [repo](https://ex.org/a) |\n";
2836 assert_eq!(
2837 plain(source, 60, false).split('\n').collect::<Vec<_>>(),
2838 [
2839 "",
2840 " ",
2841 " T W ",
2842 " โโโโโโโโโโโโโโโโโโโโโโโโโโ ",
2843 " r repo (https://ex.org/a) ",
2844 " ",
2845 ]
2846 );
2847 assert_eq!(
2849 plain(source, 60, true).split('\n').collect::<Vec<_>>(),
2850 [
2851 "",
2852 " ",
2853 " T W ",
2854 " โโโโโโโ ",
2855 " r repo ",
2856 " "
2857 ]
2858 );
2859 }
2860
2861 #[test]
2864 fn hyperlinks_off_flattens_the_labels_own_emphasis() {
2865 assert_eq!(
2866 plain("A [**b** and *i* l](https://e.org) t.", 60, false),
2867 "A b and i l (https://e.org) t. "
2868 );
2869 }
2870
2871 #[test]
2875 fn hyperlinks_off_styles_the_label_and_the_url_under_a_heading() {
2876 assert_eq!(
2877 ansi("## H [x](https://e.org)", 40, false),
2878 "\x1b[4;35mH \x1b[0m\x1b[4;94mx\x1b[0m\x1b[4;35m (\x1b[0m\
2879 \x1b[4;34mhttps://e.org\x1b[0m\x1b[4;35m)\x1b[0m "
2880 );
2881 assert_eq!(
2882 ansi("## H [x](https://e.org)", 40, true),
2883 "\x1b[4;35mH \x1b[0m\x1b]8;;https://e.org\x1b\\\x1b[4;34mx\x1b[0m\
2884 \x1b]8;;\x1b\\ "
2885 );
2886 }
2887
2888 #[test]
2892 fn a_link_inside_bold_stays_bold() {
2893 assert_eq!(
2894 ansi("x **b [l](https://e.org) b** y", 60, true),
2895 "x \x1b[1mb \x1b[0m\x1b]8;;https://e.org\x1b\\\x1b[1;4;34ml\x1b[0m\
2896 \x1b]8;;\x1b\\\x1b[1m b\x1b[0m y "
2897 );
2898 }
2899
2900 #[test]
2904 fn a_link_labelled_with_inline_code_keeps_its_destination() {
2905 assert_eq!(
2906 ansi("A [`code`](https://e.org/x) tail.", 60, true),
2907 "A \x1b]8;;https://e.org/x\x1b\\\x1b[1;4;36;40mcode\x1b[0m\x1b]8;;\x1b\\ \
2908 tail. "
2909 );
2910 }
2911
2912 #[test]
2916 fn an_email_autolink_keeps_its_mailto_scheme() {
2917 assert_eq!(
2918 plain("Mail <who@where.net> now.", 50, false),
2919 "Mail who@where.net (mailto:who@where.net) now. "
2920 );
2921 assert_eq!(
2922 ansi("Mail <who@where.net> now.", 50, true),
2923 "Mail \x1b]8;;mailto:who@where.net\x1b\\\x1b[4;34mwho@where.net\x1b[0m\
2924 \x1b]8;;\x1b\\ now. "
2925 );
2926 }
2927
2928 #[test]
2932 fn an_image_inside_a_link_carries_the_links_style() {
2933 assert_eq!(
2934 ansi("[](https://e.org)", 40, true),
2935 "\u{1f306} \x1b]8;;https://e.org\x1b\\\x1b[4;34mbadge\x1b[0m\
2936 \x1b]8;;\x1b\\ "
2937 );
2938 }
2939
2940 #[test]
2944 fn a_single_tilde_inside_a_link_label_keeps_its_place() {
2945 let out = plain("A [~a~ label](https://e.com) here.\n", 60, false);
2946 assert!(
2947 out.contains("~a~ label"),
2948 "tilde moved out of the label: {out:?}"
2949 );
2950 assert!(!out.contains("~~a"), "tildes were reordered: {out:?}");
2951 }
2952
2953 #[test]
2956 fn a_single_tilde_survives_with_no_buffer_open() {
2957 let out = plain("~5~10 and ~x~\n", 40, false);
2958 assert!(out.contains("~5~10"), "tilde dropped: {out:?}");
2959 assert!(out.contains("~x~"), "tilde dropped: {out:?}");
2960 }
2961
2962 #[test]
2965 fn table_cell_tildes_pair_like_markdown_it() {
2966 let out = plain("| h |\n|---|\n| ~~~c~~~ |\n", 20, false);
2967 assert!(out.contains("~c~"), "{out:?}");
2968 assert!(!out.contains("~~"), "{out:?}");
2969 }
2970
2971 #[test]
2973 fn code_block_tildes_are_untouched() {
2974 let out = plain("```\na ~~~x~~~ b\n```\n", 30, false);
2975 assert!(out.contains("a ~~~x~~~ b"), "{out:?}");
2976 }
2977
2978 #[test]
2983 fn tildes_crossing_a_later_emphasis_stay_literal() {
2984 let out = plain("~~a *b~~ c*", 30, false);
2985 assert!(out.contains("~~a b~~ c"), "{out:?}");
2986 }
2987}