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