1use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
2use ratatui::style::{Modifier, Style};
3use ratatui::text::{Line, Span};
4use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
5
6use crate::render::theme::Theme;
7
8#[derive(Debug, Clone)]
13pub struct MarkdownLine {
14 pub line: Line<'static>,
15 pub preformatted: bool,
16}
17
18#[derive(Debug, Clone)]
19struct ListState {
20 next_number: Option<u64>,
21 cont_indent: String,
25}
26
27fn list_marker_style(theme: &Theme) -> Style {
31 Style::new().fg(theme.colors.text_secondary.to_color())
32}
33
34#[must_use]
40pub fn line_hanging_indent(line: &Line, theme: &Theme) -> usize {
41 let marker = list_marker_style(theme);
42 let mut indent = 0usize;
43 for span in &line.spans {
44 let text = span.content.as_ref();
45 let trimmed = text.trim_start_matches(' ');
46 if trimmed.is_empty() {
47 indent += text.width(); continue;
49 }
50 indent += text.width() - trimmed.width();
53 if span.style == marker {
54 indent += trimmed.width();
55 }
56 break;
57 }
58 indent
59}
60
61#[expect(
70 clippy::too_many_lines,
71 reason = "predates the lint; see .github/baselines/expect_budget.txt"
72)]
73#[must_use]
74pub fn parse_markdown(input: &str, theme: &Theme, width: usize) -> Vec<MarkdownLine> {
75 let mut options = Options::empty();
76 options.insert(Options::ENABLE_STRIKETHROUGH);
77 options.insert(Options::ENABLE_TABLES);
78
79 let c = &theme.colors;
81 let code_bg = c.code_background.to_color();
82 let code_fg = c.code_foreground.to_color();
83 let heading1 = Style::new().fg(c.header.to_color()).bold();
84 let heading2 = Style::new().fg(c.info.to_color()).bold();
85 let heading3 = Style::new().fg(c.success.to_color()).bold();
86 let heading_other = Style::new().fg(c.warning.to_color()).bold();
87 let link_style = Style::new()
88 .fg(c.info.to_color())
89 .add_modifier(Modifier::UNDERLINED);
90 let marker_style = list_marker_style(theme);
91 let rule_style = Style::new().fg(c.text_disabled.to_color());
92 let quote_bar_style = Style::new().fg(c.text_disabled.to_color());
93 let quote_text_style = Style::new()
94 .fg(c.text_secondary.to_color())
95 .add_modifier(Modifier::ITALIC);
96
97 let parser = Parser::new_ext(input, options);
98 let mut lines: Vec<Line<'static>> = Vec::new();
99 let mut current_line_spans: Vec<Span<'static>> = Vec::new();
100 let mut style_stack = vec![Style::default()];
101 let mut in_code_block = false;
102 let mut code_block_content = String::new();
103 let mut code_block_lang = String::new();
104 let mut current_link_url: Option<String> = None;
105 let mut list_stack: Vec<ListState> = Vec::new();
106
107 let mut in_table = false;
109 let mut table_rows: Vec<Vec<String>> = Vec::new();
110 let mut current_row: Vec<String> = Vec::new();
111 let mut current_cell = String::new();
112 let mut table_header_len: usize = 0;
113 let mut table_line_indices: std::collections::HashSet<usize> = std::collections::HashSet::new();
116
117 for event in parser {
118 match event {
119 Event::Start(tag) => {
120 let new_style = match tag {
121 Tag::Heading { level, .. } => {
122 if !current_line_spans.is_empty() {
123 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
124 }
125 if !lines.is_empty() {
127 lines.push(Line::from(""));
128 }
129 match level {
130 HeadingLevel::H1 => heading1,
131 HeadingLevel::H2 => heading2,
132 HeadingLevel::H3 => heading3,
133 _ => heading_other,
134 }
135 },
136 Tag::Emphasis => style_stack.last().copied().unwrap_or_default().italic(),
137 Tag::Strong => style_stack.last().copied().unwrap_or_default().bold(),
138 Tag::Strikethrough => style_stack
139 .last()
140 .copied()
141 .unwrap_or_default()
142 .crossed_out(),
143 Tag::CodeBlock(kind) => {
144 in_code_block = true;
145 code_block_content.clear();
146 if !current_line_spans.is_empty() {
147 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
148 }
149 code_block_lang = match kind {
150 CodeBlockKind::Fenced(lang) => lang.to_string(),
151 CodeBlockKind::Indented => String::new(),
152 };
153 if !code_block_lang.is_empty() {
154 lines.push(Line::from(Span::styled(
155 code_block_lang.clone(),
156 Style::new()
157 .fg(c.text_disabled.to_color())
158 .add_modifier(Modifier::ITALIC),
159 )));
160 }
161 Style::default().fg(code_fg)
162 },
163 Tag::List(start) => {
164 list_stack.push(ListState {
165 next_number: start,
166 cont_indent: String::new(),
167 });
168 if !current_line_spans.is_empty() {
169 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
170 }
171 style_stack.last().copied().unwrap_or_default()
172 },
173 Tag::Item => {
174 let indent = " ".repeat(list_stack.len());
175 let marker = if let Some(state) = list_stack.last_mut() {
176 if let Some(current) = state.next_number {
177 state.next_number = Some(current + 1);
178 format!("{current}. ")
179 } else {
180 "• ".to_string()
181 }
182 } else {
183 "• ".to_string()
184 };
185 let cont_indent =
188 format!("{}{}", indent, " ".repeat(marker.as_str().width()));
189 if let Some(state) = list_stack.last_mut() {
190 state.cont_indent = cont_indent;
191 }
192 current_line_spans.push(Span::raw(indent));
193 current_line_spans.push(Span::styled(marker, marker_style));
194 style_stack.last().copied().unwrap_or_default()
195 },
196 Tag::Paragraph => {
197 if current_line_spans.is_empty()
201 && let Some(state) = list_stack.last()
202 && !state.cont_indent.is_empty()
203 {
204 current_line_spans.push(Span::raw(state.cont_indent.clone()));
205 }
206 style_stack.last().copied().unwrap_or_default()
207 },
208 Tag::Table(_alignments) => {
209 in_table = true;
210 table_rows.clear();
211 table_header_len = 0;
212 if !current_line_spans.is_empty() {
213 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
214 }
215 style_stack.last().copied().unwrap_or_default()
216 },
217 Tag::TableHead | Tag::TableRow => {
218 current_row.clear();
219 style_stack.last().copied().unwrap_or_default()
220 },
221 Tag::TableCell => {
222 current_cell.clear();
223 style_stack.last().copied().unwrap_or_default()
224 },
225 Tag::Link { dest_url, .. } => {
226 current_link_url = Some(dest_url.to_string());
230 link_style
231 },
232 Tag::BlockQuote(_) => {
233 if !current_line_spans.is_empty() {
234 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
235 }
236 current_line_spans.push(Span::styled("│ ", quote_bar_style));
237 quote_text_style
238 },
239 _ => style_stack.last().copied().unwrap_or_default(),
240 };
241 style_stack.push(new_style);
242 },
243 Event::End(tag) => {
244 style_stack.pop();
245 match tag {
246 TagEnd::Heading(_)
249 | TagEnd::Paragraph
250 | TagEnd::Item
251 | TagEnd::BlockQuote(_)
252 if !current_line_spans.is_empty() =>
253 {
254 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
255 },
256 TagEnd::CodeBlock => {
257 in_code_block = false;
258 let prefixes = line_comment_prefixes(&code_block_lang);
259 let base = Style::default().fg(code_fg).bg(code_bg);
260 for line_text in code_block_content.lines() {
261 let spans = highlight_code_line(line_text, prefixes, theme);
262 lines.push(Line::from(spans).style(base));
265 }
266 code_block_content.clear();
267 code_block_lang.clear();
268 },
269 TagEnd::List(_) => {
270 let _ = list_stack.pop();
271 if list_stack.is_empty() {
272 lines.push(Line::from(""));
273 }
274 },
275 TagEnd::TableCell => {
276 current_row.push(std::mem::take(&mut current_cell));
277 },
278 TagEnd::TableHead => {
279 table_header_len = current_row.len();
280 table_rows.push(std::mem::take(&mut current_row));
281 },
282 TagEnd::TableRow => {
283 table_rows.push(std::mem::take(&mut current_row));
284 },
285 TagEnd::Table => {
286 in_table = false;
287 let from = lines.len();
288 render_table(&mut lines, &table_rows, table_header_len, theme, width);
289 table_line_indices.extend(from..lines.len());
290 table_rows.clear();
291 },
292 TagEnd::Link => {
293 if let Some(url) = current_link_url.take() {
296 let text: String = current_line_spans
297 .iter()
298 .map(|s| s.content.as_ref())
299 .collect();
300 if !url.is_empty() && !text.ends_with(&url) {
301 current_line_spans.push(Span::styled(
302 format!(" ({url})"),
303 Style::new().fg(c.text_disabled.to_color()),
304 ));
305 }
306 }
307 },
308 _ => {},
309 }
310 },
311 Event::Text(text) => {
312 if in_code_block {
313 code_block_content.push_str(&text);
314 } else if in_table {
315 current_cell.push_str(&text);
316 } else {
317 let style = style_stack.last().copied().unwrap_or_default();
318 current_line_spans.push(Span::styled(text.to_string(), style));
319 }
320 },
321 Event::Code(code) => {
322 if in_table {
323 current_cell.push_str(&code);
324 } else {
325 let style = Style::default().fg(code_fg).bg(code_bg);
329 current_line_spans.push(Span::styled(code.to_string(), style));
330 }
331 },
332 Event::Rule => {
333 if !current_line_spans.is_empty() {
334 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
335 }
336 lines.push(Line::from(Span::styled("─".repeat(40), rule_style)));
337 },
338 Event::SoftBreak | Event::HardBreak if !current_line_spans.is_empty() => {
339 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
340 },
341 _ => {},
342 }
343 }
344
345 if !current_line_spans.is_empty() {
346 lines.push(Line::from(current_line_spans));
347 }
348
349 lines
352 .into_iter()
353 .enumerate()
354 .map(|(i, line)| MarkdownLine {
355 preformatted: line.style.bg == Some(code_bg) || table_line_indices.contains(&i),
356 line,
357 })
358 .collect()
359}
360
361fn render_table(
367 lines: &mut Vec<Line<'static>>,
368 table_rows: &[Vec<String>],
369 table_header_len: usize,
370 theme: &Theme,
371 width: usize,
372) {
373 let c = &theme.colors;
374 let num_cols = table_rows.iter().map(|r| r.len()).max().unwrap_or(0);
375 if num_cols == 0 {
376 return;
377 }
378
379 let mut col_widths = vec![0usize; num_cols];
381 for row in table_rows {
382 for (i, cell) in row.iter().enumerate() {
383 if i < num_cols {
384 col_widths[i] = col_widths[i].max(cell.width());
385 }
386 }
387 }
388 for w in &mut col_widths {
389 *w = (*w).max(3);
390 }
391
392 let overhead = 2 + 3 * num_cols;
401 if col_widths.iter().sum::<usize>() + overhead > width {
402 let budget = width.saturating_sub(overhead);
403 let mut total: usize = col_widths.iter().sum();
404 while total > budget {
405 let widest = (0..num_cols)
406 .filter(|&i| col_widths[i] > 1)
407 .max_by_key(|&i| col_widths[i]);
408 match widest {
409 Some(i) => {
410 col_widths[i] -= 1;
411 total -= 1;
412 },
413 None => break, }
415 }
416 }
417
418 let border_style = Style::default().fg(c.text_disabled.to_color());
419 let header_style = Style::default().fg(c.header.to_color()).bold();
420 let cell_style = Style::default().fg(c.text_primary.to_color());
421
422 for (row_idx, row) in table_rows.iter().enumerate() {
423 let style = if row_idx == 0 && table_header_len > 0 {
424 header_style
425 } else {
426 cell_style
427 };
428 let wrapped: Vec<Vec<String>> = (0..num_cols)
430 .map(|ci| {
431 wrap_cell(
432 row.get(ci).map(String::as_str).unwrap_or(""),
433 col_widths[ci],
434 )
435 })
436 .collect();
437 let row_height = wrapped.iter().map(Vec::len).max().unwrap_or(1).max(1);
438
439 for li in 0..row_height {
440 let mut spans = vec![Span::styled("| ", border_style)];
441 for ci in 0..num_cols {
442 let w = col_widths[ci];
443 let cell_line = wrapped[ci].get(li).map(String::as_str).unwrap_or("");
444 let padding = w.saturating_sub(cell_line.width());
445 let padded = format!("{}{}", cell_line, " ".repeat(padding));
446 spans.push(Span::styled(padded, style));
447 spans.push(Span::styled(" | ", border_style));
448 }
449 lines.push(Line::from(spans));
450 }
451
452 if row_idx == 0 && table_header_len > 0 {
453 let mut sep_spans = vec![Span::styled("|-", border_style)];
454 for (col, &w) in col_widths.iter().enumerate() {
455 sep_spans.push(Span::styled("-".repeat(w), border_style));
456 let closer = if col + 1 == num_cols { "-|" } else { "-|-" };
461 sep_spans.push(Span::styled(closer, border_style));
462 }
463 lines.push(Line::from(sep_spans));
464 }
465 }
466
467 lines.push(Line::from(""));
468}
469
470fn wrap_cell(text: &str, width: usize) -> Vec<String> {
473 if width == 0 {
474 return vec![String::new()];
475 }
476 let mut lines: Vec<String> = Vec::new();
477 let mut cur = String::new();
478 let mut cur_w = 0usize;
479 for word in text.split_whitespace() {
480 let ww = word.width();
481 if ww > width {
482 if !cur.is_empty() {
486 lines.push(std::mem::take(&mut cur));
487 cur_w = 0;
488 }
489 let chunks = chunk_by_width(word, width);
490 let n = chunks.len();
491 for (k, chunk) in chunks.into_iter().enumerate() {
492 if k + 1 < n {
493 lines.push(chunk);
494 } else {
495 cur_w = chunk.width();
496 cur = chunk;
497 }
498 }
499 continue;
500 }
501 let sep = usize::from(!cur.is_empty());
502 if cur_w + sep + ww > width {
503 lines.push(std::mem::take(&mut cur));
504 cur.push_str(word);
505 cur_w = ww;
506 } else {
507 if sep == 1 {
508 cur.push(' ');
509 }
510 cur.push_str(word);
511 cur_w += sep + ww;
512 }
513 }
514 if !cur.is_empty() || lines.is_empty() {
515 lines.push(cur);
516 }
517 lines
518}
519
520fn chunk_by_width(s: &str, width: usize) -> Vec<String> {
523 let mut chunks: Vec<String> = Vec::new();
524 let mut cur = String::new();
525 let mut cur_w = 0usize;
526 for ch in s.chars() {
527 let cw = ch.width().unwrap_or(0);
528 if cur_w + cw > width && !cur.is_empty() {
529 chunks.push(std::mem::take(&mut cur));
530 cur_w = 0;
531 }
532 cur.push(ch);
533 cur_w += cw;
534 }
535 if !cur.is_empty() {
536 chunks.push(cur);
537 }
538 if chunks.is_empty() {
539 chunks.push(String::new());
540 }
541 chunks
542}
543
544fn line_comment_prefixes(lang: &str) -> &'static [&'static str] {
547 match lang.trim().to_ascii_lowercase().as_str() {
548 "rust" | "rs" | "c" | "cpp" | "c++" | "h" | "hpp" | "java" | "js" | "javascript" | "ts"
549 | "typescript" | "tsx" | "jsx" | "go" | "golang" | "swift" | "kotlin" | "kt" | "scala"
550 | "cs" | "csharp" | "php" | "dart" | "zig" | "rust,no_run" => &["//"],
551 "python" | "py" | "ruby" | "rb" | "sh" | "bash" | "zsh" | "shell" | "console" | "yaml"
552 | "yml" | "toml" | "ini" | "perl" | "pl" | "r" | "elixir" | "ex" | "makefile"
553 | "dockerfile" | "nix" => &["#"],
554 "sql" | "lua" | "haskell" | "hs" | "ada" => &["--"],
555 "lisp" | "clojure" | "clj" | "scheme" | "el" => &[";"],
556 _ => &["//", "#"],
557 }
558}
559
560fn is_keyword(w: &str) -> bool {
562 matches!(
563 w,
564 "fn" | "let"
565 | "const"
566 | "mut"
567 | "pub"
568 | "struct"
569 | "enum"
570 | "impl"
571 | "trait"
572 | "use"
573 | "mod"
574 | "match"
575 | "if"
576 | "else"
577 | "for"
578 | "while"
579 | "loop"
580 | "return"
581 | "break"
582 | "continue"
583 | "async"
584 | "await"
585 | "move"
586 | "ref"
587 | "where"
588 | "type"
589 | "dyn"
590 | "as"
591 | "in"
592 | "static"
593 | "unsafe"
594 | "extern"
595 | "crate"
596 | "self"
597 | "Self"
598 | "super"
599 | "function"
600 | "var"
601 | "def"
602 | "class"
603 | "import"
604 | "from"
605 | "export"
606 | "default"
607 | "public"
608 | "private"
609 | "protected"
610 | "void"
611 | "int"
612 | "long"
613 | "float"
614 | "double"
615 | "bool"
616 | "boolean"
617 | "char"
618 | "string"
619 | "true"
620 | "false"
621 | "null"
622 | "nil"
623 | "None"
624 | "True"
625 | "False"
626 | "this"
627 | "new"
628 | "try"
629 | "catch"
630 | "finally"
631 | "throw"
632 | "throws"
633 | "package"
634 | "interface"
635 | "extends"
636 | "implements"
637 | "do"
638 | "then"
639 | "elif"
640 | "lambda"
641 | "yield"
642 | "with"
643 | "and"
644 | "or"
645 | "not"
646 | "is"
647 | "end"
648 | "begin"
649 | "val"
650 | "func"
651 | "defer"
652 | "select"
653 | "chan"
654 | "range"
655 | "switch"
656 | "case"
657 )
658}
659
660fn highlight_code_line(text: &str, comment_prefixes: &[&str], theme: &Theme) -> Vec<Span<'static>> {
664 let c = &theme.colors;
665 let bg = c.code_background.to_color();
666 let base = Style::default().fg(c.code_foreground.to_color()).bg(bg);
667 let kw_style = Style::default().fg(c.code_keyword.to_color()).bg(bg);
668 let str_style = Style::default().fg(c.code_string.to_color()).bg(bg);
669 let com_style = Style::default().fg(c.code_comment.to_color()).bg(bg);
670
671 let mut spans: Vec<Span<'static>> = Vec::new();
672 let mut pending = String::new();
673 let flush = |spans: &mut Vec<Span<'static>>, pending: &mut String| {
674 if !pending.is_empty() {
675 spans.push(Span::styled(std::mem::take(pending), base));
676 }
677 };
678
679 let mut it = text.char_indices().peekable();
680 while let Some(&(byte_idx, ch)) = it.peek() {
681 if comment_prefixes
683 .iter()
684 .any(|p| text[byte_idx..].starts_with(p))
685 {
686 flush(&mut spans, &mut pending);
687 spans.push(Span::styled(text[byte_idx..].to_string(), com_style));
688 break;
689 }
690 if ch == '"' || ch == '\'' || ch == '`' {
692 flush(&mut spans, &mut pending);
693 let quote = ch;
694 let start = byte_idx;
695 it.next(); let mut end = text.len();
697 let mut escaped = false;
698 while let Some(&(bi, ci)) = it.peek() {
699 it.next();
700 end = bi + ci.len_utf8();
701 if escaped {
702 escaped = false;
703 } else if ci == '\\' {
704 escaped = true;
705 } else if ci == quote {
706 break;
707 }
708 }
709 spans.push(Span::styled(text[start..end].to_string(), str_style));
710 continue;
711 }
712 if ch.is_alphanumeric() || ch == '_' {
714 let start = byte_idx;
715 let mut end = byte_idx + ch.len_utf8();
716 it.next();
717 while let Some(&(bi, ci)) = it.peek() {
718 if ci.is_alphanumeric() || ci == '_' {
719 end = bi + ci.len_utf8();
720 it.next();
721 } else {
722 break;
723 }
724 }
725 let word = &text[start..end];
726 if is_keyword(word) {
727 flush(&mut spans, &mut pending);
728 spans.push(Span::styled(word.to_string(), kw_style));
729 } else {
730 pending.push_str(word);
731 }
732 continue;
733 }
734 pending.push(ch);
736 it.next();
737 }
738 flush(&mut spans, &mut pending);
739 if spans.is_empty() {
740 spans.push(Span::styled(String::new(), base));
741 }
742 spans
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748
749 fn md(input: &str) -> Vec<Line<'static>> {
752 parse_markdown(input, &Theme::dark(), 80)
753 .into_iter()
754 .map(|ml| ml.line)
755 .collect()
756 }
757
758 fn lines_to_text(lines: &[Line]) -> String {
760 lines
761 .iter()
762 .map(|line| {
763 line.spans
764 .iter()
765 .map(|s| s.content.as_ref())
766 .collect::<String>()
767 })
768 .collect::<Vec<_>>()
769 .join("\n")
770 }
771
772 #[test]
773 fn wide_table_fits_narrow_viewport() {
774 let width = 16;
777 let lines = parse_markdown(
778 "| aaaa | bbbb | cccc |\n|---|---|---|\n| aaaaaaaa | bbbbbbbb | cccccccc |\n",
779 &Theme::dark(),
780 width,
781 );
782 for ml in &lines {
783 let w: usize = ml.line.spans.iter().map(|s| s.content.width()).sum();
784 assert!(w <= width, "table row width {w} exceeds viewport {width}");
785 }
786 }
787
788 #[test]
789 fn test_plain_text() {
790 let lines = md("Hello, world!");
791 assert!(!lines.is_empty());
792 assert!(lines_to_text(&lines).contains("Hello, world!"));
793 }
794
795 #[test]
796 fn test_heading_levels() {
797 let lines = md("# H1\n## H2\n### H3");
798 let text = lines_to_text(&lines);
799 assert!(text.contains("H1"));
800 assert!(text.contains("H2"));
801 assert!(text.contains("H3"));
802 assert!(lines.len() >= 3);
803 }
804
805 #[test]
806 fn line_hanging_indent_aligns_under_list_marker() {
807 let theme = Theme::dark();
808 fn find<'a>(lines: &'a [Line<'static>], needle: &str) -> &'a Line<'static> {
809 lines
810 .iter()
811 .find(|l| lines_to_text(std::slice::from_ref(l)).contains(needle))
812 .expect("line present")
813 }
814
815 let bullet = md("- Alpha item");
818 assert_eq!(
819 line_hanging_indent(find(&bullet, "Alpha"), &theme),
820 4,
821 "bullet: 2 indent + 2 marker"
822 );
823
824 let numbered = md("1. First item");
826 assert_eq!(
827 line_hanging_indent(find(&numbered, "First"), &theme),
828 5,
829 "numbered: 2 indent + 3 marker"
830 );
831
832 let para = md("Just a sentence.");
834 assert_eq!(
835 line_hanging_indent(find(¶, "sentence"), &theme),
836 0,
837 "paragraph: flush to the gutter"
838 );
839 }
840
841 #[test]
842 fn test_code_block() {
843 let lines = md("```rust\nfn main() {}\n```");
844 let text = lines_to_text(&lines);
845 assert!(text.contains("fn main() {}"));
846 assert!(text.contains("rust"));
847 }
848
849 #[test]
850 fn code_block_lines_tagged_with_code_background() {
851 let lines = md("```rust\nfn main() {}\n```");
852 let code_bg = Theme::dark().colors.code_background.to_color();
853 assert!(
856 lines.iter().any(|l| l.style.bg == Some(code_bg)
857 && l.spans
858 .iter()
859 .map(|s| s.content.as_ref())
860 .collect::<String>()
861 .contains("fn main")),
862 "code body line must carry the code_background marker"
863 );
864 }
865
866 #[test]
867 fn code_block_highlights_keywords() {
868 let lines = md("```rust\nfn main() {}\n```");
869 let kw = Theme::dark().colors.code_keyword.to_color();
870 let fn_styled_as_keyword = lines.iter().any(|l| {
872 l.spans
873 .iter()
874 .any(|s| s.content.as_ref() == "fn" && s.style.fg == Some(kw))
875 });
876 assert!(
877 fn_styled_as_keyword,
878 "`fn` should be highlighted as a keyword"
879 );
880 }
881
882 #[test]
883 fn code_block_preserves_indentation() {
884 let lines = md("```rust\n indented();\n```");
885 assert!(
887 lines.iter().any(|l| l
888 .spans
889 .iter()
890 .map(|s| s.content.as_ref())
891 .collect::<String>()
892 .starts_with(" indented")),
893 "code indentation must be preserved verbatim"
894 );
895 }
896
897 #[test]
898 fn test_code_block_no_lang() {
899 let lines = md("```\nsome code\n```");
900 assert!(lines_to_text(&lines).contains("some code"));
901 }
902
903 #[test]
904 fn test_inline_code_has_no_padding() {
905 let lines = md("Use `cargo build` to compile");
906 let code_bg = Theme::dark().colors.code_background.to_color();
907 let tight = lines.iter().any(|l| {
910 l.spans
911 .iter()
912 .any(|s| s.style.bg == Some(code_bg) && s.content.as_ref() == "cargo build")
913 });
914 assert!(
915 tight,
916 "inline code should be tight (no surrounding padding spaces)"
917 );
918 }
919
920 #[test]
921 fn test_unordered_list() {
922 let lines = md("- Item 1\n- Item 2\n- Item 3");
923 let text = lines_to_text(&lines);
924 assert!(text.contains("Item 1"));
925 assert!(text.contains("•"));
926 }
927
928 #[test]
929 fn test_ordered_list_preserves_numbers() {
930 let lines = md("1. First\n2. Second\n3. Third");
931 let text = lines_to_text(&lines);
932 assert!(text.contains("1. First"));
933 assert!(text.contains("2. Second"));
934 assert!(!text.contains("• First"));
935 }
936
937 #[test]
938 fn loose_list_item_body_hangs_under_item_text() {
939 let lines = md("- **Finding** — verified\n\n Body paragraph explaining the finding.");
942 let rendered: Vec<String> = lines
943 .iter()
944 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
945 .collect();
946 assert!(
947 rendered
948 .iter()
949 .any(|l| l.starts_with(" • ") && l.contains("Finding")),
950 "marker line should carry the bullet + indent"
951 );
952 let body = rendered
953 .iter()
954 .find(|l| l.contains("Body paragraph"))
955 .expect("body line present");
956 assert_eq!(
959 body, " Body paragraph explaining the finding.",
960 "continuation paragraph must hang-indent under the item text"
961 );
962 }
963
964 #[test]
965 fn test_nested_list() {
966 let lines = md("- Outer\n - Inner");
967 let text = lines_to_text(&lines);
968 assert!(text.contains("Outer"));
969 assert!(text.contains("Inner"));
970 }
971
972 #[test]
973 fn test_bold_and_italic() {
974 let lines = md("**bold** and *italic*");
975 let text = lines_to_text(&lines);
976 assert!(text.contains("bold"));
977 assert!(text.contains("italic"));
978 }
979
980 #[test]
981 fn test_link_shows_text_and_url() {
982 let lines = md("[click here](https://example.com)");
983 let text = lines_to_text(&lines);
984 assert!(text.contains("click here"));
985 assert!(text.contains("https://example.com"));
987 }
988
989 #[test]
990 fn test_autolink_does_not_duplicate_url() {
991 let lines = md("<https://example.com>");
993 let text = lines_to_text(&lines);
994 assert_eq!(text.matches("https://example.com").count(), 1);
995 }
996
997 #[test]
998 fn test_blockquote() {
999 let lines = md("> Quoted text");
1000 let text = lines_to_text(&lines);
1001 assert!(text.contains("Quoted text"));
1002 assert!(text.contains("│"));
1003 }
1004
1005 #[test]
1006 fn test_horizontal_rule() {
1007 let lines = md("above\n\n---\n\nbelow");
1008 let text = lines_to_text(&lines);
1009 assert!(text.contains("above"));
1010 assert!(text.contains("below"));
1011 assert!(text.contains("───"), "thematic break should render a rule");
1013 }
1014
1015 #[test]
1016 fn test_table() {
1017 let lines = md("| Header1 | Header2 |\n|---------|--------|\n| Cell1 | Cell2 |");
1018 let text = lines_to_text(&lines);
1019 assert!(text.contains("Header1"));
1020 assert!(text.contains("Cell1"));
1021 assert!(text.contains("|"));
1022 }
1023
1024 #[test]
1025 fn test_strikethrough() {
1026 let lines = md("~~deleted~~");
1027 assert!(lines_to_text(&lines).contains("deleted"));
1028 }
1029
1030 #[test]
1031 fn test_empty_input() {
1032 assert!(md("").is_empty());
1033 }
1034
1035 #[test]
1036 fn test_multiple_paragraphs() {
1037 let lines = md("Paragraph 1\n\nParagraph 2");
1038 let text = lines_to_text(&lines);
1039 assert!(text.contains("Paragraph 1"));
1040 assert!(text.contains("Paragraph 2"));
1041 }
1042
1043 #[test]
1044 fn highlight_code_line_marks_strings_and_comments() {
1045 let theme = Theme::dark();
1046 let spans = highlight_code_line("let s = \"hi\"; // note", &["//"], &theme);
1047 let str_color = theme.colors.code_string.to_color();
1048 let com_color = theme.colors.code_comment.to_color();
1049 assert!(
1050 spans
1051 .iter()
1052 .any(|s| s.content.contains("\"hi\"") && s.style.fg == Some(str_color)),
1053 "string literal must use the string color"
1054 );
1055 assert!(
1056 spans
1057 .iter()
1058 .any(|s| s.content.contains("// note") && s.style.fg == Some(com_color)),
1059 "trailing comment must use the comment color"
1060 );
1061 }
1062
1063 #[test]
1066 fn table_column_widths_use_display_cells() {
1067 let lines = md("| Name | Score |\n|------|-------|\n| 你好 | 100 |\n| ab | 50 |");
1068 let mut cjk_row_width = 0usize;
1069 let mut ascii_row_width = 0usize;
1070 for line in &lines {
1071 let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
1072 if rendered.contains("你好") {
1073 cjk_row_width = rendered.width();
1074 } else if rendered.contains("ab") && rendered.contains("|") {
1075 ascii_row_width = rendered.width();
1076 }
1077 }
1078 assert!(cjk_row_width > 0, "did not find the CJK body row");
1079 assert!(ascii_row_width > 0, "did not find the ASCII body row");
1080 assert_eq!(
1081 cjk_row_width, ascii_row_width,
1082 "CJK and ASCII rows must have equal display width to align"
1083 );
1084 }
1085
1086 #[test]
1087 fn table_lines_flagged_preformatted_prose_is_not() {
1088 let out = parse_markdown(
1091 "Intro paragraph.\n\n| A | B |\n|---|---|\n| 1 | 2 |",
1092 &Theme::dark(),
1093 80,
1094 );
1095 let para = out
1096 .iter()
1097 .find(|ml| ml.line.spans.iter().any(|s| s.content.contains("Intro")))
1098 .expect("paragraph present");
1099 assert!(!para.preformatted, "prose must word-wrap normally");
1100 let table_rows: Vec<_> = out
1101 .iter()
1102 .filter(|ml| {
1103 ml.line
1104 .spans
1105 .first()
1106 .is_some_and(|s| s.content.starts_with('|'))
1107 })
1108 .collect();
1109 assert!(!table_rows.is_empty(), "table should render rows");
1110 assert!(
1111 table_rows.iter().all(|ml| ml.preformatted),
1112 "every table line must be preformatted"
1113 );
1114 }
1115
1116 #[test]
1117 fn code_lines_flagged_preformatted() {
1118 let out = parse_markdown("```\nlet x = 1;\n```", &Theme::dark(), 80);
1119 assert!(
1120 out.iter()
1121 .filter(|ml| ml.line.spans.iter().any(|s| s.content.contains("let x")))
1122 .all(|ml| ml.preformatted),
1123 "code-block lines must be preformatted"
1124 );
1125 }
1126
1127 #[test]
1128 fn wide_table_wraps_cells_to_fit() {
1129 let width = 30;
1133 let out = parse_markdown(
1134 "| Item | Detail |\n|------|--------|\n| one | a very long cell that cannot fit on a single line at this width |",
1135 &Theme::dark(),
1136 width,
1137 );
1138 let mut saw_table = false;
1139 for ml in &out {
1140 let rendered: String = ml.line.spans.iter().map(|s| s.content.as_ref()).collect();
1141 if rendered.starts_with('|') {
1142 saw_table = true;
1143 assert!(
1144 rendered.width() <= width,
1145 "table line must fit width {width}, got {} for {rendered:?}",
1146 rendered.width()
1147 );
1148 }
1149 }
1150 assert!(saw_table, "table should have rendered");
1151 let all: String = out
1153 .iter()
1154 .map(|ml| {
1155 ml.line
1156 .spans
1157 .iter()
1158 .map(|s| s.content.as_ref())
1159 .collect::<String>()
1160 })
1161 .collect::<Vec<_>>()
1162 .join(" ");
1163 for word in ["very", "long", "cell", "cannot", "single", "width"] {
1164 assert!(all.contains(word), "wrapped table lost the word {word:?}");
1165 }
1166 }
1167}