1use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
2use ratatui::style::{Modifier, Style};
3use ratatui::text::{Line, Span};
4use unicode_width::UnicodeWidthStr;
5
6use crate::render::theme::Theme;
7
8#[derive(Debug, Clone)]
9struct ListState {
10 next_number: Option<u64>,
11 cont_indent: String,
15}
16
17fn list_marker_style(theme: &Theme) -> Style {
21 Style::new().fg(theme.colors.text_secondary.to_color())
22}
23
24pub fn line_hanging_indent(line: &Line, theme: &Theme) -> usize {
30 let marker = list_marker_style(theme);
31 let mut indent = 0usize;
32 for span in &line.spans {
33 let text = span.content.as_ref();
34 let trimmed = text.trim_start_matches(' ');
35 if trimmed.is_empty() {
36 indent += text.width(); continue;
38 }
39 indent += text.width() - trimmed.width();
42 if span.style == marker {
43 indent += trimmed.width();
44 }
45 break;
46 }
47 indent
48}
49
50pub fn parse_markdown(input: &str, theme: &Theme) -> Vec<Line<'static>> {
58 let mut options = Options::empty();
59 options.insert(Options::ENABLE_STRIKETHROUGH);
60 options.insert(Options::ENABLE_TABLES);
61
62 let c = &theme.colors;
64 let code_bg = c.code_background.to_color();
65 let code_fg = c.code_foreground.to_color();
66 let heading1 = Style::new().fg(c.header.to_color()).bold();
67 let heading2 = Style::new().fg(c.info.to_color()).bold();
68 let heading3 = Style::new().fg(c.success.to_color()).bold();
69 let heading_other = Style::new().fg(c.warning.to_color()).bold();
70 let link_style = Style::new()
71 .fg(c.info.to_color())
72 .add_modifier(Modifier::UNDERLINED);
73 let marker_style = list_marker_style(theme);
74 let rule_style = Style::new().fg(c.text_disabled.to_color());
75 let quote_bar_style = Style::new().fg(c.text_disabled.to_color());
76 let quote_text_style = Style::new()
77 .fg(c.text_secondary.to_color())
78 .add_modifier(Modifier::ITALIC);
79
80 let parser = Parser::new_ext(input, options);
81 let mut lines: Vec<Line<'static>> = Vec::new();
82 let mut current_line_spans: Vec<Span<'static>> = Vec::new();
83 let mut style_stack = vec![Style::default()];
84 let mut in_code_block = false;
85 let mut code_block_content = String::new();
86 let mut code_block_lang = String::new();
87 let mut current_link_url: Option<String> = None;
88 let mut list_stack: Vec<ListState> = Vec::new();
89
90 let mut in_table = false;
92 let mut table_rows: Vec<Vec<String>> = Vec::new();
93 let mut current_row: Vec<String> = Vec::new();
94 let mut current_cell = String::new();
95 let mut table_header_len: usize = 0;
96
97 for event in parser {
98 match event {
99 Event::Start(tag) => {
100 let new_style = match tag {
101 Tag::Heading { level, .. } => {
102 if !current_line_spans.is_empty() {
103 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
104 }
105 if !lines.is_empty() {
107 lines.push(Line::from(""));
108 }
109 match level {
110 HeadingLevel::H1 => heading1,
111 HeadingLevel::H2 => heading2,
112 HeadingLevel::H3 => heading3,
113 _ => heading_other,
114 }
115 },
116 Tag::Emphasis => style_stack.last().copied().unwrap_or_default().italic(),
117 Tag::Strong => style_stack.last().copied().unwrap_or_default().bold(),
118 Tag::Strikethrough => style_stack
119 .last()
120 .copied()
121 .unwrap_or_default()
122 .crossed_out(),
123 Tag::CodeBlock(kind) => {
124 in_code_block = true;
125 code_block_content.clear();
126 if !current_line_spans.is_empty() {
127 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
128 }
129 code_block_lang = match kind {
130 CodeBlockKind::Fenced(lang) => lang.to_string(),
131 CodeBlockKind::Indented => String::new(),
132 };
133 if !code_block_lang.is_empty() {
134 lines.push(Line::from(Span::styled(
135 code_block_lang.clone(),
136 Style::new()
137 .fg(c.text_disabled.to_color())
138 .add_modifier(Modifier::ITALIC),
139 )));
140 }
141 Style::default().fg(code_fg)
142 },
143 Tag::List(start) => {
144 list_stack.push(ListState {
145 next_number: start,
146 cont_indent: String::new(),
147 });
148 if !current_line_spans.is_empty() {
149 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
150 }
151 style_stack.last().copied().unwrap_or_default()
152 },
153 Tag::Item => {
154 let indent = " ".repeat(list_stack.len());
155 let marker = if let Some(state) = list_stack.last_mut() {
156 if let Some(current) = state.next_number {
157 state.next_number = Some(current + 1);
158 format!("{}. ", current)
159 } else {
160 "• ".to_string()
161 }
162 } else {
163 "• ".to_string()
164 };
165 let cont_indent =
168 format!("{}{}", indent, " ".repeat(marker.as_str().width()));
169 if let Some(state) = list_stack.last_mut() {
170 state.cont_indent = cont_indent;
171 }
172 current_line_spans.push(Span::raw(indent));
173 current_line_spans.push(Span::styled(marker, marker_style));
174 style_stack.last().copied().unwrap_or_default()
175 },
176 Tag::Paragraph => {
177 if current_line_spans.is_empty()
181 && let Some(state) = list_stack.last()
182 && !state.cont_indent.is_empty()
183 {
184 current_line_spans.push(Span::raw(state.cont_indent.clone()));
185 }
186 style_stack.last().copied().unwrap_or_default()
187 },
188 Tag::Table(_alignments) => {
189 in_table = true;
190 table_rows.clear();
191 table_header_len = 0;
192 if !current_line_spans.is_empty() {
193 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
194 }
195 style_stack.last().copied().unwrap_or_default()
196 },
197 Tag::TableHead | Tag::TableRow => {
198 current_row.clear();
199 style_stack.last().copied().unwrap_or_default()
200 },
201 Tag::TableCell => {
202 current_cell.clear();
203 style_stack.last().copied().unwrap_or_default()
204 },
205 Tag::Link { dest_url, .. } => {
206 current_link_url = Some(dest_url.to_string());
210 link_style
211 },
212 Tag::BlockQuote(_) => {
213 if !current_line_spans.is_empty() {
214 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
215 }
216 current_line_spans.push(Span::styled("│ ", quote_bar_style));
217 quote_text_style
218 },
219 _ => style_stack.last().copied().unwrap_or_default(),
220 };
221 style_stack.push(new_style);
222 },
223 Event::End(tag) => {
224 style_stack.pop();
225 match tag {
226 TagEnd::Heading(_) => {
227 if !current_line_spans.is_empty() {
228 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
229 }
230 },
231 TagEnd::Paragraph | TagEnd::Item => {
232 if !current_line_spans.is_empty() {
233 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
234 }
235 },
236 TagEnd::CodeBlock => {
237 in_code_block = false;
238 let prefixes = line_comment_prefixes(&code_block_lang);
239 let base = Style::default().fg(code_fg).bg(code_bg);
240 for line_text in code_block_content.lines() {
241 let spans = highlight_code_line(line_text, prefixes, theme);
242 lines.push(Line::from(spans).style(base));
245 }
246 code_block_content.clear();
247 code_block_lang.clear();
248 },
249 TagEnd::List(_) => {
250 let _ = list_stack.pop();
251 if list_stack.is_empty() {
252 lines.push(Line::from(""));
253 }
254 },
255 TagEnd::TableCell => {
256 current_row.push(std::mem::take(&mut current_cell));
257 },
258 TagEnd::TableHead => {
259 table_header_len = current_row.len();
260 table_rows.push(std::mem::take(&mut current_row));
261 },
262 TagEnd::TableRow => {
263 table_rows.push(std::mem::take(&mut current_row));
264 },
265 TagEnd::Table => {
266 in_table = false;
267 render_table(&mut lines, &table_rows, table_header_len, theme);
268 table_rows.clear();
269 },
270 TagEnd::Link => {
271 if let Some(url) = current_link_url.take() {
274 let text: String = current_line_spans
275 .iter()
276 .map(|s| s.content.as_ref())
277 .collect();
278 if !url.is_empty() && !text.ends_with(&url) {
279 current_line_spans.push(Span::styled(
280 format!(" ({})", url),
281 Style::new().fg(c.text_disabled.to_color()),
282 ));
283 }
284 }
285 },
286 TagEnd::BlockQuote(_) => {
287 if !current_line_spans.is_empty() {
288 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
289 }
290 },
291 _ => {},
292 }
293 },
294 Event::Text(text) => {
295 if in_code_block {
296 code_block_content.push_str(&text);
297 } else if in_table {
298 current_cell.push_str(&text);
299 } else {
300 let style = style_stack.last().copied().unwrap_or_default();
301 current_line_spans.push(Span::styled(text.to_string(), style));
302 }
303 },
304 Event::Code(code) => {
305 if in_table {
306 current_cell.push_str(&code);
307 } else {
308 let style = Style::default().fg(code_fg).bg(code_bg);
312 current_line_spans.push(Span::styled(code.to_string(), style));
313 }
314 },
315 Event::Rule => {
316 if !current_line_spans.is_empty() {
317 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
318 }
319 lines.push(Line::from(Span::styled("─".repeat(40), rule_style)));
320 },
321 Event::SoftBreak | Event::HardBreak => {
322 if !current_line_spans.is_empty() {
323 lines.push(Line::from(std::mem::take(&mut current_line_spans)));
324 }
325 },
326 _ => {},
327 }
328 }
329
330 if !current_line_spans.is_empty() {
331 lines.push(Line::from(current_line_spans));
332 }
333
334 lines
335}
336
337fn render_table(
339 lines: &mut Vec<Line<'static>>,
340 table_rows: &[Vec<String>],
341 table_header_len: usize,
342 theme: &Theme,
343) {
344 let c = &theme.colors;
345 let num_cols = table_rows.iter().map(|r| r.len()).max().unwrap_or(0);
347 let mut col_widths = vec![0usize; num_cols];
348 for row in table_rows {
349 for (i, cell) in row.iter().enumerate() {
350 if i < num_cols {
351 col_widths[i] = col_widths[i].max(cell.width());
352 }
353 }
354 }
355 for w in &mut col_widths {
356 *w = (*w).max(3);
357 }
358
359 let border_style = Style::default().fg(c.text_disabled.to_color());
360 let header_style = Style::default().fg(c.header.to_color()).bold();
361 let cell_style = Style::default().fg(c.text_primary.to_color());
362
363 for (row_idx, row) in table_rows.iter().enumerate() {
364 let mut spans = vec![Span::styled("| ", border_style)];
365 for (col_idx, cell) in row.iter().enumerate() {
366 let width = col_widths.get(col_idx).copied().unwrap_or(3);
367 let padding = width.saturating_sub(cell.width());
368 let padded = format!("{}{}", cell, " ".repeat(padding));
369 let style = if row_idx == 0 && table_header_len > 0 {
370 header_style
371 } else {
372 cell_style
373 };
374 spans.push(Span::styled(padded, style));
375 spans.push(Span::styled(" | ", border_style));
376 }
377 lines.push(Line::from(spans));
378
379 if row_idx == 0 && table_header_len > 0 {
380 let mut sep_spans = vec![Span::styled("|-", border_style)];
381 for (col_idx, _) in row.iter().enumerate() {
382 let width = col_widths.get(col_idx).copied().unwrap_or(3);
383 sep_spans.push(Span::styled("-".repeat(width), border_style));
384 sep_spans.push(Span::styled("-|-", border_style));
385 }
386 lines.push(Line::from(sep_spans));
387 }
388 }
389
390 lines.push(Line::from(""));
391}
392
393fn line_comment_prefixes(lang: &str) -> &'static [&'static str] {
396 match lang.trim().to_ascii_lowercase().as_str() {
397 "rust" | "rs" | "c" | "cpp" | "c++" | "h" | "hpp" | "java" | "js" | "javascript" | "ts"
398 | "typescript" | "tsx" | "jsx" | "go" | "golang" | "swift" | "kotlin" | "kt" | "scala"
399 | "cs" | "csharp" | "php" | "dart" | "zig" | "rust,no_run" => &["//"],
400 "python" | "py" | "ruby" | "rb" | "sh" | "bash" | "zsh" | "shell" | "console" | "yaml"
401 | "yml" | "toml" | "ini" | "perl" | "pl" | "r" | "elixir" | "ex" | "makefile"
402 | "dockerfile" | "nix" => &["#"],
403 "sql" | "lua" | "haskell" | "hs" | "ada" => &["--"],
404 "lisp" | "clojure" | "clj" | "scheme" | "el" => &[";"],
405 _ => &["//", "#"],
406 }
407}
408
409fn is_keyword(w: &str) -> bool {
411 matches!(
412 w,
413 "fn" | "let"
414 | "const"
415 | "mut"
416 | "pub"
417 | "struct"
418 | "enum"
419 | "impl"
420 | "trait"
421 | "use"
422 | "mod"
423 | "match"
424 | "if"
425 | "else"
426 | "for"
427 | "while"
428 | "loop"
429 | "return"
430 | "break"
431 | "continue"
432 | "async"
433 | "await"
434 | "move"
435 | "ref"
436 | "where"
437 | "type"
438 | "dyn"
439 | "as"
440 | "in"
441 | "static"
442 | "unsafe"
443 | "extern"
444 | "crate"
445 | "self"
446 | "Self"
447 | "super"
448 | "function"
449 | "var"
450 | "def"
451 | "class"
452 | "import"
453 | "from"
454 | "export"
455 | "default"
456 | "public"
457 | "private"
458 | "protected"
459 | "void"
460 | "int"
461 | "long"
462 | "float"
463 | "double"
464 | "bool"
465 | "boolean"
466 | "char"
467 | "string"
468 | "true"
469 | "false"
470 | "null"
471 | "nil"
472 | "None"
473 | "True"
474 | "False"
475 | "this"
476 | "new"
477 | "try"
478 | "catch"
479 | "finally"
480 | "throw"
481 | "throws"
482 | "package"
483 | "interface"
484 | "extends"
485 | "implements"
486 | "do"
487 | "then"
488 | "elif"
489 | "lambda"
490 | "yield"
491 | "with"
492 | "and"
493 | "or"
494 | "not"
495 | "is"
496 | "end"
497 | "begin"
498 | "val"
499 | "func"
500 | "defer"
501 | "select"
502 | "chan"
503 | "range"
504 | "switch"
505 | "case"
506 )
507}
508
509fn highlight_code_line(text: &str, comment_prefixes: &[&str], theme: &Theme) -> Vec<Span<'static>> {
513 let c = &theme.colors;
514 let bg = c.code_background.to_color();
515 let base = Style::default().fg(c.code_foreground.to_color()).bg(bg);
516 let kw_style = Style::default().fg(c.code_keyword.to_color()).bg(bg);
517 let str_style = Style::default().fg(c.code_string.to_color()).bg(bg);
518 let com_style = Style::default().fg(c.code_comment.to_color()).bg(bg);
519
520 let mut spans: Vec<Span<'static>> = Vec::new();
521 let mut pending = String::new();
522 let flush = |spans: &mut Vec<Span<'static>>, pending: &mut String| {
523 if !pending.is_empty() {
524 spans.push(Span::styled(std::mem::take(pending), base));
525 }
526 };
527
528 let mut it = text.char_indices().peekable();
529 while let Some(&(byte_idx, ch)) = it.peek() {
530 if comment_prefixes
532 .iter()
533 .any(|p| text[byte_idx..].starts_with(p))
534 {
535 flush(&mut spans, &mut pending);
536 spans.push(Span::styled(text[byte_idx..].to_string(), com_style));
537 break;
538 }
539 if ch == '"' || ch == '\'' || ch == '`' {
541 flush(&mut spans, &mut pending);
542 let quote = ch;
543 let start = byte_idx;
544 it.next(); let mut end = text.len();
546 let mut escaped = false;
547 while let Some(&(bi, ci)) = it.peek() {
548 it.next();
549 end = bi + ci.len_utf8();
550 if escaped {
551 escaped = false;
552 } else if ci == '\\' {
553 escaped = true;
554 } else if ci == quote {
555 break;
556 }
557 }
558 spans.push(Span::styled(text[start..end].to_string(), str_style));
559 continue;
560 }
561 if ch.is_alphanumeric() || ch == '_' {
563 let start = byte_idx;
564 let mut end = byte_idx + ch.len_utf8();
565 it.next();
566 while let Some(&(bi, ci)) = it.peek() {
567 if ci.is_alphanumeric() || ci == '_' {
568 end = bi + ci.len_utf8();
569 it.next();
570 } else {
571 break;
572 }
573 }
574 let word = &text[start..end];
575 if is_keyword(word) {
576 flush(&mut spans, &mut pending);
577 spans.push(Span::styled(word.to_string(), kw_style));
578 } else {
579 pending.push_str(word);
580 }
581 continue;
582 }
583 pending.push(ch);
585 it.next();
586 }
587 flush(&mut spans, &mut pending);
588 if spans.is_empty() {
589 spans.push(Span::styled(String::new(), base));
590 }
591 spans
592}
593
594#[cfg(test)]
595mod tests {
596 use super::*;
597
598 fn md(input: &str) -> Vec<Line<'static>> {
600 parse_markdown(input, &Theme::dark())
601 }
602
603 fn lines_to_text(lines: &[Line]) -> String {
605 lines
606 .iter()
607 .map(|line| {
608 line.spans
609 .iter()
610 .map(|s| s.content.as_ref())
611 .collect::<String>()
612 })
613 .collect::<Vec<_>>()
614 .join("\n")
615 }
616
617 #[test]
618 fn test_plain_text() {
619 let lines = md("Hello, world!");
620 assert!(!lines.is_empty());
621 assert!(lines_to_text(&lines).contains("Hello, world!"));
622 }
623
624 #[test]
625 fn test_heading_levels() {
626 let lines = md("# H1\n## H2\n### H3");
627 let text = lines_to_text(&lines);
628 assert!(text.contains("H1"));
629 assert!(text.contains("H2"));
630 assert!(text.contains("H3"));
631 assert!(lines.len() >= 3);
632 }
633
634 #[test]
635 fn line_hanging_indent_aligns_under_list_marker() {
636 let theme = Theme::dark();
637 fn find<'a>(lines: &'a [Line<'static>], needle: &str) -> &'a Line<'static> {
638 lines
639 .iter()
640 .find(|l| lines_to_text(std::slice::from_ref(l)).contains(needle))
641 .expect("line present")
642 }
643
644 let bullet = parse_markdown("- Alpha item", &theme);
647 assert_eq!(
648 line_hanging_indent(find(&bullet, "Alpha"), &theme),
649 4,
650 "bullet: 2 indent + 2 marker"
651 );
652
653 let numbered = parse_markdown("1. First item", &theme);
655 assert_eq!(
656 line_hanging_indent(find(&numbered, "First"), &theme),
657 5,
658 "numbered: 2 indent + 3 marker"
659 );
660
661 let para = parse_markdown("Just a sentence.", &theme);
663 assert_eq!(
664 line_hanging_indent(find(¶, "sentence"), &theme),
665 0,
666 "paragraph: flush to the gutter"
667 );
668 }
669
670 #[test]
671 fn test_code_block() {
672 let lines = md("```rust\nfn main() {}\n```");
673 let text = lines_to_text(&lines);
674 assert!(text.contains("fn main() {}"));
675 assert!(text.contains("rust"));
676 }
677
678 #[test]
679 fn code_block_lines_tagged_with_code_background() {
680 let lines = md("```rust\nfn main() {}\n```");
681 let code_bg = Theme::dark().colors.code_background.to_color();
682 assert!(
685 lines.iter().any(|l| l.style.bg == Some(code_bg)
686 && l.spans
687 .iter()
688 .map(|s| s.content.as_ref())
689 .collect::<String>()
690 .contains("fn main")),
691 "code body line must carry the code_background marker"
692 );
693 }
694
695 #[test]
696 fn code_block_highlights_keywords() {
697 let lines = md("```rust\nfn main() {}\n```");
698 let kw = Theme::dark().colors.code_keyword.to_color();
699 let fn_styled_as_keyword = lines.iter().any(|l| {
701 l.spans
702 .iter()
703 .any(|s| s.content.as_ref() == "fn" && s.style.fg == Some(kw))
704 });
705 assert!(
706 fn_styled_as_keyword,
707 "`fn` should be highlighted as a keyword"
708 );
709 }
710
711 #[test]
712 fn code_block_preserves_indentation() {
713 let lines = md("```rust\n indented();\n```");
714 assert!(
716 lines.iter().any(|l| l
717 .spans
718 .iter()
719 .map(|s| s.content.as_ref())
720 .collect::<String>()
721 .starts_with(" indented")),
722 "code indentation must be preserved verbatim"
723 );
724 }
725
726 #[test]
727 fn test_code_block_no_lang() {
728 let lines = md("```\nsome code\n```");
729 assert!(lines_to_text(&lines).contains("some code"));
730 }
731
732 #[test]
733 fn test_inline_code_has_no_padding() {
734 let lines = md("Use `cargo build` to compile");
735 let code_bg = Theme::dark().colors.code_background.to_color();
736 let tight = lines.iter().any(|l| {
739 l.spans
740 .iter()
741 .any(|s| s.style.bg == Some(code_bg) && s.content.as_ref() == "cargo build")
742 });
743 assert!(
744 tight,
745 "inline code should be tight (no surrounding padding spaces)"
746 );
747 }
748
749 #[test]
750 fn test_unordered_list() {
751 let lines = md("- Item 1\n- Item 2\n- Item 3");
752 let text = lines_to_text(&lines);
753 assert!(text.contains("Item 1"));
754 assert!(text.contains("•"));
755 }
756
757 #[test]
758 fn test_ordered_list_preserves_numbers() {
759 let lines = md("1. First\n2. Second\n3. Third");
760 let text = lines_to_text(&lines);
761 assert!(text.contains("1. First"));
762 assert!(text.contains("2. Second"));
763 assert!(!text.contains("• First"));
764 }
765
766 #[test]
767 fn loose_list_item_body_hangs_under_item_text() {
768 let lines = md("- **Finding** — verified\n\n Body paragraph explaining the finding.");
771 let rendered: Vec<String> = lines
772 .iter()
773 .map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
774 .collect();
775 assert!(
776 rendered
777 .iter()
778 .any(|l| l.starts_with(" • ") && l.contains("Finding")),
779 "marker line should carry the bullet + indent"
780 );
781 let body = rendered
782 .iter()
783 .find(|l| l.contains("Body paragraph"))
784 .expect("body line present");
785 assert_eq!(
788 body, " Body paragraph explaining the finding.",
789 "continuation paragraph must hang-indent under the item text"
790 );
791 }
792
793 #[test]
794 fn test_nested_list() {
795 let lines = md("- Outer\n - Inner");
796 let text = lines_to_text(&lines);
797 assert!(text.contains("Outer"));
798 assert!(text.contains("Inner"));
799 }
800
801 #[test]
802 fn test_bold_and_italic() {
803 let lines = md("**bold** and *italic*");
804 let text = lines_to_text(&lines);
805 assert!(text.contains("bold"));
806 assert!(text.contains("italic"));
807 }
808
809 #[test]
810 fn test_link_shows_text_and_url() {
811 let lines = md("[click here](https://example.com)");
812 let text = lines_to_text(&lines);
813 assert!(text.contains("click here"));
814 assert!(text.contains("https://example.com"));
816 }
817
818 #[test]
819 fn test_autolink_does_not_duplicate_url() {
820 let lines = md("<https://example.com>");
822 let text = lines_to_text(&lines);
823 assert_eq!(text.matches("https://example.com").count(), 1);
824 }
825
826 #[test]
827 fn test_blockquote() {
828 let lines = md("> Quoted text");
829 let text = lines_to_text(&lines);
830 assert!(text.contains("Quoted text"));
831 assert!(text.contains("│"));
832 }
833
834 #[test]
835 fn test_horizontal_rule() {
836 let lines = md("above\n\n---\n\nbelow");
837 let text = lines_to_text(&lines);
838 assert!(text.contains("above"));
839 assert!(text.contains("below"));
840 assert!(text.contains("───"), "thematic break should render a rule");
842 }
843
844 #[test]
845 fn test_table() {
846 let lines = md("| Header1 | Header2 |\n|---------|--------|\n| Cell1 | Cell2 |");
847 let text = lines_to_text(&lines);
848 assert!(text.contains("Header1"));
849 assert!(text.contains("Cell1"));
850 assert!(text.contains("|"));
851 }
852
853 #[test]
854 fn test_strikethrough() {
855 let lines = md("~~deleted~~");
856 assert!(lines_to_text(&lines).contains("deleted"));
857 }
858
859 #[test]
860 fn test_empty_input() {
861 assert!(md("").is_empty());
862 }
863
864 #[test]
865 fn test_multiple_paragraphs() {
866 let lines = md("Paragraph 1\n\nParagraph 2");
867 let text = lines_to_text(&lines);
868 assert!(text.contains("Paragraph 1"));
869 assert!(text.contains("Paragraph 2"));
870 }
871
872 #[test]
873 fn highlight_code_line_marks_strings_and_comments() {
874 let theme = Theme::dark();
875 let spans = highlight_code_line("let s = \"hi\"; // note", &["//"], &theme);
876 let str_color = theme.colors.code_string.to_color();
877 let com_color = theme.colors.code_comment.to_color();
878 assert!(
879 spans
880 .iter()
881 .any(|s| s.content.contains("\"hi\"") && s.style.fg == Some(str_color)),
882 "string literal must use the string color"
883 );
884 assert!(
885 spans
886 .iter()
887 .any(|s| s.content.contains("// note") && s.style.fg == Some(com_color)),
888 "trailing comment must use the comment color"
889 );
890 }
891
892 #[test]
895 fn table_column_widths_use_display_cells() {
896 let lines = md("| Name | Score |\n|------|-------|\n| 你好 | 100 |\n| ab | 50 |");
897 let mut cjk_row_width = 0usize;
898 let mut ascii_row_width = 0usize;
899 for line in &lines {
900 let rendered: String = line.spans.iter().map(|s| s.content.as_ref()).collect();
901 if rendered.contains("你好") {
902 cjk_row_width = rendered.width();
903 } else if rendered.contains("ab") && rendered.contains("|") {
904 ascii_row_width = rendered.width();
905 }
906 }
907 assert!(cjk_row_width > 0, "did not find the CJK body row");
908 assert!(ascii_row_width > 0, "did not find the ASCII body row");
909 assert_eq!(
910 cjk_row_width, ascii_row_width,
911 "CJK and ASCII rows must have equal display width to align"
912 );
913 }
914}