1use anstyle::{Color as AnsiColorEnum, Effects, RgbColor};
3use oxicode_vtui_compat::ui_protocol::{InlineSegment, InlineTextStyle};
4use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
5use std::sync::{Arc, LazyLock};
6use syntect::easy::HighlightLines;
7use syntect::highlighting::ThemeSet;
8use syntect::parsing::SyntaxSet;
9use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
10
11static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
13static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
14
15type SyntectMemoMap =
22 std::collections::HashMap<(String, String, usize, String), Vec<InlineSegment>>;
23thread_local! {
24 static SYNTECT_LINE_MEMO: std::cell::RefCell<SyntectMemoMap> =
25 std::cell::RefCell::new(std::collections::HashMap::new());
26}
27const SYNTECT_MEMO_CAP: usize = 4096;
28fn memo_get(lang: &str, line: &str, width: usize, theme: &str) -> Option<Vec<InlineSegment>> {
29 SYNTECT_LINE_MEMO.with(|m| {
30 m.borrow()
31 .get(&(lang.to_string(), line.to_string(), width, theme.to_string()))
32 .cloned()
33 })
34}
35
36fn memo_put(lang: &str, line: &str, width: usize, theme: &str, segs: Vec<InlineSegment>) {
37 SYNTECT_LINE_MEMO.with(|m| {
38 let mut map = m.borrow_mut();
39 if map.len() >= SYNTECT_MEMO_CAP {
40 map.clear();
44 }
45 map.insert(
46 (lang.to_string(), line.to_string(), width, theme.to_string()),
47 segs,
48 );
49 });
50}
51
52#[derive(Default, Debug)]
63pub struct MdRenderCache {
64 prev_text: String,
65 prev_width: usize,
66 lines: Vec<Vec<InlineSegment>>,
67 hits: usize,
70}
71
72impl MdRenderCache {
73 pub fn debug_hits(&self) -> usize {
77 self.hits
78 }
79}
80
81pub fn render_markdown_cached(
87 text: &str,
88 width: usize,
89 cache: &mut MdRenderCache,
90) -> Vec<Vec<InlineSegment>> {
91 if width == cache.prev_width && text == cache.prev_text && !cache.lines.is_empty() {
92 cache.hits += 1;
93 return cache.lines.clone();
94 }
95 let lines = render_markdown(text, width);
96 cache.prev_text = text.to_string();
97 cache.prev_width = width;
98 cache.lines = lines.clone();
99 lines
100}
101
102pub fn render_markdown(text: &str, width: usize) -> Vec<Vec<InlineSegment>> {
110 let mut opts = Options::empty();
111 opts.insert(Options::ENABLE_TABLES);
112 opts.insert(Options::ENABLE_STRIKETHROUGH);
113 opts.insert(Options::ENABLE_HEADING_ATTRIBUTES);
114
115 let mut lines: Vec<Vec<InlineSegment>> = Vec::new();
116 let mut cur: Vec<InlineSegment> = Vec::new();
117 let mut effects: Effects = Effects::default();
118 let mut code_buf: Option<CodeBlockState> = None;
119 let mut table_buf: Option<TableState> = None;
120 let mut list_stack: Vec<ListLevel> = Vec::new();
121 let ss = &*SYNTAX_SET;
122
123 for event in Parser::new_ext(text, opts) {
124 if let Some(tb) = &mut table_buf {
125 match event {
126 Event::Text(t) | Event::Html(t) | Event::Code(t) => tb.current_cell.push_str(&t),
127 Event::End(TagEnd::TableCell) => {
128 tb.current_row.push(std::mem::take(&mut tb.current_cell));
129 }
130 Event::End(TagEnd::TableRow) => {
131 tb.rows.push(std::mem::take(&mut tb.current_row));
132 }
133 Event::End(TagEnd::TableHead) => {
134 if tb.header.is_empty() {
139 tb.header = std::mem::take(&mut tb.current_row);
140 }
141 }
142 Event::End(TagEnd::Table) => {
143 let tb = table_buf.take().unwrap();
144 let table_lines = render_table(&tb.header, &tb.rows, width);
145 lines.extend(table_lines);
146 lines.push(Vec::new());
147 }
148 _ => {}
149 }
150 continue;
151 }
152
153 if let Some(cb) = &mut code_buf {
155 match event {
156 Event::Text(t) => cb.code.push_str(&t),
157 Event::End(TagEnd::CodeBlock) => {
158 let cb = code_buf.take().unwrap();
159 flush_line(&mut cur, &mut lines);
160 let block_lines = render_code_block(&cb.code, cb.lang.as_deref(), ss, width);
161 lines.extend(block_lines);
162 lines.push(Vec::new());
163 }
164 _ => {}
165 }
166 continue;
167 }
168
169 match event {
171 Event::Start(Tag::CodeBlock(kind)) => {
173 flush_line(&mut cur, &mut lines);
174 let lang = match kind {
175 CodeBlockKind::Fenced(l) => Some(l.to_string()),
176 CodeBlockKind::Indented => None,
177 };
178 code_buf = Some(CodeBlockState {
179 code: String::new(),
180 lang,
181 });
182 }
183
184 Event::Start(Tag::Table(_)) => {
186 flush_line(&mut cur, &mut lines);
187 table_buf = Some(TableState::default());
188 }
189
190 Event::Start(Tag::List(start)) => {
192 list_stack.push(ListLevel {
193 is_ordered: start.is_some(),
194 index: start.map(|n| n.saturating_sub(1)).unwrap_or(0),
195 });
196 }
197 Event::End(TagEnd::List(_)) => {
198 list_stack.pop();
199 }
200 Event::Start(Tag::Item) => {
201 flush_line(&mut cur, &mut lines);
202 let depth = list_stack.len();
203 if let Some(top) = list_stack.last_mut() {
204 let indent = " ".repeat((depth.saturating_sub(1)) * 2);
205 let marker = if top.is_ordered {
206 let n = top.index + 1;
207 top.index += 1;
208 format!("{}{}. ", indent, n)
209 } else {
210 format!("{}• ", indent)
211 };
212 let seg = InlineSegment {
213 text: marker,
214 style: Arc::new(InlineTextStyle::default()),
215 };
216 merge_or_push(&mut cur, seg);
217 }
218 }
219
220 Event::Start(Tag::Paragraph) => {}
222
223 Event::Start(Tag::Heading { level, .. }) => {
224 flush_line(&mut cur, &mut lines);
225 {
226 effects = effects.insert(Effects::BOLD);
227 };
228 {
229 effects = effects.insert(if level == HeadingLevel::H1 {
230 Effects::UNDERLINE
231 } else {
232 Effects::default()
233 });
234 }
235 }
236 Event::End(TagEnd::Heading(_)) => {
237 {
238 effects = effects.remove(Effects::BOLD | Effects::UNDERLINE);
239 };
240 flush_line(&mut cur, &mut lines);
241 }
242
243 Event::Start(Tag::BlockQuote(_)) => {
244 {
245 effects = effects.insert(Effects::DIMMED);
246 };
247 }
248 Event::End(TagEnd::BlockQuote(_)) => {
249 {
250 effects = effects.remove(Effects::DIMMED);
251 };
252 }
253
254 Event::End(TagEnd::Paragraph) | Event::End(TagEnd::Item) => {
255 flush_line(&mut cur, &mut lines);
256 }
257
258 Event::Rule => {
259 flush_line(&mut cur, &mut lines);
260 lines.push(vec![InlineSegment {
261 text: "\u{2500}".repeat(40),
262 style: Arc::new(InlineTextStyle::default().dim()),
263 }]);
264 }
265
266 Event::Start(Tag::Emphasis) => {
268 {
269 effects = effects.insert(Effects::ITALIC);
270 };
271 }
272 Event::End(TagEnd::Emphasis) => {
273 {
274 effects = effects.remove(Effects::ITALIC);
275 };
276 }
277
278 Event::Start(Tag::Strong) => {
279 {
280 effects = effects.insert(Effects::BOLD);
281 };
282 }
283 Event::End(TagEnd::Strong) => {
284 {
285 effects = effects.remove(Effects::BOLD);
286 };
287 }
288
289 Event::Start(Tag::Strikethrough) => {
290 {
291 effects = effects.insert(Effects::STRIKETHROUGH);
292 };
293 }
294 Event::End(TagEnd::Strikethrough) => {
295 {
296 effects = effects.remove(Effects::STRIKETHROUGH);
297 };
298 }
299
300 Event::Start(Tag::Link { .. }) => {
301 {
302 effects = effects.insert(Effects::UNDERLINE);
303 };
304 }
305 Event::End(TagEnd::Link) => {
306 {
307 effects = effects.remove(Effects::UNDERLINE);
308 };
309 }
310
311 Event::Text(t) | Event::Html(t) => {
313 let style = apply_effects(InlineTextStyle::default(), effects);
314 let seg = InlineSegment {
315 text: t.to_string(),
316 style: Arc::new(style),
317 };
318 merge_or_push(&mut cur, seg);
319 }
320
321 Event::Code(t) => {
322 let seg = InlineSegment {
323 text: t.to_string(),
324 style: Arc::new(InlineTextStyle::default().bold()),
325 };
326 merge_or_push(&mut cur, seg);
327 }
328
329 Event::SoftBreak | Event::HardBreak => {
330 flush_line(&mut cur, &mut lines);
331 }
332
333 Event::FootnoteReference(t) => {
334 let seg = InlineSegment {
335 text: format!("[^{}]", t),
336 style: Arc::new(InlineTextStyle::default().dim()),
337 };
338 merge_or_push(&mut cur, seg);
339 }
340
341 _ => {}
342 }
343 }
344
345 flush_line(&mut cur, &mut lines);
346 lines
347}
348
349pub fn render_code_block(
360 code: &str,
361 lang: Option<&str>,
362 ss: &SyntaxSet,
363 width: usize,
364) -> Vec<Vec<InlineSegment>> {
365 let syntax = lang
366 .and_then(|l| ss.find_syntax_by_token(l))
367 .unwrap_or_else(|| ss.find_syntax_plain_text());
368 let theme_name: &'static str = crate::get_active_syntax_theme();
373 let theme = THEME_SET
374 .themes
375 .get(theme_name)
376 .or_else(|| THEME_SET.themes.get("base16-ocean.dark"))
377 .cloned()
378 .unwrap_or_default();
379 #[allow(unused_mut)]
380 let mut h = HighlightLines::new(syntax, &theme);
381 let mut lines = Vec::new();
382
383 for line in syntect::util::LinesWithEndings::from(code) {
384 let cache_key_lang = lang.unwrap_or("");
391 let highlighted: Vec<InlineSegment> =
392 if let Some(seg) = memo_get(cache_key_lang, line, width, theme_name) {
393 seg
394 } else if let Ok(ranges) = h.highlight_line(line, ss) {
395 let seg: Vec<InlineSegment> =
396 ranges
397 .into_iter()
398 .map(|(s, t)| {
399 let fg = s.foreground;
400 InlineSegment {
401 text: t.to_string(),
402 style: Arc::new(InlineTextStyle::default().with_color(Some(
403 AnsiColorEnum::Rgb(RgbColor(fg.r, fg.g, fg.b)),
404 ))),
405 }
406 })
407 .collect();
408 memo_put(cache_key_lang, line, width, theme_name, seg.clone());
409 seg
410 } else {
411 vec![InlineSegment {
412 text: line.to_string(),
413 style: Arc::new(InlineTextStyle::default()),
414 }]
415 };
416 if width == 0 {
417 lines.push(highlighted);
418 } else {
419 for wrapped in wrap_segments_to_rows(&highlighted, width) {
420 lines.push(wrapped);
421 }
422 }
423 }
424 lines
425}
426
427fn wrap_segments_to_rows(segs: &[InlineSegment], width: usize) -> Vec<Vec<InlineSegment>> {
433 let mut rows: Vec<Vec<InlineSegment>> = Vec::new();
434 let mut cur_row: Vec<InlineSegment> = Vec::new();
435 let mut cur_buf = String::new();
436 let mut cur_style: Option<Arc<InlineTextStyle>> = None;
437 let mut cur_w: usize = 0;
438
439 for seg in segs {
440 for ch in seg.text.chars() {
441 if ch == '\t' {
442 let pad = 4 - (cur_w % 4);
445 for _ in 0..pad {
446 if cur_w + 1 > width {
447 flush_wrap_chunk(
448 &mut cur_buf,
449 &mut cur_style,
450 &mut cur_row,
451 &mut rows,
452 &mut cur_w,
453 );
454 }
455 cur_buf.push(' ');
456 if cur_style.is_none() {
457 cur_style = Some(Arc::clone(&seg.style));
458 }
459 cur_w += 1;
460 }
461 continue;
462 }
463 let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
464 if ch_w == 0 {
466 cur_buf.push(ch);
467 if cur_style.is_none() {
468 cur_style = Some(Arc::clone(&seg.style));
469 }
470 continue;
471 }
472 if ch_w > width {
475 continue;
476 }
477 if cur_w + ch_w > width {
478 flush_wrap_chunk(
479 &mut cur_buf,
480 &mut cur_style,
481 &mut cur_row,
482 &mut rows,
483 &mut cur_w,
484 );
485 }
486 cur_buf.push(ch);
487 if cur_style.is_none() {
488 cur_style = Some(Arc::clone(&seg.style));
489 }
490 cur_w += ch_w;
491 }
492 }
493 flush_wrap_chunk(
494 &mut cur_buf,
495 &mut cur_style,
496 &mut cur_row,
497 &mut rows,
498 &mut cur_w,
499 );
500 if rows.is_empty() {
501 rows.push(Vec::new());
502 }
503 rows
504}
505
506fn flush_wrap_chunk(
509 buf: &mut String,
510 style: &mut Option<Arc<InlineTextStyle>>,
511 row: &mut Vec<InlineSegment>,
512 rows: &mut Vec<Vec<InlineSegment>>,
513 used: &mut usize,
514) {
515 if !buf.is_empty()
516 && let Some(s) = style.take()
517 {
518 row.push(InlineSegment {
519 text: std::mem::take(buf),
520 style: s,
521 });
522 }
523 if !row.is_empty() {
524 rows.push(std::mem::take(row));
525 }
526 *used = 0;
527}
528fn render_table(header: &[String], rows: &[Vec<String>], max_w: usize) -> Vec<Vec<InlineSegment>> {
537 let num_cols = std::cmp::max(
538 header.len(),
539 rows.iter().map(|r| r.len()).max().unwrap_or(0),
540 );
541 if num_cols == 0 {
542 return Vec::new();
543 }
544
545 let mut col_width: Vec<usize> = vec![0; num_cols];
547 for (c, cell) in header.iter().enumerate() {
548 col_width[c] = std::cmp::max(col_width[c], cell.width());
549 }
550 for row in rows {
551 for (c, cell) in row.iter().enumerate() {
552 col_width[c] = std::cmp::max(col_width[c], cell.width());
553 }
554 }
555
556 let chrome = 3 * num_cols + 1;
561 let budget = max_w.saturating_sub(chrome);
562 while col_width.iter().sum::<usize>() > budget {
563 let widest = col_width
566 .iter()
567 .enumerate()
568 .max_by_key(|&(i, w)| (w, std::cmp::Reverse(i)))
569 .filter(|&(_, w)| *w > 1)
570 .map(|(i, _)| i);
571 match widest {
572 Some(i) => col_width[i] -= 1,
573 None => break,
574 }
575 }
576
577 let mut out: Vec<Vec<InlineSegment>> = Vec::new();
578
579 let border = |l: &str, j: &str, r: &str| {
580 format!(
581 "{l}{}{r}",
582 col_width
583 .iter()
584 .map(|w| "─".repeat(w + 2))
585 .collect::<Vec<_>>()
586 .join(j)
587 )
588 };
589
590 let plain = |s: String| {
591 vec![InlineSegment {
592 text: s,
593 style: Arc::new(InlineTextStyle::default()),
594 }]
595 };
596 let bold = |s: String| {
597 vec![InlineSegment {
598 text: s,
599 style: Arc::new(InlineTextStyle::default().bold()),
600 }]
601 };
602
603 out.push(plain(border("┌", "┬", "┐")));
604
605 let mut cell_rows: Vec<(&[String], bool)> = vec![(header, true)];
606 cell_rows.extend(rows.iter().map(|r| (r.as_slice(), false)));
607 for (cells, is_header) in cell_rows {
608 let wrapped: Vec<Vec<String>> = col_width
611 .iter()
612 .enumerate()
613 .map(|(c, &w)| wrap_cell(cells.get(c).map(String::as_str).unwrap_or(""), w))
614 .collect();
615 let height = wrapped.iter().map(Vec::len).max().unwrap_or(1).max(1);
616 for line_idx in 0..height {
617 let text = format_wrapped_row(&wrapped, line_idx, &col_width);
618 let segs = if is_header { bold(text) } else { plain(text) };
619 out.push(segs);
620 }
621 if is_header {
622 out.push(plain(border("├", "┼", "┤")));
623 }
624 }
625
626 out.push(plain(border("└", "┴", "┘")));
627 out
628}
629
630fn wrap_cell(text: &str, w: usize) -> Vec<String> {
632 if w == 0 {
633 return vec![String::new()];
634 }
635 if text.width() <= w {
636 return vec![text.to_string()];
637 }
638 let mut out: Vec<String> = Vec::new();
639 let mut cur = String::new();
640 let mut cur_w = 0usize;
641 for ch in text.chars() {
642 let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
643 if cur_w + ch_w > w && !cur.is_empty() {
644 out.push(std::mem::take(&mut cur));
645 cur_w = 0;
646 }
647 cur.push(ch);
648 cur_w += ch_w;
649 }
650 if !cur.is_empty() {
651 out.push(cur);
652 }
653 if out.is_empty() {
654 out.push(String::new());
655 }
656 out
657}
658
659fn format_wrapped_row(wrapped: &[Vec<String>], line_idx: usize, col_width: &[usize]) -> String {
662 let mut parts: Vec<String> = Vec::with_capacity(col_width.len());
663 for (c, &w) in col_width.iter().enumerate() {
664 let text = wrapped
665 .get(c)
666 .and_then(|lines| lines.get(line_idx))
667 .map(String::as_str)
668 .unwrap_or("");
669 let pad = w.saturating_sub(text.width());
670 parts.push(format!(" {text}{} ", " ".repeat(pad)));
671 }
672 format!("│{}│", parts.join("│"))
673}
674struct CodeBlockState {
677 code: String,
678 lang: Option<String>,
679}
680
681#[derive(Default)]
682struct TableState {
683 header: Vec<String>,
684 rows: Vec<Vec<String>>,
685 current_cell: String,
686 current_row: Vec<String>,
687}
688
689struct ListLevel {
690 is_ordered: bool,
691 index: u64,
692}
693
694fn flush_line(cur: &mut Vec<InlineSegment>, lines: &mut Vec<Vec<InlineSegment>>) {
695 if !cur.is_empty() {
696 lines.push(std::mem::take(cur));
697 }
698}
699
700fn merge_or_push(cur: &mut Vec<InlineSegment>, seg: InlineSegment) {
701 if let Some(last) = cur.last_mut() {
702 if last.style == seg.style {
703 last.text.push_str(&seg.text);
704 return;
705 }
706 }
707 cur.push(seg);
708}
709
710fn apply_effects(mut style: InlineTextStyle, effects: Effects) -> InlineTextStyle {
711 if effects.contains(Effects::BOLD) {
712 style = style.bold();
713 }
714 if effects.contains(Effects::ITALIC) {
715 style = style.italic();
716 }
717 if effects.contains(Effects::UNDERLINE) {
718 style = style.underline();
719 }
720 if effects.contains(Effects::DIMMED) {
721 style = style.dim();
722 }
723 if effects.contains(Effects::STRIKETHROUGH) {
724 style.effects |= Effects::STRIKETHROUGH;
725 }
726 style
727}
728
729#[cfg(test)]
730mod tests {
731 use super::*;
732
733 fn line_text(line: &[InlineSegment]) -> String {
734 line.iter().map(|s| s.text.as_str()).collect()
735 }
736
737 #[test]
738 fn unordered_list_has_markers() {
739 let out = render_markdown("- a\n- b\n", 200);
740 let combined: Vec<String> = out.iter().map(|l| line_text(l)).collect();
742 let line_a = combined
743 .iter()
744 .find(|l| l.contains('a'))
745 .expect("line with 'a'");
746 let line_b = combined
747 .iter()
748 .find(|l| l.contains('b'))
749 .expect("line with 'b'");
750 assert!(line_a.contains('\u{2022}'), "missing bullet in: {line_a:?}");
751 assert!(line_b.contains('\u{2022}'), "missing bullet in: {line_b:?}");
752 }
753
754 #[test]
755 fn ordered_list_has_numbers() {
756 let out = render_markdown("1. first\n2. second\n", 200);
757 let combined: Vec<String> = out.iter().map(|l| line_text(l)).collect();
758 let has_one = combined
759 .iter()
760 .any(|l| l.contains("1.") && l.contains("first"));
761 let has_two = combined
762 .iter()
763 .any(|l| l.contains("2.") && l.contains("second"));
764 assert!(has_one, "missing '1.' marker in {combined:?}");
765 assert!(has_two, "missing '2.' marker in {combined:?}");
766 }
767
768 #[test]
769 fn table_renders_borders() {
770 let md = "| h1 | h2 |\n|----|----|\n| a | b |\n| c | d |\n";
771 let out = render_markdown(md, 200);
772 let combined: Vec<String> = out.iter().map(|l| line_text(l)).collect();
773 let bar_lines = combined.iter().filter(|l| l.contains('\u{2502}')).count();
774 assert!(bar_lines >= 3, "expected ≥3 lines with │, got {combined:?}");
775 let has_top_or_bottom = combined
776 .iter()
777 .any(|l| l.contains('\u{250C}') || l.contains('\u{2514}'));
778 assert!(
779 has_top_or_bottom,
780 "expected ┌ or └ in output, got {combined:?}"
781 );
782 }
783
784 #[test]
785 fn inline_still_works() {
786 let out = render_markdown("**bold**", 200);
787 let bold_found = out.iter().any(|line| {
788 line.iter()
789 .any(|seg| seg.style.effects.contains(anstyle::Effects::BOLD))
790 });
791 assert!(bold_found, "expected BOLD effect in rendered segments");
792 }
793 #[test]
794 fn table_cell_keeps_inline_code() {
795 let md = "| type | example |\n|------|----------|\n| foo | `bar` |\n";
798 let out = render_markdown(md, 200);
799 let joined: String = out
800 .iter()
801 .map(|l| line_text(l))
802 .collect::<Vec<_>>()
803 .join("\n");
804 assert!(
805 joined.contains("bar"),
806 "inline code `bar` dropped from table cell: {joined:?}"
807 );
808 }
809
810 #[test]
811 fn table_cjk_columns_align() {
812 let md = "| a | b |\n|---|----|\n| 中 | x |\n| 1 | yy |\n";
815 let out = render_markdown(md, 200);
816 let rows: Vec<String> = out
817 .iter()
818 .map(|l| line_text(l))
819 .filter(|l| l.starts_with('\u{2502}'))
820 .collect();
821 let widths: Vec<usize> = rows
822 .iter()
823 .map(|l| unicode_width::UnicodeWidthStr::width(l.as_str()))
824 .collect();
825 let first = widths[0];
826 assert!(
827 widths.iter().all(|&w| w == first),
828 "CJK column misalignment — row display widths differ: {widths:?}\n{rows:?}"
829 );
830 }
831
832 #[test]
833 fn table_fits_the_given_width_and_wraps_cells() {
834 let md = "\
838| colA | colB |\n\
839|------|------|\n\
840| alpha | xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx |\n";
841 let width = 30usize;
842 let out = render_markdown(md, width);
843 let rows: Vec<String> = out.iter().map(|l| line_text(l)).collect();
844 assert!(!rows.is_empty(), "table produced no rows");
845 for (i, row) in rows.iter().enumerate() {
846 let w = unicode_width::UnicodeWidthStr::width(row.as_str());
847 assert!(w <= width, "row {i} overflows: {w} > {width}\n{row}");
848 }
849 let borders: Vec<usize> = rows
850 .iter()
851 .filter(|r| r.starts_with('┌') || r.starts_with('├') || r.starts_with('└'))
852 .map(|r| unicode_width::UnicodeWidthStr::width(r.as_str()))
853 .collect();
854 assert_eq!(borders.len(), 3, "top/mid/bottom borders");
855 assert!(
856 borders.iter().all(|&w| w == borders[0]),
857 "border widths differ: {borders:?}"
858 );
859 let data_rows = rows.iter().filter(|r| r.starts_with('│')).count();
861 assert!(
862 data_rows > 1,
863 "the long cell should wrap to multiple rows, got {data_rows}\n{rows:?}"
864 );
865 }
866
867 #[test]
868 fn table_narrower_than_viewport_keeps_natural_width() {
869 let md = "| a | b |\n|---|---|\n| 1 | 2 |\n";
870 let out = render_markdown(md, 200);
871 let top = out
872 .iter()
873 .map(|l| line_text(l))
874 .find(|l| l.starts_with('┌'))
875 .expect("top border");
876 assert!(
877 unicode_width::UnicodeWidthStr::width(top.as_str()) <= 200,
878 "natural width exceeds viewport"
879 );
880 }
881 #[test]
882 fn code_block_hard_wraps_to_given_width() {
883 let line: String = "a".repeat(200);
887 let md = format!("```\n{line}\n```\n");
888 let out = render_markdown(&md, 80);
889 let rows: Vec<String> = out.iter().map(|l| line_text(l)).collect();
890 assert!(!rows.is_empty(), "code block produced no rows");
891 for (i, row) in rows.iter().enumerate() {
892 let w = unicode_width::UnicodeWidthStr::width(row.as_str());
893 assert!(w <= 80, "code row {i} overflows: {w} > 80\n{row}");
894 }
895 let joined: String = rows.join("");
896 assert!(
897 joined.contains(&line),
898 "concatenated code rows must contain the full original line\njoined={joined:?}\nwant={line:?}"
899 );
900 }
901
902 #[test]
903 fn code_block_width_zero_keeps_natural_lines() {
904 let line: String = "z".repeat(40);
907 let md = format!("```\n{line}\n```\n");
908 let out = render_markdown(&md, 0);
909 let rows: Vec<String> = out.iter().map(|l| line_text(l)).collect();
910 assert!(
911 rows.iter().any(|r| r.contains(&line)),
912 "width 0 must preserve natural-length lines, got {rows:?}"
913 );
914 }
915
916 fn flatten_lines(lines: &[Vec<InlineSegment>]) -> Vec<String> {
924 lines.iter().map(|l| line_text(l)).collect()
925 }
926
927 #[test]
928 fn cached_prefix_reuses_lines() {
929 let mut cache = MdRenderCache::default();
931 let _ = render_markdown_cached("hello", 80, &mut cache);
932 assert_eq!(
933 cache.debug_hits(),
934 0,
935 "first call is a cold render (no cache hit)"
936 );
937
938 let again = render_markdown_cached("hello", 80, &mut cache);
941 assert_eq!(
942 cache.debug_hits(),
943 1,
944 "identical input must register a cache hit"
945 );
946 assert_eq!(
947 flatten_lines(&again),
948 flatten_lines(&render_markdown("hello", 80)),
949 "fast-path output equals fresh render"
950 );
951
952 let _ = render_markdown_cached("hello world", 80, &mut cache);
955 assert_eq!(
956 cache.debug_hits(),
957 1,
958 "different text does not register a hit"
959 );
960 let _ = render_markdown_cached("hello world", 80, &mut cache);
961 assert_eq!(
962 cache.debug_hits(),
963 2,
964 "second identical call after a miss must hit again"
965 );
966 }
967
968 #[test]
969 fn cached_result_equals_fresh_render() {
970 let mut cache = MdRenderCache::default();
975 let base = "The quick brown fox jumps over the lazy dog.";
976 let appends = ["", " Stream chunk one.", " More.", " Even more."];
977 let mut text = base.to_string();
978 let width = 40usize;
979 for (i, suffix) in std::iter::once("")
980 .chain(appends.iter().copied())
981 .enumerate()
982 {
983 if i > 0 {
984 text.push_str(suffix);
985 }
986 let cached = render_markdown_cached(&text, width, &mut cache);
987 let fresh = render_markdown(&text, width);
988 assert_eq!(
989 flatten_lines(&cached),
990 flatten_lines(&fresh),
991 "cached output diverges from fresh render at step {i}: text={text:?}"
992 );
993 }
994 }
995
996 #[test]
997 fn width_change_busts_cache() {
998 let mut cache = MdRenderCache::default();
1000 let _baseline = render_markdown_cached("# title\n\nbody", 80, &mut cache);
1001 assert_eq!(cache.debug_hits(), 0);
1002
1003 let narrowed = render_markdown_cached("# title\n\nbody", 40, &mut cache);
1006 assert_eq!(
1007 cache.debug_hits(),
1008 0,
1009 "width change must NOT register a fast-path hit"
1010 );
1011 assert_eq!(
1012 flatten_lines(&narrowed),
1013 flatten_lines(&render_markdown("# title\n\nbody", 40)),
1014 "narrowed output equals fresh render"
1015 );
1016
1017 let _ = render_markdown_cached("# title\n\nbody", 80, &mut cache);
1020 assert_eq!(cache.debug_hits(), 0, "miss after width bust");
1021 let _ = render_markdown_cached("# title\n\nbody", 80, &mut cache);
1022 assert_eq!(
1023 cache.debug_hits(),
1024 1,
1025 "subsequent identical input must hit again"
1026 );
1027 }
1028}