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