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