1use std::sync::OnceLock;
18
19use pulldown_cmark::{CodeBlockKind, Event, Options, Parser, Tag, TagEnd};
20use ratatui::style::{Color, Modifier, Style};
21use ratatui::text::{Line, Span};
22use syntect::easy::HighlightLines;
23use syntect::highlighting::{Style as SyntectStyle, ThemeSet};
24use syntect::parsing::SyntaxSet;
25use syntect::util::LinesWithEndings;
26
27fn syntax_set() -> &'static SyntaxSet {
32 static SYNTAX_SET: OnceLock<SyntaxSet> = OnceLock::new();
33 SYNTAX_SET.get_or_init(SyntaxSet::load_defaults_newlines)
34}
35
36fn theme_set() -> &'static ThemeSet {
37 static THEME_SET: OnceLock<ThemeSet> = OnceLock::new();
38 THEME_SET.get_or_init(ThemeSet::load_defaults)
39}
40
41#[derive(Default, Clone)]
46pub struct MdState {
47 pub in_code_block: bool,
49 pub code_lang: String,
52}
53
54pub struct RenderedLine {
56 pub spans: Vec<Span<'static>>,
57 pub state: MdState,
59}
60
61pub fn render_inline(line: &str, default_fg: Color, state: MdState) -> RenderedLine {
68 let trimmed = line.trim_start();
69
70 if trimmed.starts_with("```") {
72 if state.in_code_block {
73 return RenderedLine {
75 spans: vec![Span::styled(
76 line.to_string(),
77 Style::default().fg(Color::Gray),
78 )],
79 state: MdState::default(),
80 };
81 } else {
82 let lang = trimmed.trim_start_matches('`').trim().to_lowercase();
84 return RenderedLine {
85 spans: vec![Span::styled(
86 line.to_string(),
87 Style::default().fg(Color::Gray),
88 )],
89 state: MdState {
90 in_code_block: true,
91 code_lang: lang,
92 },
93 };
94 }
95 }
96
97 if state.in_code_block {
99 let spans = highlight_code_line(line, &state.code_lang);
100 return RenderedLine { spans, state };
101 }
102
103 if let Some(rest) = strip_heading(line) {
105 return RenderedLine {
106 spans: vec![Span::styled(
107 rest.to_string(),
108 Style::default()
109 .fg(Color::LightCyan)
110 .add_modifier(Modifier::BOLD),
111 )],
112 state,
113 };
114 }
115
116 if let Some((indent, rest)) = strip_bullet(line) {
118 let mut out: Vec<Span<'static>> = Vec::with_capacity(3);
119 out.push(Span::raw(indent.to_string()));
120 out.push(Span::styled(
121 "• ".to_string(),
122 Style::default().fg(Color::LightYellow),
123 ));
124 out.extend(parse_inline(rest, default_fg));
125 return RenderedLine { spans: out, state };
126 }
127
128 RenderedLine {
130 spans: parse_inline(line, default_fg),
131 state,
132 }
133}
134
135pub fn render_table(rows: &[&str], gutter_prefix: &str, gutter_style: Style) -> Vec<Line<'static>> {
140 let parsed = parse_table_rows(rows);
141 if parsed.is_empty() {
142 return Vec::new();
143 }
144
145 let ncols = parsed
147 .iter()
148 .map(|(_, cells)| cells.len())
149 .max()
150 .unwrap_or(0);
151 if ncols == 0 {
152 return Vec::new();
153 }
154
155 let separator_idx = parsed.iter().position(|(is_sep, _)| *is_sep);
157
158 let mut col_widths: Vec<usize> = vec![0; ncols];
160 for (is_sep, cells) in &parsed {
161 if *is_sep {
162 continue;
163 }
164 for (ci, cell) in cells.iter().enumerate().take(ncols) {
165 col_widths[ci] = col_widths[ci].max(cell.len());
166 }
167 }
168 for w in &mut col_widths {
170 if *w == 0 {
171 *w = 1;
172 }
173 }
174
175 let mut out: Vec<Line<'static>> = Vec::new();
176 let gutter_owned = gutter_prefix.to_string();
177
178 out.push(make_border_line(
180 &col_widths,
181 '┌',
182 '─',
183 '┬',
184 '┐',
185 &gutter_owned,
186 gutter_style,
187 ));
188
189 let data_rows: Vec<(bool, Vec<String>, bool)> = parsed
190 .iter()
191 .enumerate()
192 .filter_map(|(row_idx, (is_sep, cells))| {
193 if *is_sep {
194 None
195 } else {
196 let is_header = separator_idx.is_some_and(|si| row_idx < si);
197 Some((*is_sep, cells.clone(), is_header))
198 }
199 })
200 .collect();
201
202 for (data_idx, (_is_sep, cells, is_header)) in data_rows.iter().enumerate() {
203 let mut spans: Vec<Span<'static>> = Vec::new();
205 spans.push(Span::styled(gutter_owned.clone(), gutter_style));
206 spans.push(Span::raw("│"));
207 for (ci, w) in col_widths.iter().enumerate() {
208 let cell_text = cells.get(ci).map(String::as_str).unwrap_or("");
209 let padded = format!(" {:<width$} ", cell_text, width = w);
210 let style = if *is_header {
211 Style::default()
212 .fg(Color::LightCyan)
213 .add_modifier(Modifier::BOLD)
214 } else {
215 Style::default().fg(Color::White)
216 };
217 spans.push(Span::styled(padded, style));
218 spans.push(Span::raw("│"));
219 }
220 out.push(Line::from(spans));
221
222 let is_last_data = data_idx == data_rows.len() - 1;
224 if *is_header && !is_last_data {
225 out.push(make_border_line(
226 &col_widths,
227 '├',
228 '─',
229 '┼',
230 '┤',
231 &gutter_owned,
232 gutter_style,
233 ));
234 }
235 }
236
237 out.push(make_border_line(
239 &col_widths,
240 '└',
241 '─',
242 '┴',
243 '┘',
244 &gutter_owned,
245 gutter_style,
246 ));
247
248 out
249}
250
251pub fn is_table_line(line: &str) -> bool {
256 let t = line.trim();
257 t.starts_with('|')
260}
261
262pub fn render_markdown(text: &str, wrap_width: u16) -> Vec<Line<'static>> {
279 let width = if wrap_width == 0 { 80 } else { wrap_width } as usize;
280
281 if text.trim().is_empty() {
283 return text.lines().map(|l| Line::from(l.to_string())).collect();
284 }
285
286 let parser = Parser::new_ext(text, Options::ENABLE_TABLES);
287 let mut out: Vec<Line<'static>> = Vec::new();
288 let mut current: Vec<Span<'static>> = Vec::new();
289 let mut pending_text: String = String::new();
290 let mut style_stack: Vec<Style> = vec![Style::default().fg(Color::White)];
291 let mut list_stack: Vec<ListState> = Vec::new();
292 let mut in_code_block = false;
293 let mut code_block_lang: String = String::new();
294 let mut code_block_buffer: Vec<String> = Vec::new();
295 let mut in_table = false;
297 let mut table_rows: Vec<Vec<String>> = Vec::new();
298 let mut current_table_row: Vec<String> = Vec::new();
299 let mut current_table_cell = String::new();
300
301 let flush_pending =
303 |current: &mut Vec<Span<'static>>, pending_text: &mut String, style_stack: &[Style]| {
304 if !pending_text.is_empty() {
305 let style = style_stack.last().copied().unwrap_or_default();
306 current.push(Span::styled(std::mem::take(pending_text), style));
307 }
308 };
309
310 let flush_line = |out: &mut Vec<Line<'static>>,
312 current: &mut Vec<Span<'static>>,
313 pending_text: &mut String,
314 style_stack: &[Style]| {
315 flush_pending(current, pending_text, style_stack);
316 if !current.is_empty() {
317 out.push(Line::from(std::mem::take(current)));
318 }
319 };
320
321 for event in parser {
322 match event {
323 Event::Start(tag) => match tag {
324 Tag::Paragraph => {
325 }
327 Tag::Strong => {
328 flush_pending(&mut current, &mut pending_text, &style_stack);
329 let base = style_stack.last().copied().unwrap_or_default();
330 style_stack.push(base.add_modifier(Modifier::BOLD));
331 }
332 Tag::Emphasis => {
333 flush_pending(&mut current, &mut pending_text, &style_stack);
334 let base = style_stack.last().copied().unwrap_or_default();
335 style_stack.push(base.add_modifier(Modifier::ITALIC));
336 }
337 Tag::CodeBlock(kind) => {
338 flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
340 in_code_block = true;
341 code_block_lang = match kind {
342 CodeBlockKind::Fenced(s) => s.into_string(),
343 CodeBlockKind::Indented => String::new(),
344 };
345 code_block_buffer.clear();
346 }
347 Tag::List(start) => {
348 flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
350 list_stack.push(ListState {
351 ordered: start.is_some(),
352 next_num: start.unwrap_or(1),
353 });
354 }
355 Tag::Item => {
356 flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
358 let prefix = if let Some(list) = list_stack.last_mut() {
359 if list.ordered {
360 let n = list.next_num;
361 list.next_num += 1;
362 format!("{}. ", n)
363 } else {
364 "\u{2022} ".to_string()
365 }
366 } else {
367 "\u{2022} ".to_string()
368 };
369 current.push(Span::styled(
370 prefix,
371 Style::default().fg(Color::LightYellow),
372 ));
373 }
374 Tag::Heading { level, .. } => {
375 flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
376 let _ = level; }
378 Tag::Table(_) => {
379 flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
380 in_table = true;
381 table_rows.clear();
382 current_table_row.clear();
383 current_table_cell.clear();
384 }
385 Tag::TableHead => {
386 current_table_row.clear();
387 current_table_cell.clear();
388 }
389 Tag::TableRow => {
390 current_table_row.clear();
391 current_table_cell.clear();
392 }
393 Tag::TableCell => {
394 current_table_cell.clear();
395 }
396 _ => {
397 }
402 },
403 Event::End(tag_end) => match tag_end {
404 TagEnd::Paragraph => {
405 flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
406 }
407 TagEnd::Strong | TagEnd::Emphasis => {
408 flush_pending(&mut current, &mut pending_text, &style_stack);
409 if style_stack.len() > 1 {
410 style_stack.pop();
411 }
412 }
413 TagEnd::CodeBlock => {
414 for line in code_block_buffer.drain(..) {
416 out.push(Line::from(vec![
417 Span::styled("\u{2502} ".to_string(), Style::default().fg(Color::Cyan)),
418 Span::styled(line, Style::default().fg(Color::Cyan)),
419 ]));
420 }
421 in_code_block = false;
422 code_block_lang.clear();
423 }
424 TagEnd::List(_) => {
425 list_stack.pop();
426 }
427 TagEnd::Item => {
428 flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
429 }
430 TagEnd::Heading(_) => {
431 flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
432 }
433 TagEnd::TableCell => {
434 current_table_row.push(std::mem::take(&mut current_table_cell));
435 }
436 TagEnd::TableHead => {
437 table_rows.push(std::mem::take(&mut current_table_row));
441 let ncols = table_rows.last().map_or(0, |r| r.len());
443 if ncols > 0 {
444 table_rows.push((0..ncols).map(|_| "---".to_string()).collect());
445 }
446 }
447 TagEnd::TableRow => {
448 table_rows.push(std::mem::take(&mut current_table_row));
449 }
450 TagEnd::Table => {
451 let raw_rows: Vec<String> = table_rows
453 .iter()
454 .map(|row| {
455 format!(
456 "|{}|",
457 row.iter()
458 .map(|c| format!(" {c} "))
459 .collect::<Vec<_>>()
460 .join("|")
461 )
462 })
463 .collect();
464 let row_refs: Vec<&str> = raw_rows.iter().map(String::as_str).collect();
465 out.extend(render_table(
466 &row_refs,
467 " ",
468 Style::default().fg(Color::DarkGray),
469 ));
470 in_table = false;
471 table_rows.clear();
472 current_table_row.clear();
473 current_table_cell.clear();
474 }
475 _ => {}
476 },
477 Event::Text(s) => {
478 let text = s.into_string();
479 if in_table {
480 current_table_cell.push_str(&text);
482 } else if in_code_block {
483 let mut parts = text.split('\n');
488 if let Some(first) = parts.next() {
489 if !first.is_empty() {
490 code_block_buffer.push(first.to_string());
491 }
492 for part in parts {
493 code_block_buffer.push(part.to_string());
494 }
495 }
496 } else {
497 pending_text.push_str(&text);
498 }
499 }
500 Event::Code(s) => {
501 if in_table {
502 current_table_cell.push('`');
505 current_table_cell.push_str(&s);
506 current_table_cell.push('`');
507 } else if in_code_block {
508 code_block_buffer.push(s.into_string());
510 } else {
511 flush_pending(&mut current, &mut pending_text, &style_stack);
512 let base = style_stack.last().copied().unwrap_or_default();
513 let code_style = base.fg(Color::Cyan);
514 current.push(Span::styled(s.into_string(), code_style));
515 }
516 }
517 Event::SoftBreak => {
518 if in_table {
519 current_table_cell.push(' ');
520 } else if in_code_block {
521 code_block_buffer.push(String::new());
523 } else {
524 pending_text.push(' ');
525 }
526 }
527 Event::HardBreak => {
528 if in_table {
529 current_table_cell.push(' ');
531 } else if in_code_block {
532 code_block_buffer.push(String::new());
533 } else {
534 flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
535 }
536 }
537 Event::Rule => {
538 flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
539 out.push(Line::from(Span::styled(
540 "\u{2500}".repeat(width),
541 Style::default().fg(Color::Gray),
542 )));
543 }
544 _ => {
545 }
548 }
549 }
550
551 flush_line(&mut out, &mut current, &mut pending_text, &style_stack);
553
554 if out.is_empty() {
555 return text.lines().map(|l| Line::from(l.to_string())).collect();
558 }
559
560 out
561}
562
563struct ListState {
565 ordered: bool,
567 next_num: u64,
569}
570
571fn highlight_code_line(line: &str, lang: &str) -> Vec<Span<'static>> {
574 let ss = syntax_set();
575 let ts = theme_set();
576
577 let syntax = if lang.is_empty() {
579 None
580 } else {
581 ss.find_syntax_by_token(lang)
582 };
583
584 let Some(syntax) = syntax else {
585 return vec![Span::styled(
587 line.to_string(),
588 Style::default().fg(Color::LightYellow),
589 )];
590 };
591
592 let theme = ts
594 .themes
595 .get("base16-ocean.dark")
596 .or_else(|| ts.themes.values().next());
597 let Some(theme) = theme else {
598 return vec![Span::styled(
599 line.to_string(),
600 Style::default().fg(Color::LightYellow),
601 )];
602 };
603
604 let mut highlighter = HighlightLines::new(syntax, theme);
605 let mut out: Vec<Span<'static>> = Vec::new();
606
607 let text_with_newline = format!("{line}\n");
609 for piece in LinesWithEndings::from(&text_with_newline) {
610 if let Ok(highlighted) = highlighter.highlight_line(piece, ss) {
611 for (style, text) in highlighted {
612 let ratatui_color = syntect_color_to_ratatui(style);
613 let clean = text.trim_end_matches('\n').to_string();
614 if !clean.is_empty() {
615 out.push(Span::styled(clean, Style::default().fg(ratatui_color)));
616 }
617 }
618 }
619 }
620
621 if out.is_empty() {
622 out.push(Span::styled(
623 line.to_string(),
624 Style::default().fg(Color::LightYellow),
625 ));
626 }
627 out
628}
629
630fn syntect_color_to_ratatui(style: SyntectStyle) -> Color {
631 let c = style.foreground;
632 Color::Rgb(c.r, c.g, c.b)
633}
634
635fn parse_table_rows(rows: &[&str]) -> Vec<(bool, Vec<String>)> {
638 rows.iter()
639 .map(|row| {
640 let trimmed = row.trim();
641 let inner = trimmed.strip_prefix('|').unwrap_or(trimmed);
643 let inner = inner.strip_suffix('|').unwrap_or(inner);
644 let cells: Vec<String> = inner.split('|').map(|c| c.trim().to_string()).collect();
645 let is_sep = cells
646 .iter()
647 .all(|c| c.is_empty() || c.chars().all(|ch| ch == '-' || ch == ':' || ch == ' '));
648 (is_sep, cells)
649 })
650 .collect()
651}
652
653fn make_border_line(
655 col_widths: &[usize],
656 left: char,
657 fill: char,
658 sep: char,
659 right: char,
660 gutter: &str,
661 gutter_style: Style,
662) -> Line<'static> {
663 let mut spans: Vec<Span<'static>> = Vec::new();
664 spans.push(Span::styled(gutter.to_string(), gutter_style));
665 let mut s = String::new();
666 s.push(left);
667 for (i, &w) in col_widths.iter().enumerate() {
668 for _ in 0..w + 2 {
670 s.push(fill);
671 }
672 if i < col_widths.len() - 1 {
673 s.push(sep);
674 }
675 }
676 s.push(right);
677 spans.push(Span::styled(s, Style::default().fg(Color::Gray)));
678 Line::from(spans)
679}
680
681fn strip_heading(line: &str) -> Option<&str> {
686 let mut rest = line;
687 let mut hashes = 0;
688 while rest.starts_with('#') && hashes < 6 {
689 rest = &rest[1..];
690 hashes += 1;
691 }
692 if hashes == 0 {
693 return None;
694 }
695 if let Some(stripped) = rest.strip_prefix(' ') {
696 Some(stripped)
697 } else if rest.is_empty() {
698 Some("")
699 } else {
700 None
701 }
702}
703
704fn strip_bullet(line: &str) -> Option<(&str, &str)> {
706 let trimmed = line.trim_start();
707 let indent_len = line.len() - trimmed.len();
708 let body = trimmed
709 .strip_prefix("- ")
710 .or_else(|| trimmed.strip_prefix("* "))
711 .or_else(|| trimmed.strip_prefix("+ "))?;
712 Some((&line[..indent_len], body))
713}
714
715fn parse_inline(line: &str, default_fg: Color) -> Vec<Span<'static>> {
718 let mut out: Vec<Span<'static>> = Vec::new();
719 let bytes = line.as_bytes();
720 let mut i = 0;
721 let mut plain_start = 0;
722
723 let plain_style = Style::default().fg(default_fg);
724 let bold_style = Style::default()
725 .fg(Color::LightCyan)
726 .add_modifier(Modifier::BOLD);
727 let italic_style = Style::default()
728 .fg(default_fg)
729 .add_modifier(Modifier::ITALIC);
730 let code_style = Style::default().fg(Color::LightYellow);
731
732 let flush_plain =
733 |out: &mut Vec<Span<'static>>, line: &str, start: usize, end: usize, style: Style| {
734 if end > start {
735 out.push(Span::styled(line[start..end].to_string(), style));
736 }
737 };
738
739 while i < bytes.len() {
740 if i + 1 < bytes.len() && &bytes[i..i + 2] == b"**" {
742 if let Some(close) = find_close(line, i + 2, "**") {
743 flush_plain(&mut out, line, plain_start, i, plain_style);
744 out.push(Span::styled(line[i + 2..close].to_string(), bold_style));
745 i = close + 2;
746 plain_start = i;
747 continue;
748 }
749 }
750 if bytes[i] == b'`' {
752 if let Some(close) = find_close(line, i + 1, "`") {
753 flush_plain(&mut out, line, plain_start, i, plain_style);
754 out.push(Span::styled(line[i + 1..close].to_string(), code_style));
755 i = close + 1;
756 plain_start = i;
757 continue;
758 }
759 }
760 if (bytes[i] == b'*' || bytes[i] == b'_') && !is_double(bytes, i) {
762 let marker = bytes[i] as char;
763 let pat: String = marker.to_string();
764 if let Some(close) = find_close(line, i + 1, &pat) {
765 if close > i + 1 {
766 flush_plain(&mut out, line, plain_start, i, plain_style);
767 out.push(Span::styled(line[i + 1..close].to_string(), italic_style));
768 i = close + 1;
769 plain_start = i;
770 continue;
771 }
772 }
773 }
774 i += 1;
775 }
776 flush_plain(&mut out, line, plain_start, bytes.len(), plain_style);
777 if out.is_empty() {
778 out.push(Span::raw(String::new()));
779 }
780 out
781}
782
783fn is_double(bytes: &[u8], i: usize) -> bool {
784 if bytes[i] != b'*' {
785 return false;
786 }
787 let prev = i > 0 && bytes[i - 1] == b'*';
788 let next = i + 1 < bytes.len() && bytes[i + 1] == b'*';
789 prev || next
790}
791
792fn find_close(line: &str, start: usize, pat: &str) -> Option<usize> {
793 line[start..].find(pat).map(|p| p + start)
794}
795
796#[cfg(test)]
801mod tests {
802 use super::*;
803
804 fn collect_text(spans: &[Span<'_>]) -> String {
805 spans.iter().map(|s| s.content.as_ref()).collect()
806 }
807
808 #[test]
811 fn plain_text_renders_with_default_fg() {
812 let r = render_inline("hello world", Color::White, MdState::default());
813 assert_eq!(collect_text(&r.spans), "hello world");
814 assert!(!r.state.in_code_block);
815 }
816
817 #[test]
818 fn bold_double_star_styles_inner() {
819 let r = render_inline("hi **there** friend", Color::White, MdState::default());
820 let text = collect_text(&r.spans);
821 assert_eq!(text, "hi there friend");
822 let has_bold = r
823 .spans
824 .iter()
825 .any(|s| s.style.add_modifier.contains(Modifier::BOLD));
826 assert!(has_bold);
827 }
828
829 #[test]
830 fn inline_code_rendered_with_code_colour() {
831 let r = render_inline("call `foo()` now", Color::White, MdState::default());
832 let has_yellow = r
833 .spans
834 .iter()
835 .any(|s| s.style.fg == Some(Color::LightYellow));
836 assert!(has_yellow);
837 assert_eq!(collect_text(&r.spans), "call foo() now");
838 }
839
840 #[test]
841 fn italic_with_underscore() {
842 let r = render_inline("an _emphasised_ word", Color::White, MdState::default());
843 assert_eq!(collect_text(&r.spans), "an emphasised word");
844 let has_italic = r
845 .spans
846 .iter()
847 .any(|s| s.style.add_modifier.contains(Modifier::ITALIC));
848 assert!(has_italic);
849 }
850
851 #[test]
852 fn heading_strips_hashes_and_bolds() {
853 let r = render_inline("## Hello", Color::White, MdState::default());
854 assert_eq!(collect_text(&r.spans), "Hello");
855 let h = &r.spans[0];
856 assert!(h.style.add_modifier.contains(Modifier::BOLD));
857 assert_eq!(h.style.fg, Some(Color::LightCyan));
858 }
859
860 #[test]
861 fn bullet_replaces_dash_with_dot() {
862 let r = render_inline("- first item", Color::White, MdState::default());
863 let text = collect_text(&r.spans);
864 assert!(text.contains("• "));
865 assert!(text.contains("first item"));
866 }
867
868 #[test]
869 fn fence_toggles_code_block_state() {
870 let s0 = MdState::default();
871 let r1 = render_inline("```rust", Color::White, s0);
872 assert!(r1.state.in_code_block);
873 let r2 = render_inline("let x = 1;", Color::White, r1.state);
874 assert!(r2.state.in_code_block);
875 let r3 = render_inline("```", Color::White, r2.state);
876 assert!(!r3.state.in_code_block);
877 }
878
879 #[test]
880 fn unmatched_marker_treated_as_plain() {
881 let r = render_inline("a*b c", Color::White, MdState::default());
882 assert_eq!(collect_text(&r.spans), "a*b c");
883 }
884
885 #[test]
886 fn empty_line_yields_an_empty_span() {
887 let r = render_inline("", Color::White, MdState::default());
888 assert_eq!(r.spans.len(), 1);
889 assert_eq!(r.spans[0].content.as_ref(), "");
890 }
891
892 #[test]
895 fn fenced_block_multiline_threading_unchanged() {
896 let s0 = MdState::default();
897 let r1 = render_inline("```", Color::White, s0);
898 assert!(r1.state.in_code_block);
899 let r2 = render_inline("some code", Color::White, r1.state);
900 assert!(r2.state.in_code_block);
901 assert_eq!(r2.spans.len(), 1);
902 let r3 = render_inline("```", Color::White, r2.state);
903 assert!(!r3.state.in_code_block);
904 }
905
906 #[test]
909 fn table_three_columns_renders_cells() {
910 let rows = ["| A | B | C |", "|---|---|---|", "| 1 | 2 | 3 |"];
911 let rows_ref: Vec<&str> = rows.iter().map(|s| s.as_ref()).collect();
912 let lines = render_table(&rows_ref, "│ ", Style::default().fg(Color::Gray));
913 assert_eq!(lines.len(), 5);
915 let header_text: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
917 assert!(header_text.contains('A'));
918 assert!(header_text.contains('B'));
919 assert!(header_text.contains('C'));
920 let data_text: String = lines[3].spans.iter().map(|s| s.content.as_ref()).collect();
922 assert!(data_text.contains('1'));
923 assert!(data_text.contains('2'));
924 assert!(data_text.contains('3'));
925 }
926
927 #[test]
928 fn table_header_separator_data_parses_correctly() {
929 let rows = [
930 "| Name | Value |",
931 "|----------|-------|",
932 "| foo | 42 |",
933 ];
934 let rows_ref: Vec<&str> = rows.iter().map(|s| s.as_ref()).collect();
935 let lines = render_table(&rows_ref, "", Style::default());
936 assert_eq!(lines.len(), 5);
938 let hdr: String = lines[1].spans.iter().map(|s| s.content.as_ref()).collect();
939 assert!(hdr.contains("Name"));
940 assert!(hdr.contains("Value"));
941 }
942
943 #[test]
944 fn table_without_separator_falls_back_to_plain() {
945 let rows = ["| A | B |", "| 1 | 2 |"];
946 let rows_ref: Vec<&str> = rows.iter().map(|s| s.as_ref()).collect();
947 let lines = render_table(&rows_ref, "", Style::default());
948 assert_eq!(lines.len(), 4);
951 }
952
953 #[test]
956 fn syntax_rust_keywords_get_color_spans() {
957 let state = MdState {
958 in_code_block: true,
959 code_lang: "rust".to_string(),
960 };
961 let r = render_inline("fn main() {}", Color::White, state);
962 assert!(!r.spans.is_empty());
964 let text = collect_text(&r.spans);
965 assert!(text.contains("fn"));
966 assert!(text.contains("main"));
967 }
968
969 #[test]
970 fn syntax_unknown_language_uses_fallback_color() {
971 let state = MdState {
972 in_code_block: true,
973 code_lang: "notalang_xyz".to_string(),
974 };
975 let r = render_inline("some code here", Color::White, state);
976 assert_eq!(r.spans.len(), 1);
977 assert_eq!(r.spans[0].style.fg, Some(Color::LightYellow));
978 }
979
980 #[test]
981 fn syntax_empty_code_block_no_panic() {
982 let state = MdState {
983 in_code_block: true,
984 code_lang: "rust".to_string(),
985 };
986 let r = render_inline("", Color::White, state);
987 let _ = r.spans;
989 }
990
991 fn lines_text(lines: &[Line<'_>]) -> String {
994 lines
995 .iter()
996 .map(|l| {
997 l.spans
998 .iter()
999 .map(|s| s.content.as_ref())
1000 .collect::<String>()
1001 })
1002 .collect::<Vec<_>>()
1003 .join("\n")
1004 }
1005
1006 #[test]
1007 fn bold_renders_as_bold_span() {
1008 let lines = render_markdown("hello **world**!", 80);
1009 let all_spans: Vec<&Span<'static>> = lines.iter().flat_map(|l| l.spans.iter()).collect();
1010 let has_bold = all_spans
1011 .iter()
1012 .any(|s| s.style.add_modifier.contains(Modifier::BOLD));
1013 assert!(
1014 has_bold,
1015 "expected at least one bold span; got spans={all_spans:?}"
1016 );
1017 }
1018
1019 #[test]
1020 fn inline_code_renders_cyan() {
1021 let lines = render_markdown("call `foo()` now", 80);
1022 let all_spans: Vec<&Span<'static>> = lines.iter().flat_map(|l| l.spans.iter()).collect();
1023 let has_cyan = all_spans.iter().any(|s| s.style.fg == Some(Color::Cyan));
1024 assert!(
1025 has_cyan,
1026 "expected at least one cyan span for inline code; got spans={all_spans:?}"
1027 );
1028 }
1029
1030 #[test]
1031 fn fenced_code_block_prefixed() {
1032 let src = "```\nsome code\n```";
1033 let lines = render_markdown(src, 80);
1034 assert!(!lines.is_empty(), "fenced code block produced no lines");
1035 for line in &lines {
1037 let first = line.spans.first().map(|s| s.content.as_ref()).unwrap_or("");
1038 assert!(
1039 first.starts_with('\u{2502}'),
1040 "expected fenced code line to start with `\u{2502} `, got {first:?}"
1041 );
1042 }
1043 let all_text = lines_text(&lines);
1045 assert!(
1046 all_text.contains("some code"),
1047 "expected code text in output: {all_text:?}"
1048 );
1049 }
1050
1051 #[test]
1052 fn bullet_list_prefixed() {
1053 let lines = render_markdown("- first item\n- second", 80);
1054 let all_text = lines_text(&lines);
1055 assert!(
1056 all_text.contains('\u{2022}'),
1057 "expected bullet `\u{2022}` prefix in list output: {all_text:?}"
1058 );
1059 for line in &lines {
1061 let first = line.spans.first().map(|s| s.content.as_ref()).unwrap_or("");
1062 if first.is_empty() {
1063 continue;
1064 }
1065 assert!(
1066 first.starts_with('\u{2022}'),
1067 "expected list line to start with `\u{2022} `, got {first:?}"
1068 );
1069 }
1070 }
1071
1072 #[test]
1073 fn plain_text_passthrough() {
1074 let lines = render_markdown("hello world", 80);
1075 assert!(!lines.is_empty(), "expected at least one line");
1076 let all_text = lines_text(&lines);
1077 assert!(
1078 all_text.contains("hello world"),
1079 "expected plain text in output: {all_text:?}"
1080 );
1081 }
1082
1083 #[test]
1084 fn empty_string_returns_empty() {
1085 let lines = render_markdown("", 80);
1086 assert!(
1089 lines.is_empty(),
1090 "expected empty Vec for empty input, got {lines:?}"
1091 );
1092 }
1093
1094 #[test]
1095 fn zero_wrap_width_falls_back_to_80() {
1096 let lines = render_markdown("hello", 0);
1098 assert!(!lines.is_empty());
1099 }
1100
1101 #[test]
1102 fn horizontal_rule_fills_wrap_width() {
1103 let lines = render_markdown("---", 40);
1104 assert_eq!(lines.len(), 1);
1105 let text: String = lines[0].spans.iter().map(|s| s.content.as_ref()).collect();
1106 let rule_count = text.chars().filter(|c| *c == '\u{2500}').count();
1107 assert_eq!(
1108 rule_count, 40,
1109 "expected 40 box-drawing chars, got {text:?}"
1110 );
1111 }
1112}
1113
1114#[test]
1117fn render_markdown_table_with_inline_code_in_cell() {
1118 let text = "| File | Desc |\n|------|------|\n| `foo.rs` | new file |\n| `bar.rs` | updated |";
1119 let lines = render_markdown(text, 80);
1120 let all_text: String = lines
1121 .iter()
1122 .flat_map(|l| l.spans.iter())
1123 .map(|s| s.content.as_ref())
1124 .collect();
1125 assert!(all_text.contains("foo.rs"), "inline code 'foo.rs' missing");
1127 assert!(all_text.contains("bar.rs"), "inline code 'bar.rs' missing");
1128}
1129
1130#[test]
1133fn render_markdown_table_end_to_end() {
1134 let text = "| Name | Value |\n|------|-------|\n| foo | 42 |\n| bar | 99 |";
1135 let lines = render_markdown(text, 80);
1136 let all_text: String = lines
1137 .iter()
1138 .flat_map(|l| l.spans.iter())
1139 .map(|s| s.content.as_ref())
1140 .collect();
1141 assert!(all_text.contains("Name"), "header 'Name' missing");
1143 assert!(all_text.contains("Value"), "header 'Value' missing");
1144 assert!(all_text.contains("foo"), "data 'foo' missing");
1145 assert!(all_text.contains("42"), "data '42' missing");
1146 assert!(all_text.contains("bar"), "data 'bar' missing");
1147 assert!(all_text.contains("99"), "data '99' missing");
1148 assert!(
1150 all_text.contains('┌') || all_text.contains('─'),
1151 "no box-drawing chars found"
1152 );
1153 assert!(lines.len() >= 6, "expected ≥6 lines, got {}", lines.len());
1155}