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