Skip to main content

rab/tui/components/
markdown.rs

1#![allow(clippy::type_complexity, clippy::arc_with_non_send_sync)]
2
3//! Markdown rendering using comrak's tree-based AST.
4//!
5//! Two-phase approach:
6//!   1. Parse with comrak → mutable tree AST
7//!   2. AST manipulation: float headings/code blocks/blockquotes out of lists
8//!      (prevents progressive nesting from LLM output artifacts)
9//!   3. Render tree → styled ANSI lines
10//!   4. Wrap + pad → final output
11
12use std::sync::Arc;
13
14use comrak::nodes::{AstNode, ListType, NodeCodeBlock, NodeTable, NodeValue};
15use comrak::{Arena, Options, parse_document};
16
17use crate::tui::Component;
18use crate::tui::util::{visible_width, wrap_text_with_ansi};
19
20// ── Type aliases ────────────────────────────────────────────────
21
22/// Type alias for markdown theme styling functions.
23pub type StyleFn = Arc<dyn Fn(&str) -> String>;
24/// Type alias for code highlighting function.
25pub type HighlightFn = Arc<dyn Fn(&str, Option<&str>) -> Vec<String>>;
26
27// ── Code block indent ───────────────────────────────────────────
28
29/// Indent prefix applied to each code line inside a fenced code block.
30/// Defaults to two spaces for visual inset from the backtick fence.
31pub const CODE_BLOCK_INDENT: &str = "  ";
32
33// ── MarkdownTheme ───────────────────────────────────────────────
34
35/// Theme functions for markdown elements.
36/// Each function takes text and returns styled text with ANSI codes.
37pub struct MarkdownTheme {
38    pub heading: StyleFn,
39    pub link: StyleFn,
40    pub link_url: StyleFn,
41    pub code: StyleFn,
42    pub code_block: StyleFn,
43    pub code_block_border: StyleFn,
44    pub quote: StyleFn,
45    pub quote_border: StyleFn,
46    pub hr: StyleFn,
47    pub list_bullet: StyleFn,
48    pub bold: StyleFn,
49    pub italic: StyleFn,
50    pub strikethrough: StyleFn,
51    pub underline: StyleFn,
52    /// If set, used for syntax-highlighted code blocks.
53    pub highlight_code: Option<HighlightFn>,
54    /// Indent prefix applied to each code line inside a fenced code block.
55    /// Defaults to two spaces for visual inset from the backtick fence.
56    pub code_block_indent: String,
57}
58
59impl MarkdownTheme {
60    #[allow(clippy::too_many_arguments)]
61    pub fn new(
62        heading: StyleFn,
63        link: StyleFn,
64        link_url: StyleFn,
65        code: StyleFn,
66        code_block: StyleFn,
67        code_block_border: StyleFn,
68        quote: StyleFn,
69        quote_border: StyleFn,
70        hr: StyleFn,
71        list_bullet: StyleFn,
72        bold: StyleFn,
73        italic: StyleFn,
74        strikethrough: StyleFn,
75        underline: StyleFn,
76    ) -> Self {
77        Self {
78            heading,
79            link,
80            link_url,
81            code,
82            code_block,
83            code_block_border,
84            quote,
85            quote_border,
86            hr,
87            list_bullet,
88            bold,
89            italic,
90            strikethrough,
91            underline,
92            highlight_code: None,
93            code_block_indent: "  ".to_string(),
94        }
95    }
96}
97
98// ── DefaultTextStyle ─────────────────────────────────────────────
99
100/// Default text styling for markdown content.
101/// Applied to all text unless overridden by markdown formatting.
102pub struct DefaultTextStyle {
103    /// Optional foreground color function.
104    pub color: Option<StyleFn>,
105    pub bold: bool,
106    pub italic: bool,
107    pub strikethrough: bool,
108    pub underline: bool,
109}
110
111// ── Internal helpers ─────────────────────────────────────────────
112
113/// Context for inline rendering, carrying the parent-style functions
114/// and the ANSI prefix to restore after inline resets.
115struct InlineCtx {
116    /// Apply the current text style (color + decorations).
117    apply_text: Arc<dyn Fn(&str) -> String>,
118    /// ANSI prefix to emit after closing an inline element,
119    /// restoring this context's styling.
120    style_prefix: String,
121}
122
123impl InlineCtx {
124    fn new(apply_text: Arc<dyn Fn(&str) -> String>) -> Self {
125        let prefix = get_style_prefix(&*apply_text);
126        Self {
127            apply_text,
128            style_prefix: prefix,
129        }
130    }
131}
132
133/// Extract the ANSI prefix from a style function.
134fn get_style_prefix(style_fn: &dyn Fn(&str) -> String) -> String {
135    const SENTINEL: char = '\0';
136    let styled = style_fn(&SENTINEL.to_string());
137    styled
138        .find(SENTINEL)
139        .map(|i| styled[..i].to_string())
140        .unwrap_or_default()
141}
142
143/// Check whether hyperlinks (OSC 8) are supported.
144pub(crate) fn hyperlinks_supported() -> bool {
145    if let Ok(prog) = std::env::var("TERM_PROGRAM")
146        && (prog == "iTerm.app" || prog == "kitty" || prog == "WezTerm" || prog == "vscode")
147    {
148        return true;
149    }
150    if let Ok(term) = std::env::var("TERM")
151        && term.contains("kitty")
152    {
153        return true;
154    }
155    #[cfg(windows)]
156    {
157        if let Ok(prog) = std::env::var("WT_SESSION") {
158            let _ = prog;
159            return true;
160        }
161    }
162    false
163}
164
165/// Wrap text in an OSC 8 hyperlink.
166pub(crate) fn hyperlink(text: &str, url: &str) -> String {
167    format!("\x1b]8;;{}\x07{}\x1b]8;;\x07", url, text)
168}
169
170// ── Image Display Support (Kitty Protocol) ───────────────────────
171
172/// Check whether the terminal supports the Kitty image protocol.
173pub(crate) fn kitty_images_supported() -> bool {
174    // Kitty, iTerm2, and WezTerm all support the Kitty image protocol.
175    if let Ok(prog) = std::env::var("TERM_PROGRAM")
176        && (prog == "iTerm.app" || prog == "kitty" || prog == "WezTerm")
177    {
178        return true;
179    }
180    if let Ok(term) = std::env::var("TERM")
181        && term.contains("kitty")
182    {
183        return true;
184    }
185    false
186}
187
188/// Generate a Kitty image protocol sequence for inline display.
189///
190/// The image is displayed inline at the cursor position. After the sequence,
191/// the cursor moves to the end of the image (which occupies one line in the
192/// terminal, with height calculated from aspect ratio).
193///
194/// Format: `\x1b_Ga=T,f=<format>,m=0;<base64>\x1b\\`
195pub(crate) fn kitty_image_sequence(data: &[u8], mime_type: &str) -> String {
196    use base64::Engine as _;
197    let format = match mime_type {
198        "image/png" => 100,
199        "image/jpeg" | "image/jpg" => 101,
200        "image/gif" => 102,
201        "image/webp" => 103,
202        _ => 100, // default to PNG
203    };
204    let b64 = base64::engine::general_purpose::STANDARD.encode(data);
205    format!("\x1b_Ga=T,f={},m=0;{}\x1b\\", format, b64)
206}
207
208// ── Markdown Component ───────────────────────────────────────────
209
210/// Markdown rendering component.
211///
212/// Parses markdown with comrak (tree-based CommonMark parser),
213/// restructures the AST to fix LLM-induced nesting artifacts,
214/// then renders to styled ANSI output.
215pub struct Markdown {
216    text: String,
217    padding_x: usize,
218    padding_y: usize,
219    theme: MarkdownTheme,
220    default_text_style: Option<DefaultTextStyle>,
221
222    // Cache
223    cached_text: Option<String>,
224    cached_width: Option<usize>,
225    cached_lines: Vec<String>,
226}
227
228impl Markdown {
229    pub fn new(
230        text: impl Into<String>,
231        padding_x: usize,
232        padding_y: usize,
233        theme: MarkdownTheme,
234        default_text_style: Option<DefaultTextStyle>,
235    ) -> Self {
236        Self {
237            text: text.into(),
238            padding_x,
239            padding_y,
240            theme,
241            default_text_style,
242            cached_text: None,
243            cached_width: None,
244            cached_lines: Vec::new(),
245        }
246    }
247
248    pub fn set_text(&mut self, text: impl Into<String>) {
249        self.text = text.into();
250        self.invalidate();
251    }
252
253    pub fn cached_text_matches(&self, other: &str) -> bool {
254        self.cached_text.as_deref() == Some(&self.text) && self.text == other
255    }
256
257    pub fn get_text(&self) -> &str {
258        &self.text
259    }
260
261    // ── Style helpers ────────────────────────────────────────────
262
263    fn build_default_ctx(&self) -> InlineCtx {
264        InlineCtx::new(self.build_default_apply_fn())
265    }
266
267    fn build_default_apply_fn(&self) -> Arc<dyn Fn(&str) -> String> {
268        let style = &self.default_text_style;
269        let theme = &self.theme;
270
271        let color: Option<StyleFn> = style.as_ref().and_then(|s| s.color.clone());
272        let bold = style.as_ref().map(|s| s.bold).unwrap_or(false);
273        let italic = style.as_ref().map(|s| s.italic).unwrap_or(false);
274        let strikethrough = style.as_ref().map(|s| s.strikethrough).unwrap_or(false);
275        let underline = style.as_ref().map(|s| s.underline).unwrap_or(false);
276        let theme_bold = theme.bold.clone();
277        let theme_italic = theme.italic.clone();
278        let theme_strikethrough = theme.strikethrough.clone();
279        let theme_underline = theme.underline.clone();
280
281        Arc::new(move |text: &str| {
282            let mut styled = text.to_string();
283            if let Some(ref color_fn) = color {
284                styled = color_fn(&styled);
285            }
286            if bold {
287                styled = theme_bold(&styled);
288            }
289            if italic {
290                styled = theme_italic(&styled);
291            }
292            if strikethrough {
293                styled = theme_strikethrough(&styled);
294            }
295            if underline {
296                styled = theme_underline(&styled);
297            }
298            styled
299        })
300    }
301
302    fn heading_ctx(&self, level: u8) -> InlineCtx {
303        let theme_heading = self.theme.heading.clone();
304        let theme_bold = self.theme.bold.clone();
305        let theme_underline = self.theme.underline.clone();
306
307        let style_fn: Arc<dyn Fn(&str) -> String> = match level {
308            1 => Arc::new(move |text: &str| theme_heading(&theme_bold(&theme_underline(text)))),
309            _ => Arc::new(move |text: &str| theme_heading(&theme_bold(text))),
310        };
311        InlineCtx::new(style_fn)
312    }
313
314    fn quote_ctx(&self) -> InlineCtx {
315        let theme_quote = self.theme.quote.clone();
316        let theme_italic = self.theme.italic.clone();
317        let style_fn: Arc<dyn Fn(&str) -> String> =
318            Arc::new(move |text: &str| theme_quote(&theme_italic(text)));
319        InlineCtx::new(style_fn)
320    }
321
322    // ── Flattening: float "LLM artifact" nodes out of lists ─────
323    //
324    // LLM markdown output often indents headings, code blocks, and
325    // blockquotes inside list items, e.g.:
326    //
327    //   - Step 1
328    //     ### Notes
329    //     ```python
330    //     ...
331    //     ```
332    //
333    // CommonMark parsers nest these inside the list item. This pass
334    // detects such nodes and detaches/reparents them just after the
335    // nearest enclosing List, so they render at list-sibling depth.
336    //
337    // We collect nodes first, then reparent, to avoid issues with
338    // tree mutation during iteration.
339
340    /// Collect nodes that should be floated out of their enclosing list.
341    fn collect_float_candidates<'a>(&self, root: &'a AstNode<'a>) -> Vec<&'a AstNode<'a>> {
342        let mut candidates: Vec<&'a AstNode<'a>> = Vec::new();
343
344        for node in root.descendants() {
345            let val = node.data.borrow();
346            let is_floatable = matches!(
347                val.value,
348                NodeValue::Heading(_) | NodeValue::CodeBlock(_) | NodeValue::BlockQuote
349            );
350            if !is_floatable {
351                continue;
352            }
353            // Check if any ancestor is a List or Item (but not reaching root)
354            let mut ancestor = node.parent();
355            let mut inside_list = false;
356            while let Some(anc) = ancestor {
357                let av = anc.data.borrow();
358                match av.value {
359                    NodeValue::List(_) | NodeValue::Item { .. } => {
360                        inside_list = true;
361                        break;
362                    }
363                    NodeValue::Document => break,
364                    _ => {}
365                }
366                ancestor = anc.parent();
367            }
368            if inside_list {
369                candidates.push(node);
370            }
371        }
372        candidates
373    }
374
375    /// Find the nearest enclosing List ancestor of a node.
376    fn find_enclosing_list<'a>(&self, node: &'a AstNode<'a>) -> Option<&'a AstNode<'a>> {
377        let mut ancestor = node.parent();
378        while let Some(anc) = ancestor {
379            let av = anc.data.borrow();
380            if matches!(av.value, NodeValue::List(_)) {
381                return Some(anc);
382            }
383            ancestor = anc.parent();
384        }
385        None
386    }
387
388    /// Float collected candidates out of their enclosing lists.
389    fn float_block_nodes<'a>(&self, root: &'a AstNode<'a>) {
390        let candidates = self.collect_float_candidates(root);
391        for node in candidates {
392            // If already detached by a previous reparenting, skip
393            if node.parent().is_none() {
394                continue;
395            }
396            let Some(list_node) = self.find_enclosing_list(node) else {
397                continue;
398            };
399
400            // Detach the candidate from its current parent
401            node.detach();
402
403            // Insert it after the list node (at the list's sibling level)
404            list_node.insert_after(node);
405        }
406    }
407
408    // ── Comrak options ───────────────────────────────────────────
409
410    fn comrak_options() -> Options<'static> {
411        use comrak::options::Extension;
412        Options {
413            extension: Extension {
414                strikethrough: true,
415                table: true,
416                autolink: true,
417                tasklist: true,
418                tagfilter: false,
419                ..Extension::default()
420            },
421            ..Options::default()
422        }
423    }
424}
425
426impl Component for Markdown {
427    fn render(&mut self, width: usize) -> Vec<String> {
428        // Check cache
429        if self.cached_text.as_deref() == Some(&self.text) && self.cached_width == Some(width) {
430            return self.cached_lines.clone();
431        }
432
433        // Don't render anything if there's no actual text
434        if self.text.is_empty() || self.text.trim().is_empty() {
435            self.cached_text = Some(self.text.clone());
436            self.cached_width = Some(width);
437            self.cached_lines = Vec::new();
438            return Vec::new();
439        }
440
441        let content_width = width.saturating_sub(2 * self.padding_x).max(1);
442
443        // Parse with comrak
444        let arena = Arena::new();
445        let normalized = self.text.replace('\t', "   ");
446        let opts = Self::comrak_options();
447        let root = parse_document(&arena, &normalized, &opts);
448
449        // AST manipulation: float headings/code/blockquotes out of lists
450        self.float_block_nodes(root);
451
452        // Render tree to styled ANSI lines
453        let rendered = self.render_node_lines(root, content_width, 0);
454
455        // Wrap lines
456        let mut wrapped: Vec<String> = Vec::new();
457        for line in &rendered {
458            for wl in wrap_text_with_ansi(line, content_width) {
459                wrapped.push(wl);
460            }
461        }
462
463        // Add padding
464        let left_margin = " ".repeat(self.padding_x);
465        let right_margin = " ".repeat(self.padding_x);
466        let mut content_lines: Vec<String> = Vec::new();
467        for line in &wrapped {
468            let line_with_margins = format!("{}{}{}", left_margin, line, right_margin);
469            let visible = visible_width(&line_with_margins);
470            let padded = if visible < width {
471                format!("{}{}", line_with_margins, " ".repeat(width - visible))
472            } else {
473                line_with_margins
474            };
475            content_lines.push(padded);
476        }
477
478        let empty_line = " ".repeat(width);
479        let mut result = Vec::new();
480        for _ in 0..self.padding_y {
481            result.push(empty_line.clone());
482        }
483        result.extend(content_lines);
484        for _ in 0..self.padding_y {
485            result.push(empty_line.clone());
486        }
487
488        // Update cache
489        self.cached_text = Some(self.text.clone());
490        self.cached_width = Some(width);
491        self.cached_lines = result.clone();
492
493        if result.is_empty() {
494            vec![String::new()]
495        } else {
496            result
497        }
498    }
499
500    fn invalidate(&mut self) {
501        self.cached_text = None;
502        self.cached_width = None;
503        self.cached_lines.clear();
504    }
505}
506
507// ── Tree Rendering ──────────────────────────────────────────────
508
509impl Markdown {
510    /// Render a node's children as lines, collecting non-inline children.
511    /// `list_depth` tracks list nesting for indentation (the float pass
512    /// removes most artifical nesting, but genuine nested lists remain).
513    /// Determine whether to add a blank line between two consecutive block-level siblings.
514    /// Determine whether to add a blank line between two consecutive block-level siblings.
515    /// Matches pi's per-block-type behavior:
516    ///   - Paragraph: add blank unless next is List
517    ///   - List: never add trailing blank
518    ///   - Heading, CodeBlock, BlockQuote, Table, ThematicBreak: always add blank
519    ///   - HtmlBlock, FrontMatter, other: never add blank
520    fn should_add_block_spacing(current: &NodeValue, next: &NodeValue) -> bool {
521        match current {
522            NodeValue::Paragraph => !matches!(next, NodeValue::List(_)),
523            NodeValue::List(_) => false,
524            NodeValue::Heading(_)
525            | NodeValue::CodeBlock(_)
526            | NodeValue::BlockQuote
527            | NodeValue::Table(_)
528            | NodeValue::ThematicBreak => true,
529            _ => false,
530        }
531    }
532
533    fn render_node_lines<'a>(
534        &self,
535        node: &'a AstNode<'a>,
536        width: usize,
537        list_depth: usize,
538    ) -> Vec<String> {
539        let val = node.data.borrow();
540        let mut lines: Vec<String> = Vec::new();
541        let children: Vec<_> = node.children().collect();
542
543        match &val.value {
544            NodeValue::Document => {
545                for (i, child) in children.iter().enumerate() {
546                    let child_lines = self.render_node_lines(child, width, 0);
547                    let is_last = i + 1 == children.len();
548                    if child_lines.is_empty() && is_last {
549                        continue;
550                    }
551                    lines.extend(child_lines);
552                    if !is_last {
553                        let current_val = child.data.borrow();
554                        let next_val = children[i + 1].data.borrow();
555                        if Self::should_add_block_spacing(&current_val.value, &next_val.value) {
556                            lines.push(String::new());
557                        }
558                    }
559                }
560            }
561
562            NodeValue::Paragraph => {
563                let ctx = self.build_default_ctx();
564                let text = self.render_inline_children(&children, &ctx);
565                if !text.is_empty() {
566                    lines.push(text);
567                }
568            }
569
570            NodeValue::Heading(h) => {
571                let ctx = self.heading_ctx(h.level);
572                let content = self.render_inline_children(&children, &ctx);
573                let styled = if h.level >= 3 {
574                    let prefix = format!("{} ", "#".repeat(h.level as usize));
575                    format!("{}{}", (ctx.apply_text)(&prefix), content)
576                } else {
577                    content
578                };
579                lines.push(styled);
580            }
581
582            NodeValue::CodeBlock(cb) => {
583                self.render_code_block(cb, &mut lines);
584            }
585
586            NodeValue::List(_lst) => {
587                let list_lines = self.render_list(node, children.clone(), width, list_depth);
588                lines.extend(list_lines);
589            }
590
591            NodeValue::Item(_) => {
592                // Items are handled by render_list; render children directly
593                for child in &children {
594                    lines.extend(self.render_node_lines(child, width, list_depth));
595                }
596            }
597
598            NodeValue::BlockQuote => {
599                lines.extend(self.render_blockquote(&children, width));
600            }
601
602            NodeValue::Table(tbl) => {
603                lines.extend(self.render_table(node, tbl, &children, width));
604            }
605
606            NodeValue::ThematicBreak => {
607                lines.push((self.theme.hr)(&"─".repeat(width.min(80))));
608            }
609
610            NodeValue::HtmlBlock(hb) => {
611                let ctx = self.build_default_ctx();
612                for line in hb.literal.lines() {
613                    let trimmed = line.trim();
614                    if !trimmed.is_empty() {
615                        lines.push((ctx.apply_text)(trimmed));
616                    }
617                }
618            }
619
620            NodeValue::FrontMatter(_) => {
621                // Skip front matter
622            }
623
624            _ => {
625                // Fallback: try to render as inline text if there's any
626                let ctx = self.build_default_ctx();
627                let text = self.render_inline_children(&children, &ctx);
628                if !text.is_empty() {
629                    lines.push(text);
630                }
631            }
632        }
633
634        lines
635    }
636
637    // ── Code Block ───────────────────────────────────────────────
638
639    fn render_code_block(&self, cb: &NodeCodeBlock, lines: &mut Vec<String>) {
640        let border = self.theme.code_block_border.clone();
641        let code_fn = self.theme.code_block.clone();
642        let indent = &self.theme.code_block_indent;
643
644        let lang = if cb.info.is_empty() {
645            None
646        } else {
647            Some(cb.info.as_str())
648        };
649
650        // Opening fence
651        lines.push(border(&format!("```{}", lang.unwrap_or(""))));
652
653        // Syntax highlighting or plain
654        if let Some(ref highlight) = self.theme.highlight_code {
655            let hl_lines = highlight(&cb.literal, lang);
656            for hl in hl_lines {
657                lines.push(format!("{}{}", indent, hl));
658            }
659        } else {
660            for code_line in cb.literal.split('\n') {
661                lines.push(format!("{}{}", indent, code_fn(code_line)));
662            }
663        }
664
665        // Closing fence
666        lines.push(border("```"));
667    }
668
669    // ── List ─────────────────────────────────────────────────────
670
671    fn render_list<'a>(
672        &self,
673        node: &'a AstNode<'a>,
674        children: Vec<&'a AstNode<'a>>,
675        width: usize,
676        depth: usize,
677    ) -> Vec<String> {
678        let mut result: Vec<String> = Vec::new();
679        let val = node.data.borrow();
680        let NodeValue::List(lst) = &val.value else {
681            return result;
682        };
683
684        let indent_str = "    ".repeat(depth.min(8));
685        let start_number = lst.start.max(1);
686        let mut item_index: u64 = 0;
687
688        for child in &children {
689            let cv = child.data.borrow();
690            let is_item = matches!(cv.value, NodeValue::Item(_) | NodeValue::TaskItem(_));
691            if !is_item {
692                continue;
693            }
694            item_index += 1;
695
696            // Check for task list marker
697            let mut task_marker = String::new();
698            if let NodeValue::TaskItem(ti) = &cv.value {
699                task_marker = if ti.symbol.is_some() {
700                    "[x] ".to_string()
701                } else {
702                    "[ ] ".to_string()
703                };
704            } else {
705                // Also check children for TaskItem (some comrak versions nest it)
706                for ic in child.children() {
707                    if let NodeValue::TaskItem(ti) = &ic.data.borrow().value {
708                        task_marker = if ti.symbol.is_some() {
709                            "[x] ".to_string()
710                        } else {
711                            "[ ] ".to_string()
712                        };
713                        break;
714                    }
715                }
716            }
717
718            let raw_marker = if lst.list_type == ListType::Ordered {
719                format!("{}. ", start_number + item_index as usize - 1)
720            } else {
721                "- ".to_string()
722            };
723            let marker = format!("{}{}", raw_marker, task_marker);
724
725            let bullet_prefix = indent_str.clone() + &(self.theme.list_bullet)(&marker);
726            let continuation_prefix = indent_str.clone() + &" ".repeat(visible_width(&marker));
727            let item_width = width.saturating_sub(visible_width(&bullet_prefix)).max(1);
728            let mut rendered_any = false;
729
730            // Gather item's block-level children
731            let item_children: Vec<_> = child.children().collect();
732            for item_child in &item_children {
733                let ic_val = item_child.data.borrow();
734                match &ic_val.value {
735                    NodeValue::List(_) => {
736                        // Nested list: fold into rendering by recursing
737                        let nested = self.render_list(
738                            item_child,
739                            item_child.children().collect(),
740                            width,
741                            depth + 1,
742                        );
743                        result.extend(nested);
744                        rendered_any = true;
745                    }
746                    NodeValue::Paragraph => {
747                        let ctx = self.build_default_ctx();
748                        let text = self.render_inline_children(
749                            &item_child.children().collect::<Vec<_>>(),
750                            &ctx,
751                        );
752                        for wl in wrap_text_with_ansi(&text, item_width) {
753                            let prefix = if rendered_any {
754                                &continuation_prefix
755                            } else {
756                                &bullet_prefix
757                            };
758                            result.push(format!("{}{}", prefix, wl));
759                            rendered_any = true;
760                        }
761                    }
762                    _ => {
763                        // Other block (already floated, but handle eg nested quotes)
764                        let block_lines = self.render_node_lines(item_child, item_width, depth);
765                        for bl in &block_lines {
766                            for wl in wrap_text_with_ansi(bl, item_width) {
767                                let prefix = if rendered_any {
768                                    &continuation_prefix
769                                } else {
770                                    &bullet_prefix
771                                };
772                                result.push(format!("{}{}", prefix, wl));
773                                rendered_any = true;
774                            }
775                        }
776                    }
777                }
778            }
779
780            if !rendered_any {
781                result.push(bullet_prefix);
782            }
783        }
784
785        result
786    }
787
788    // ── Blockquote ───────────────────────────────────────────────
789
790    fn render_blockquote<'a>(&self, children: &[&'a AstNode<'a>], width: usize) -> Vec<String> {
791        let quote_content_width = width.saturating_sub(2).max(1);
792        let quote_ctx = self.quote_ctx();
793        let quote_style_prefix = get_style_prefix(&|s: &str| (quote_ctx.apply_text)(s));
794        let qborder = self.theme.quote_border.clone();
795
796        let mut inner_lines: Vec<String> = Vec::new();
797        for (i, child) in children.iter().enumerate() {
798            let child_lines = self.render_node_lines(child, quote_content_width, 0);
799            let is_last = i + 1 == children.len();
800            inner_lines.extend(child_lines);
801            if !is_last {
802                let current_val = child.data.borrow();
803                let next_val = children[i + 1].data.borrow();
804                if Self::should_add_block_spacing(&current_val.value, &next_val.value) {
805                    inner_lines.push(String::new());
806                }
807            }
808        }
809
810        // Remove trailing blank lines
811        while inner_lines.last().is_some_and(|l| l.is_empty()) {
812            inner_lines.pop();
813        }
814
815        let mut result: Vec<String> = Vec::new();
816        for line in &inner_lines {
817            let restyled = if !quote_style_prefix.is_empty() {
818                line.replace("\x1b[0m", &format!("\x1b[0m{}", quote_style_prefix))
819            } else {
820                line.clone()
821            };
822            let styled = (quote_ctx.apply_text)(&restyled);
823            let wrapped = wrap_text_with_ansi(&styled, quote_content_width);
824            for wl in wrapped {
825                result.push(format!("{} {}", qborder("│"), wl));
826            }
827        }
828
829        result
830    }
831
832    // ── Table ────────────────────────────────────────────────────
833
834    fn render_table<'a>(
835        &self,
836        _node: &'a AstNode<'a>,
837        tbl: &NodeTable,
838        children: &[&'a AstNode<'a>],
839        width: usize,
840    ) -> Vec<String> {
841        let ctx = self.build_default_ctx();
842        let num_cols = tbl.num_columns;
843        if num_cols == 0 {
844            return Vec::new();
845        }
846
847        let border_overhead = 3 * num_cols + 1;
848        let available_for_cells = width.saturating_sub(border_overhead);
849        if available_for_cells < num_cols {
850            return Vec::new();
851        }
852
853        // Separate rows into header and body
854        let mut header_cells: Vec<Vec<String>> = Vec::new();
855        let mut body_rows: Vec<Vec<Vec<String>>> = Vec::new();
856
857        for child in children {
858            let cv = child.data.borrow();
859            if let NodeValue::TableRow(is_header) = &cv.value {
860                let row_cells: Vec<Vec<String>> = child
861                    .children()
862                    .filter_map(|cell_node| {
863                        let cell_val = cell_node.data.borrow();
864                        if matches!(cell_val.value, NodeValue::TableCell) {
865                            let cell_children: Vec<_> = cell_node.children().collect();
866                            let text = self.render_inline_children(&cell_children, &ctx);
867                            Some(text.split('\n').map(|s| s.to_string()).collect::<Vec<_>>())
868                        } else {
869                            None
870                        }
871                    })
872                    .collect();
873
874                if *is_header {
875                    header_cells = row_cells;
876                } else {
877                    body_rows.push(row_cells);
878                }
879            }
880        }
881
882        if header_cells.is_empty() {
883            return Vec::new();
884        }
885
886        // Calculate column widths (same algorithm as current)
887        let max_unbroken_word_width = 30;
888        let mut natural_widths = vec![0usize; num_cols];
889        let mut min_word_widths = vec![1usize; num_cols];
890
891        let update_widths =
892            |cells: &[Vec<String>], natural: &mut [usize], min_word: &mut [usize]| {
893                for (i, cell_lines) in cells.iter().enumerate() {
894                    if i >= num_cols {
895                        break;
896                    }
897                    for cl in cell_lines {
898                        let vw = visible_width(cl);
899                        natural[i] = natural[i].max(vw);
900                        let longest = cl
901                            .split_whitespace()
902                            .map(visible_width)
903                            .max()
904                            .unwrap_or(0)
905                            .min(max_unbroken_word_width);
906                        min_word[i] = min_word[i].max(longest.max(1));
907                    }
908                }
909            };
910
911        update_widths(&header_cells, &mut natural_widths, &mut min_word_widths);
912        for row_cells in &body_rows {
913            update_widths(row_cells, &mut natural_widths, &mut min_word_widths);
914        }
915
916        let total_natural: usize = natural_widths.iter().sum();
917        let mut column_widths = vec![0usize; num_cols];
918
919        if total_natural + border_overhead <= width {
920            for i in 0..num_cols {
921                column_widths[i] = natural_widths[i].max(min_word_widths[i]);
922            }
923        } else {
924            let min_total: usize = min_word_widths.iter().sum();
925            let extra = available_for_cells.saturating_sub(min_total);
926            let grow_potential: usize = natural_widths
927                .iter()
928                .zip(min_word_widths.iter())
929                .map(|(n, m)| n.saturating_sub(*m))
930                .sum();
931
932            if min_total <= available_for_cells {
933                for i in 0..num_cols {
934                    let n = natural_widths[i];
935                    let m = min_word_widths[i];
936                    let potential = n.saturating_sub(m);
937                    let grow = if grow_potential > 0 {
938                        extra
939                            .checked_mul(potential)
940                            .map(|p| p / grow_potential)
941                            .unwrap_or(0)
942                    } else {
943                        0
944                    };
945                    column_widths[i] = m + grow;
946                }
947                let allocated: usize = column_widths.iter().sum();
948                let mut remaining = available_for_cells.saturating_sub(allocated);
949                for i in 0..num_cols {
950                    if remaining == 0 {
951                        break;
952                    }
953                    if column_widths[i] < natural_widths[i] {
954                        column_widths[i] += 1;
955                        remaining -= 1;
956                    }
957                }
958            } else {
959                let base = available_for_cells / num_cols;
960                let rem = available_for_cells % num_cols;
961                for (i, cw) in column_widths.iter_mut().enumerate() {
962                    *cw = base + if i < rem { 1 } else { 0 };
963                }
964            }
965        }
966
967        // Render
968        let mut result: Vec<String> = Vec::new();
969
970        // Top border
971        let top_cells: Vec<String> = column_widths.iter().map(|w| "─".repeat(*w)).collect();
972        result.push(format!("┌─{}─┐", top_cells.join("─┬─")));
973
974        // Header row
975        let header_lines = self.render_table_row(&header_cells, &column_widths, num_cols, true);
976        result.extend(header_lines);
977
978        // Separator
979        let sep_cells: Vec<String> = column_widths.iter().map(|w| "─".repeat(*w)).collect();
980        result.push(format!("├─{}─┤", sep_cells.join("─┼─")));
981
982        // Body rows
983        for (ri, row_cells) in body_rows.iter().enumerate() {
984            let row_lines = self.render_table_row(row_cells, &column_widths, num_cols, false);
985            result.extend(row_lines);
986            if ri < body_rows.len() - 1 {
987                result.push(format!("├─{}─┤", sep_cells.join("─┼─")));
988            }
989        }
990
991        // Bottom border
992        let bottom_cells: Vec<String> = column_widths.iter().map(|w| "─".repeat(*w)).collect();
993        result.push(format!("└─{}─┘", bottom_cells.join("─┴─")));
994
995        result
996    }
997
998    fn render_table_row(
999        &self,
1000        cells: &[Vec<String>],
1001        column_widths: &[usize],
1002        num_cols: usize,
1003        is_header: bool,
1004    ) -> Vec<String> {
1005        if cells.is_empty() {
1006            return Vec::new();
1007        }
1008
1009        let mut wrapped_cells: Vec<Vec<String>> = Vec::new();
1010        for (i, cell_lines) in cells.iter().enumerate() {
1011            if i >= num_cols {
1012                break;
1013            }
1014            let col_width = column_widths[i];
1015            let mut wrapped: Vec<String> = Vec::new();
1016            for cl in cell_lines {
1017                for wl in wrap_text_with_ansi(cl, col_width) {
1018                    wrapped.push(wl);
1019                }
1020            }
1021            if wrapped.is_empty() {
1022                wrapped.push(String::new());
1023            }
1024            wrapped_cells.push(wrapped);
1025        }
1026
1027        let max_lines = wrapped_cells.iter().map(|c| c.len()).max().unwrap_or(1);
1028        for cell in &mut wrapped_cells {
1029            while cell.len() < max_lines {
1030                cell.push(String::new());
1031            }
1032        }
1033
1034        let mut result: Vec<String> = Vec::new();
1035        for line_idx in 0..max_lines {
1036            let mut row_parts: Vec<String> = Vec::new();
1037            for (col_idx, cell) in wrapped_cells.iter().enumerate() {
1038                let text = cell.get(line_idx).map(|s| s.as_str()).unwrap_or("");
1039                let vw = visible_width(text);
1040                let padding = column_widths[col_idx].saturating_sub(vw);
1041                let padded = if is_header {
1042                    (self.theme.bold)(&format!("{}{}", text, " ".repeat(padding)))
1043                } else {
1044                    format!("{}{}", text, " ".repeat(padding))
1045                };
1046                row_parts.push(padded);
1047            }
1048            result.push(format!("│ {} │", row_parts.join(" │ ")));
1049        }
1050
1051        result
1052    }
1053
1054    // ── Inline Rendering ─────────────────────────────────────────
1055
1056    /// Render inline children into a single styled string.
1057    fn render_inline_children<'a>(&self, children: &[&'a AstNode<'a>], ctx: &InlineCtx) -> String {
1058        let mut result = String::new();
1059
1060        for node in children {
1061            let val = node.data.borrow();
1062            match &val.value {
1063                NodeValue::Text(t) => {
1064                    result.push_str(&split_newline_apply(t, &*ctx.apply_text));
1065                }
1066                NodeValue::Code(c) => {
1067                    result.push_str(&(self.theme.code)(&c.literal));
1068                    result.push_str(&ctx.style_prefix);
1069                }
1070                NodeValue::Emph => {
1071                    let inner =
1072                        self.render_inline_children(&node.children().collect::<Vec<_>>(), ctx);
1073                    result.push_str(&(self.theme.italic)(&inner));
1074                    result.push_str(&ctx.style_prefix);
1075                }
1076                NodeValue::Strong => {
1077                    let inner =
1078                        self.render_inline_children(&node.children().collect::<Vec<_>>(), ctx);
1079                    result.push_str(&(self.theme.bold)(&inner));
1080                    result.push_str(&ctx.style_prefix);
1081                }
1082                NodeValue::Strikethrough => {
1083                    let inner =
1084                        self.render_inline_children(&node.children().collect::<Vec<_>>(), ctx);
1085                    result.push_str(&(self.theme.strikethrough)(&inner));
1086                    result.push_str(&ctx.style_prefix);
1087                }
1088                NodeValue::Link(link) => {
1089                    let inner =
1090                        self.render_inline_children(&node.children().collect::<Vec<_>>(), ctx);
1091                    let styled_link = (self.theme.link)(&(self.theme.underline)(&inner));
1092                    if hyperlinks_supported() {
1093                        result.push_str(&hyperlink(&styled_link, &link.url));
1094                    } else {
1095                        let href_clean = if let Some(mailto) = link.url.strip_prefix("mailto:") {
1096                            mailto
1097                        } else {
1098                            &link.url
1099                        };
1100                        if inner.trim() == href_clean || inner.trim() == link.url {
1101                            result.push_str(&styled_link);
1102                        } else {
1103                            result.push_str(&styled_link);
1104                            result.push_str(&(self.theme.link_url)(&format!(" ({})", link.url)));
1105                        }
1106                    }
1107                    result.push_str(&ctx.style_prefix);
1108                }
1109                NodeValue::Image(_) => {
1110                    // Skip image content
1111                }
1112                NodeValue::SoftBreak | NodeValue::LineBreak => {
1113                    result.push('\n');
1114                }
1115                NodeValue::HtmlInline(h) => {
1116                    result.push_str(&(ctx.apply_text)(h.trim()));
1117                }
1118
1119                _ => {
1120                    // Skip unknown inline nodes
1121                }
1122            }
1123        }
1124
1125        // Trim trailing style prefix
1126        while result.ends_with(&ctx.style_prefix) && !ctx.style_prefix.is_empty() {
1127            result = result[..result.len() - ctx.style_prefix.len()].to_string();
1128        }
1129
1130        result
1131    }
1132}
1133
1134// ── Helper functions ─────────────────────────────────────────────
1135
1136/// Split text by newlines and apply style to each segment.
1137fn split_newline_apply(text: &str, apply: &dyn Fn(&str) -> String) -> String {
1138    let segments: Vec<&str> = text.split('\n').collect();
1139    segments
1140        .iter()
1141        .enumerate()
1142        .map(|(i, s)| {
1143            if i > 0 {
1144                format!("\n{}", apply(s))
1145            } else {
1146                apply(s)
1147            }
1148        })
1149        .collect()
1150}
1151
1152// ── Syntax Highlighting (feature-gated) ─────────────────────────
1153
1154/// Create a syntax highlighting function.
1155pub fn create_highlight_fn() -> Option<HighlightFn> {
1156    #[cfg(feature = "syntect")]
1157    {
1158        Some(Arc::new(highlight_code))
1159    }
1160    #[cfg(not(feature = "syntect"))]
1161    {
1162        None
1163    }
1164}
1165
1166#[cfg(feature = "syntect")]
1167pub fn highlight_code(code: &str, lang: Option<&str>) -> Vec<String> {
1168    use std::sync::LazyLock;
1169
1170    use syntect::{
1171        easy::HighlightLines,
1172        highlighting::ThemeSet,
1173        parsing::SyntaxSet,
1174        util::{LinesWithEndings, as_24_bit_terminal_escaped},
1175    };
1176
1177    static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
1178
1179    static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
1180
1181    let ss = &SYNTAX_SET;
1182    let ts = &THEME_SET;
1183
1184    let syntax = lang
1185        .and_then(|l| ss.find_syntax_by_token(l))
1186        .unwrap_or_else(|| ss.find_syntax_plain_text());
1187
1188    let theme = ts
1189        .themes
1190        .get("base16-ocean.dark")
1191        .or_else(|| ts.themes.iter().next().map(|(_, t)| t));
1192
1193    let Some(theme) = theme else {
1194        return code.split('\n').map(|s| s.to_string()).collect();
1195    };
1196
1197    let mut highlighter = HighlightLines::new(syntax, theme);
1198    let mut result = Vec::new();
1199
1200    for line in LinesWithEndings::from(code) {
1201        match highlighter.highlight_line(line, ss) {
1202            Ok(ranges) => {
1203                let escaped = as_24_bit_terminal_escaped(&ranges, false);
1204                let trimmed = escaped.trim_end_matches('\n');
1205                if trimmed.is_empty() {
1206                    result.push(String::new());
1207                } else {
1208                    result.push(format!("{}\x1b[0m", trimmed));
1209                }
1210            }
1211            Err(_) => {
1212                result.push(line.trim_end_matches('\n').to_string());
1213            }
1214        }
1215    }
1216
1217    result
1218}
1219
1220/// Map a file path to a language identifier for syntax highlighting.
1221pub fn path_to_language(path: &str) -> Option<&'static str> {
1222    let ext = path.rsplit('.').next()?.to_lowercase();
1223    let lang = match ext.as_str() {
1224        "ts" | "tsx" => "typescript",
1225        "js" | "jsx" | "mjs" | "cjs" => "javascript",
1226        "py" => "python",
1227        "rb" => "ruby",
1228        "rs" => "rust",
1229        "go" => "go",
1230        "java" => "java",
1231        "kt" => "kotlin",
1232        "swift" => "swift",
1233        "c" | "h" => "c",
1234        "cpp" | "cc" | "cxx" | "hpp" => "cpp",
1235        "cs" => "csharp",
1236        "php" => "php",
1237        "sh" | "bash" | "zsh" => "bash",
1238        "ps1" => "powershell",
1239        "sql" => "sql",
1240        "html" | "htm" => "html",
1241        "css" | "scss" | "sass" | "less" => "css",
1242        "json" => "json",
1243        "yaml" | "yml" => "yaml",
1244        "toml" => "toml",
1245        "xml" => "xml",
1246        "md" | "markdown" => "markdown",
1247        "clj" | "cljs" | "cljc" => "clojure",
1248        "ex" | "exs" => "elixir",
1249        "hs" => "haskell",
1250        "lua" => "lua",
1251        _ => return None,
1252    };
1253    Some(lang)
1254}
1255
1256// ── Tests ────────────────────────────────────────────────────────
1257
1258#[cfg(test)]
1259mod tests {
1260    use super::*;
1261
1262    fn test_theme() -> MarkdownTheme {
1263        MarkdownTheme::new(
1264            Arc::new(|s| format!("\x1b[33m{}\x1b[39m", s)),
1265            Arc::new(|s| format!("\x1b[34m{}\x1b[39m", s)),
1266            Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)),
1267            Arc::new(|s| format!("\x1b[36m{}\x1b[39m", s)),
1268            Arc::new(|s| format!("\x1b[32m{}\x1b[39m", s)),
1269            Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)),
1270            Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)),
1271            Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)),
1272            Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)),
1273            Arc::new(|s| format!("\x1b[33m{}\x1b[39m", s)),
1274            Arc::new(|s| format!("\x1b[1m{}\x1b[22m", s)),
1275            Arc::new(|s| format!("\x1b[3m{}\x1b[23m", s)),
1276            Arc::new(|s| format!("\x1b[9m{}\x1b[29m", s)),
1277            Arc::new(|s| format!("\x1b[4m{}\x1b[24m", s)),
1278        )
1279    }
1280
1281    #[test]
1282    fn test_basic_paragraph() {
1283        let theme = test_theme();
1284        let mut md = Markdown::new("hello world", 0, 0, theme, None);
1285        let lines = md.render(80);
1286        let all = lines.join("\n");
1287        assert!(all.contains("hello world"));
1288        assert!(!all.contains("\x1b["));
1289    }
1290
1291    #[test]
1292    fn test_heading_h1() {
1293        let theme = test_theme();
1294        let mut md = Markdown::new("# Heading 1", 0, 0, theme, None);
1295        let lines = md.render(80);
1296        let all = lines.join("\n");
1297        assert!(all.contains("Heading 1"));
1298        assert!(all.contains("\x1b[1m"));
1299        assert!(all.contains("\x1b[33m"));
1300    }
1301
1302    #[test]
1303    fn test_heading_h3_marker() {
1304        let theme = test_theme();
1305        let mut md = Markdown::new("### Heading 3", 0, 0, theme, None);
1306        let lines = md.render(80);
1307        let all = lines.join("\n");
1308        assert!(all.contains("###") || all.contains("Heading 3"));
1309    }
1310
1311    #[test]
1312    fn test_bold_italic() {
1313        let theme = test_theme();
1314        let mut md = Markdown::new("**bold** and *italic*", 0, 0, theme, None);
1315        let lines = md.render(80);
1316        let all = lines.join("\n");
1317        assert!(all.contains("bold"));
1318        assert!(all.contains("italic"));
1319        assert!(all.contains("\x1b[1m"));
1320        assert!(all.contains("\x1b[3m"));
1321    }
1322
1323    #[test]
1324    fn test_codespan() {
1325        let theme = test_theme();
1326        let mut md = Markdown::new("use `code` here", 0, 0, theme, None);
1327        let lines = md.render(80);
1328        let all = lines.join("\n");
1329        assert!(all.contains("code"));
1330        assert!(all.contains("\x1b[36m"));
1331    }
1332
1333    #[test]
1334    fn test_inline_code_style_restore() {
1335        let theme = test_theme();
1336        let mut md = Markdown::new("**bold `code` end**", 0, 0, theme, None);
1337        let lines = md.render(80);
1338        let all = lines.join("\n");
1339        assert!(all.contains("bold"));
1340        assert!(all.contains("code"));
1341        assert!(all.contains("end"));
1342    }
1343
1344    #[test]
1345    fn test_code_block() {
1346        let theme = test_theme();
1347        let mut md = Markdown::new("```\nlet x = 1;\n```", 0, 0, theme, None);
1348        let lines = md.render(80);
1349        let all = lines.join("\n");
1350        assert!(all.contains("let x = 1;"));
1351        assert!(all.contains("\x1b[32m"));
1352        assert!(all.contains("```"));
1353    }
1354
1355    #[test]
1356    fn test_fenced_code_with_language() {
1357        let theme = test_theme();
1358        let mut md = Markdown::new("```rust\nfn main() {}\n```", 0, 0, theme, None);
1359        let lines = md.render(80);
1360        let all = lines.join("\n");
1361        assert!(all.contains("```rust"));
1362        assert!(all.contains("fn main() {}"));
1363    }
1364
1365    #[test]
1366    fn test_unordered_list() {
1367        let theme = test_theme();
1368        let mut md = Markdown::new("- item 1\n- item 2\n- item 3", 0, 0, theme, None);
1369        let lines = md.render(80);
1370        let all = lines.join("\n");
1371        assert!(all.contains("item 1"));
1372        assert!(all.contains("item 2"));
1373        assert!(all.contains("item 3"));
1374    }
1375
1376    #[test]
1377    fn test_strikethrough() {
1378        let theme = test_theme();
1379        let mut md = Markdown::new("~~struck~~", 0, 0, theme, None);
1380        let lines = md.render(80);
1381        let all = lines.join("\n");
1382        assert!(all.contains("struck"));
1383        assert!(all.contains("\x1b[9m"));
1384    }
1385
1386    #[test]
1387    fn test_link_inline() {
1388        let theme = test_theme();
1389        let mut md = Markdown::new("[text](https://example.com)", 0, 0, theme, None);
1390        let lines = md.render(80);
1391        let all = lines.join("\n");
1392        assert!(all.contains("text"));
1393        assert!(all.contains("https://example.com"));
1394    }
1395
1396    #[test]
1397    fn test_empty_text() {
1398        let theme = test_theme();
1399        let mut md = Markdown::new("", 0, 0, theme, None);
1400        let lines = md.render(80);
1401        assert!(lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()));
1402    }
1403
1404    #[test]
1405    fn test_whitespace_only() {
1406        let theme = test_theme();
1407        let mut md = Markdown::new("   ", 0, 0, theme, None);
1408        let lines = md.render(80);
1409        assert!(lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()));
1410    }
1411
1412    #[test]
1413    fn test_horizontal_rule() {
1414        let theme = test_theme();
1415        let mut md = Markdown::new("---", 0, 0, theme, None);
1416        let lines = md.render(80);
1417        let all = lines.join("\n");
1418        assert!(all.contains('─'));
1419    }
1420
1421    #[test]
1422    fn test_padding_x() {
1423        let theme = test_theme();
1424        let mut md = Markdown::new("hello", 2, 0, theme, None);
1425        let lines = md.render(20);
1426        assert_eq!(visible_width(&lines[0]), 20);
1427        assert!(lines[0].starts_with("  "));
1428    }
1429
1430    #[test]
1431    fn test_padding_y() {
1432        let theme = test_theme();
1433        let mut md = Markdown::new("hello", 0, 1, theme, None);
1434        let lines = md.render(20);
1435        assert_eq!(lines.len(), 3);
1436    }
1437
1438    #[test]
1439    fn test_cache_hit() {
1440        let theme = test_theme();
1441        let mut md = Markdown::new("hello", 1, 0, theme, None);
1442        let a = md.render(20);
1443        let b = md.render(20);
1444        assert_eq!(a, b);
1445    }
1446
1447    #[test]
1448    fn test_cache_invalidation() {
1449        let theme = test_theme();
1450        let mut md = Markdown::new("hello", 1, 0, theme, None);
1451        let a = md.render(20);
1452        md.set_text("world");
1453        let b = md.render(20);
1454        assert_ne!(a, b);
1455    }
1456
1457    #[test]
1458    fn test_blockquote() {
1459        let theme = test_theme();
1460        let mut md = Markdown::new("> quoted text", 0, 0, theme, None);
1461        let lines = md.render(80);
1462        let all = lines.join("\n");
1463        assert!(all.contains("quoted text"));
1464        assert!(all.contains("│"));
1465    }
1466
1467    #[test]
1468    fn test_task_list() {
1469        let theme = test_theme();
1470        let mut md = Markdown::new("- [x] done\n- [ ] todo", 0, 0, theme, None);
1471        let lines = md.render(80);
1472        let all = lines.join("\n");
1473        assert!(all.contains("[x]") || all.contains("done"));
1474        assert!(all.contains("[ ]") || all.contains("todo"));
1475    }
1476
1477    #[test]
1478    fn test_paragraph_spacing() {
1479        let theme = test_theme();
1480        let mut md = Markdown::new("para one\n\npara two", 0, 0, theme, None);
1481        let lines = md.render(80);
1482        // Two paragraphs should produce three lines: para one, blank, para two
1483        assert!(lines.len() >= 2, "should have at least 2 lines");
1484        // Lines are padded to width; check trimmed versions
1485        assert!(
1486            lines[0].trim_end().ends_with("para one"),
1487            "first line should contain 'para one', got {:?}",
1488            lines[0]
1489        );
1490        assert!(
1491            lines[1].trim().is_empty(),
1492            "line between paragraphs should be empty, got {:?}",
1493            lines[1]
1494        );
1495        assert!(
1496            lines[2].trim_end().ends_with("para two"),
1497            "third line should contain 'para two', got {:?}",
1498            lines[2]
1499        );
1500    }
1501
1502    #[test]
1503    fn test_tabs_replaced() {
1504        let theme = test_theme();
1505        let mut md = Markdown::new("\tindented", 0, 0, theme, None);
1506        let lines = md.render(80);
1507        let all = lines.join("\n");
1508        assert!(all.contains("indented"));
1509    }
1510
1511    #[test]
1512    fn test_default_text_style() {
1513        let theme = test_theme();
1514        let default_style = DefaultTextStyle {
1515            color: Some(Arc::new(|s| format!("\x1b[33m{}\x1b[39m", s))),
1516            bold: true,
1517            italic: false,
1518            strikethrough: false,
1519            underline: false,
1520        };
1521        let mut md = Markdown::new("styled text", 0, 0, theme, Some(default_style));
1522        let lines = md.render(80);
1523        let all = lines.join("\n");
1524        assert!(all.contains("styled text"));
1525        assert!(all.contains("\x1b[1m"));
1526        assert!(all.contains("\x1b[33m"));
1527    }
1528
1529    #[test]
1530    fn test_table_basic() {
1531        let theme = test_theme();
1532        let mut md = Markdown::new(
1533            "| H1 | H2 |\n| --- | --- |\n| A1 | B1 |\n| A2 | B2 |",
1534            0,
1535            0,
1536            theme,
1537            None,
1538        );
1539        let lines = md.render(80);
1540        let all = lines.join("\n");
1541        assert!(all.contains("H1"));
1542        assert!(all.contains("H2"));
1543        assert!(all.contains("A1"));
1544        assert!(all.contains("┌"));
1545        assert!(all.contains("└"));
1546        assert!(all.contains("│"));
1547    }
1548
1549    #[test]
1550    fn test_table_narrow_fallback() {
1551        let theme = test_theme();
1552        let mut md = Markdown::new("| A | B |\n| --- | --- |\n| 1 | 2 |", 0, 0, theme, None);
1553        let lines = md.render(10);
1554        assert!(!lines.is_empty());
1555    }
1556
1557    #[test]
1558    fn test_ordered_list() {
1559        let theme = test_theme();
1560        let mut md = Markdown::new("1. first\n2. second\n3. third", 0, 0, theme, None);
1561        let lines = md.render(80);
1562        let all = lines.join("\n");
1563        assert!(all.contains("first"));
1564        assert!(all.contains("second"));
1565        assert!(all.contains("third"));
1566    }
1567
1568    #[test]
1569    fn test_nested_list() {
1570        let theme = test_theme();
1571        let mut md = Markdown::new("- outer\n  - inner\n- more", 0, 0, theme, None);
1572        let lines = md.render(80);
1573        let all = lines.join("\n");
1574        assert!(all.contains("outer"));
1575        assert!(all.contains("inner"));
1576        assert!(all.contains("more"));
1577    }
1578
1579    #[test]
1580    fn test_blockquote_nested() {
1581        let theme = test_theme();
1582        let mut md = Markdown::new("> outer\n> > nested\n> back", 0, 0, theme, None);
1583        let lines = md.render(80);
1584        let all = lines.join("\n");
1585        assert!(all.contains("outer"));
1586        assert!(all.contains("nested"));
1587        assert!(all.contains("back"));
1588        assert!(all.contains("│"));
1589    }
1590
1591    #[test]
1592    fn test_link_with_dest() {
1593        let theme = test_theme();
1594        let mut md = Markdown::new("[example](https://example.com/page)", 0, 0, theme, None);
1595        let lines = md.render(80);
1596        let all = lines.join("\n");
1597        assert!(all.contains("example"));
1598        assert!(all.contains("example.com/page"));
1599    }
1600
1601    #[test]
1602    fn test_autolink() {
1603        let theme = test_theme();
1604        let mut md = Markdown::new("<https://example.com>", 0, 0, theme, None);
1605        let lines = md.render(80);
1606        let all = lines.join("\n");
1607        assert!(all.contains("example.com"));
1608    }
1609
1610    #[test]
1611    fn test_wrap_long_text() {
1612        let theme = test_theme();
1613        let long = "this is a very long line that should definitely wrap to multiple lines when rendered in a narrow terminal column";
1614        let mut md = Markdown::new(long, 0, 0, theme, None);
1615        let lines = md.render(30);
1616        assert!(lines.len() > 1);
1617        for line in &lines {
1618            assert!(visible_width(line) <= 30);
1619        }
1620    }
1621
1622    #[test]
1623    fn test_cache_different_width() {
1624        let theme = test_theme();
1625        let mut md = Markdown::new("hello world", 1, 0, theme, None);
1626        let a = md.render(30);
1627        let b = md.render(50);
1628        assert_ne!(a, b);
1629    }
1630
1631    #[test]
1632    fn test_html_block_plain() {
1633        let theme = test_theme();
1634        let mut md = Markdown::new("<div>plain html</div>", 0, 0, theme, None);
1635        let lines = md.render(80);
1636        let all = lines.join("\n");
1637        assert!(all.contains("plain html"));
1638    }
1639
1640    #[test]
1641    fn test_bold_italic_style_restore() {
1642        let theme = test_theme();
1643        let mut md = Markdown::new("**bold `code` more bold**", 0, 0, theme, None);
1644        let lines = md.render(80);
1645        let all = lines.join("\n");
1646        assert!(all.contains("bold"));
1647        assert!(all.contains("code"));
1648        assert!(all.contains("more"));
1649    }
1650
1651    // ── Heading-in-list float test ───────────────────────────────
1652
1653    #[test]
1654    fn test_heading_inside_list_is_floated() {
1655        let theme = test_theme();
1656        let md_text = "- item\n  ### heading\n  - nested\n- more";
1657        let mut md = Markdown::new(md_text, 0, 0, theme, None);
1658        let lines = md.render(80);
1659        let all = lines.join("\n");
1660        // heading should NOT be indented with list prefix — it was floated
1661        // Check it appears near the left margin, not 4+ spaces in
1662        assert!(all.contains("heading"), "Should contain heading text");
1663        // The nested list item should still be indented
1664        assert!(all.contains("nested"), "Should contain nested item");
1665        assert!(all.contains("more"), "Should contain more item");
1666    }
1667
1668    // ── Code block inside list float test ────────────────────────
1669
1670    #[test]
1671    fn test_code_block_inside_list_is_floated() {
1672        let theme = test_theme();
1673        let md_text = "- item\n  ```python\n  print('hi')\n  ```\n- more";
1674        let mut md = Markdown::new(md_text, 0, 0, theme, None);
1675        let lines = md.render(80);
1676        let all = lines.join("\n");
1677        assert!(all.contains("print('hi')"), "Should contain code content");
1678        assert!(all.contains("```"), "Should have fence markers");
1679        assert!(all.contains("item"), "Should contain item text");
1680        assert!(all.contains("more"), "Should contain more item");
1681    }
1682}