Skip to main content

rab/tui/components/
markdown.rs

1#![allow(clippy::type_complexity, clippy::arc_with_non_send_sync)]
2
3use std::cell::RefCell;
4use std::sync::Arc;
5
6use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
7
8use crate::tui::Component;
9use crate::tui::util::{apply_background_to_line, visible_width, wrap_text_with_ansi};
10
11/// Type alias for markdown theme styling functions.
12pub type StyleFn = Arc<dyn Fn(&str) -> String>;
13/// Type alias for code highlighting function.
14pub type HighlightFn = Arc<dyn Fn(&str, Option<&str>) -> Vec<String>>;
15
16// ── MarkdownTheme ────────────────────────────────────────────────
17
18/// Theme functions for markdown elements.
19/// Each function takes text and returns styled text with ANSI codes.
20pub struct MarkdownTheme {
21    pub heading: StyleFn,
22    pub link: StyleFn,
23    pub link_url: StyleFn,
24    pub code: StyleFn,
25    pub code_block: StyleFn,
26    pub code_block_border: StyleFn,
27    pub quote: StyleFn,
28    pub quote_border: StyleFn,
29    pub hr: StyleFn,
30    pub list_bullet: StyleFn,
31    pub bold: StyleFn,
32    pub italic: StyleFn,
33    pub strikethrough: StyleFn,
34    pub underline: StyleFn,
35    /// If set, used for syntax-highlighted code blocks.
36    pub highlight_code: Option<HighlightFn>,
37    /// Prefix applied to each rendered code block line (default: `"  "`).
38    pub code_block_indent: String,
39}
40
41impl MarkdownTheme {
42    #[allow(clippy::too_many_arguments)]
43    pub fn new(
44        heading: StyleFn,
45        link: StyleFn,
46        link_url: StyleFn,
47        code: StyleFn,
48        code_block: StyleFn,
49        code_block_border: StyleFn,
50        quote: StyleFn,
51        quote_border: StyleFn,
52        hr: StyleFn,
53        list_bullet: StyleFn,
54        bold: StyleFn,
55        italic: StyleFn,
56        strikethrough: StyleFn,
57        underline: StyleFn,
58    ) -> Self {
59        Self {
60            heading,
61            link,
62            link_url,
63            code,
64            code_block,
65            code_block_border,
66            quote,
67            quote_border,
68            hr,
69            list_bullet,
70            bold,
71            italic,
72            strikethrough,
73            underline,
74            highlight_code: None,
75            code_block_indent: "  ".to_string(),
76        }
77    }
78}
79
80// ── DefaultTextStyle ─────────────────────────────────────────────
81
82/// Default text styling for markdown content.
83/// Applied to all text unless overridden by markdown formatting.
84pub struct DefaultTextStyle {
85    /// Optional foreground color function.
86    pub color: Option<StyleFn>,
87    /// Optional background color function (applied at the padding stage).
88    pub bg_color: Option<StyleFn>,
89    pub bold: bool,
90    pub italic: bool,
91    pub strikethrough: bool,
92    pub underline: bool,
93}
94
95// ── MarkdownOptions ──────────────────────────────────────────────
96
97#[derive(Clone, Default)]
98pub struct MarkdownOptions {
99    /// Preserve source list markers instead of normalizing them.
100    pub preserve_ordered_list_markers: bool,
101}
102
103// ── Internal helpers ─────────────────────────────────────────────
104
105/// Context for inline rendering, carrying the parent-style functions
106/// and the ANSI prefix to restore after inline resets.
107struct InlineCtx {
108    /// Apply the current text style (color + decorations).
109    apply_text: Arc<dyn Fn(&str) -> String>,
110    /// ANSI prefix to emit after closing an inline element,
111    /// restoring this context's styling.
112    style_prefix: String,
113}
114
115impl InlineCtx {
116    fn new(apply_text: Arc<dyn Fn(&str) -> String>) -> Self {
117        let prefix = get_style_prefix(&*apply_text);
118        Self {
119            apply_text,
120            style_prefix: prefix,
121        }
122    }
123}
124
125/// Extract the ANSI prefix from a style function.
126/// Uses a sentinel character (`\0`) to find where text starts.
127fn get_style_prefix(style_fn: &dyn Fn(&str) -> String) -> String {
128    const SENTINEL: char = '\0';
129    let styled = style_fn(&SENTINEL.to_string());
130    styled
131        .find(SENTINEL)
132        .map(|i| styled[..i].to_string())
133        .unwrap_or_default()
134}
135
136/// Check whether hyperlinks (OSC 8) are supported.
137/// Detects common terminal emulators that support the feature.
138fn hyperlinks_supported() -> bool {
139    // Check well-known terminal programs
140    if let Ok(prog) = std::env::var("TERM_PROGRAM")
141        && (prog == "iTerm.app" || prog == "kitty" || prog == "WezTerm" || prog == "vscode")
142    {
143        return true;
144    }
145    // Check TERM env for kitty
146    if let Ok(term) = std::env::var("TERM")
147        && term.contains("kitty")
148    {
149        return true;
150    }
151    // Windows Terminal supports OSC 8
152    #[cfg(windows)]
153    {
154        if let Ok(prog) = std::env::var("WT_SESSION") {
155            let _ = prog;
156            return true;
157        }
158    }
159    false
160}
161
162/// Wrap text in an OSC 8 hyperlink.
163/// Format: `\x1b]8;params;url\x07text\x1b]8;;\x07`
164fn hyperlink(text: &str, url: &str) -> String {
165    format!("\x1b]8;;{}\x07{}\x1b]8;;\x07", url, text)
166}
167
168// ── Markdown Component ───────────────────────────────────────────
169
170/// Markdown rendering component.
171///
172/// Parses markdown text with pulldown-cmark and renders styled ANSI output.
173/// Two-phase: (1) render tokens → styled ANSI lines, (2) wrap + pad + bg.
174pub struct Markdown {
175    text: String,
176    padding_x: usize,
177    padding_y: usize,
178    theme: MarkdownTheme,
179    default_text_style: Option<DefaultTextStyle>,
180    #[allow(dead_code)]
181    options: MarkdownOptions,
182
183    // Cache
184    cached_text: RefCell<Option<String>>,
185    cached_width: RefCell<Option<usize>>,
186    cached_lines: RefCell<Vec<String>>,
187}
188
189impl Markdown {
190    #[allow(clippy::too_many_arguments)]
191    pub fn new(
192        text: impl Into<String>,
193        padding_x: usize,
194        padding_y: usize,
195        theme: MarkdownTheme,
196        default_text_style: Option<DefaultTextStyle>,
197        options: Option<MarkdownOptions>,
198    ) -> Self {
199        Self {
200            text: text.into(),
201            padding_x,
202            padding_y,
203            theme,
204            default_text_style,
205            options: options.unwrap_or_default(),
206            cached_text: RefCell::new(None),
207            cached_width: RefCell::new(None),
208            cached_lines: RefCell::new(Vec::new()),
209        }
210    }
211
212    pub fn set_text(&mut self, text: impl Into<String>) {
213        self.text = text.into();
214        self.invalidate();
215    }
216
217    fn build_default_ctx(&self) -> InlineCtx {
218        InlineCtx::new(self.build_default_apply_fn())
219    }
220
221    /// Build the default `apply_text` closure from `DefaultTextStyle`.
222    fn build_default_apply_fn(&self) -> Arc<dyn Fn(&str) -> String> {
223        let style = &self.default_text_style;
224        let theme = &self.theme;
225
226        // Capture what we need as Arcs to satisfy the closure lifetime
227        let color: Option<StyleFn> = style.as_ref().and_then(|s| s.color.clone());
228        let bold = style.as_ref().map(|s| s.bold).unwrap_or(false);
229        let italic = style.as_ref().map(|s| s.italic).unwrap_or(false);
230        let strikethrough = style.as_ref().map(|s| s.strikethrough).unwrap_or(false);
231        let underline = style.as_ref().map(|s| s.underline).unwrap_or(false);
232        let theme_bold = theme.bold.clone();
233        let theme_italic = theme.italic.clone();
234        let theme_strikethrough = theme.strikethrough.clone();
235        let theme_underline = theme.underline.clone();
236
237        Arc::new(move |text: &str| {
238            let mut styled = text.to_string();
239            if let Some(ref color_fn) = color {
240                styled = color_fn(&styled);
241            }
242            if bold {
243                styled = theme_bold(&styled);
244            }
245            if italic {
246                styled = theme_italic(&styled);
247            }
248            if strikethrough {
249                styled = theme_strikethrough(&styled);
250            }
251            if underline {
252                styled = theme_underline(&styled);
253            }
254            styled
255        })
256    }
257
258    /// Build the style context for a heading at the given level.
259    fn heading_ctx(&self, level: HeadingLevel) -> InlineCtx {
260        let theme_heading = self.theme.heading.clone();
261        let theme_bold = self.theme.bold.clone();
262        let theme_underline = self.theme.underline.clone();
263
264        let style_fn: Arc<dyn Fn(&str) -> String> = match level {
265            HeadingLevel::H1 => {
266                Arc::new(move |text: &str| theme_heading(&theme_bold(&theme_underline(text))))
267            }
268            _ => Arc::new(move |text: &str| theme_heading(&theme_bold(text))),
269        };
270        InlineCtx::new(style_fn)
271    }
272
273    /// Build the default inline style context for blockquote content.
274    fn quote_ctx(&self) -> InlineCtx {
275        let theme_quote = self.theme.quote.clone();
276        let theme_italic = self.theme.italic.clone();
277
278        let style_fn: Arc<dyn Fn(&str) -> String> =
279            Arc::new(move |text: &str| theme_quote(&theme_italic(text)));
280        InlineCtx::new(style_fn)
281    }
282}
283
284impl Component for Markdown {
285    fn render(&self, width: usize) -> Vec<String> {
286        // Check cache
287        if self.cached_text.borrow().as_deref() == Some(&self.text)
288            && *self.cached_width.borrow() == Some(width)
289        {
290            return self.cached_lines.borrow().clone();
291        }
292
293        // Don't render anything if there's no actual text
294        if self.text.is_empty() || self.text.trim().is_empty() {
295            let result: Vec<String> = Vec::new();
296            *self.cached_text.borrow_mut() = Some(self.text.clone());
297            *self.cached_width.borrow_mut() = Some(width);
298            *self.cached_lines.borrow_mut() = result.clone();
299            return result;
300        }
301
302        // Calculate available width for content
303        let content_width = width.saturating_sub(2 * self.padding_x).max(1);
304
305        // Replace tabs with 3 spaces
306        let normalized = self.text.replace('\t', "   ");
307
308        // Parse with pulldown-cmark
309        let md_options = Options::ENABLE_STRIKETHROUGH
310            | Options::ENABLE_TABLES
311            | Options::ENABLE_TASKLISTS
312            | Options::ENABLE_HEADING_ATTRIBUTES
313            | Options::ENABLE_GFM;
314        let parser = Parser::new_ext(&normalized, md_options);
315        let events: Vec<Event> = parser.collect();
316
317        // Render document to styled ANSI lines (Phase 1)
318        let rendered = self.render_document(&events, content_width);
319
320        // Wrap lines
321        let mut wrapped: Vec<String> = Vec::new();
322        for line in &rendered {
323            for wl in wrap_text_with_ansi(line, content_width) {
324                wrapped.push(wl);
325            }
326        }
327
328        // Add padding and background
329        let left_margin = " ".repeat(self.padding_x);
330        let right_margin = " ".repeat(self.padding_x);
331        let bg_fn = self
332            .default_text_style
333            .as_ref()
334            .and_then(|s| s.bg_color.clone());
335
336        let mut content_lines: Vec<String> = Vec::new();
337        for line in &wrapped {
338            let line_with_margins = format!("{}{}{}", left_margin, line, right_margin);
339            if let Some(ref bg) = bg_fn {
340                content_lines.push(apply_background_to_line(
341                    &line_with_margins,
342                    width,
343                    bg.as_ref(),
344                ));
345            } else {
346                let visible = visible_width(&line_with_margins);
347                if visible < width {
348                    content_lines.push(format!(
349                        "{}{}",
350                        line_with_margins,
351                        " ".repeat(width - visible)
352                    ));
353                } else {
354                    content_lines.push(line_with_margins);
355                }
356            }
357        }
358
359        let empty_line = " ".repeat(width);
360        let empty_bg = bg_fn
361            .as_ref()
362            .map(|bg| bg(&empty_line))
363            .unwrap_or_else(|| empty_line.clone());
364
365        let mut result = Vec::new();
366        for _ in 0..self.padding_y {
367            result.push(empty_bg.clone());
368        }
369        result.extend(content_lines);
370        for _ in 0..self.padding_y {
371            result.push(empty_bg.clone());
372        }
373
374        // Update cache
375        *self.cached_text.borrow_mut() = Some(self.text.clone());
376        *self.cached_width.borrow_mut() = Some(width);
377        *self.cached_lines.borrow_mut() = result.clone();
378
379        if result.is_empty() {
380            vec![String::new()]
381        } else {
382            result
383        }
384    }
385
386    fn invalidate(&mut self) {
387        *self.cached_text.borrow_mut() = None;
388        *self.cached_width.borrow_mut() = None;
389        self.cached_lines.borrow_mut().clear();
390    }
391}
392
393// ── Document / Block Rendering ──────────────────────────────────
394
395impl Markdown {
396    /// Render the full event stream into styled ANSI lines.
397    fn render_document(&self, events: &[Event], width: usize) -> Vec<String> {
398        let mut lines: Vec<String> = Vec::new();
399        let mut pos = 0;
400
401        while pos < events.len() {
402            match &events[pos] {
403                Event::Start(tag) => {
404                    pos += 1;
405                    let block_lines = self.render_block(events, &mut pos, tag, width, false, 0);
406                    if !block_lines.is_empty() {
407                        lines.extend(block_lines);
408                    }
409                }
410                Event::End(_) => {
411                    pos += 1;
412                }
413                Event::Rule => {
414                    pos += 1;
415                    lines.push((self.theme.hr)(&"─".repeat(width.min(80))));
416                    // Check next event for spacing
417                    if pos < events.len() && !matches!(events[pos], Event::Start(Tag::Paragraph)) {
418                        lines.push(String::new());
419                    }
420                }
421                Event::SoftBreak | Event::HardBreak => {
422                    pos += 1;
423                }
424                Event::Text(text) => {
425                    pos += 1;
426                    let ctx = self.build_default_ctx();
427                    lines.push((ctx.apply_text)(text));
428                }
429                _ => {
430                    pos += 1;
431                }
432            }
433        }
434
435        lines
436    }
437
438    /// Render a single block element, consuming events until the matching `End`.
439    /// `inside_quote` indicates whether we're inside a blockquote (affects spacing).
440    /// `list_depth` tracks nesting depth for list indentation.
441    fn render_block(
442        &self,
443        events: &[Event],
444        pos: &mut usize,
445        tag: &Tag,
446        width: usize,
447        inside_quote: bool,
448        list_depth: usize,
449    ) -> Vec<String> {
450        match tag {
451            Tag::Paragraph => {
452                let content =
453                    self.render_inline(events, pos, TagEnd::Paragraph, &self.build_default_ctx());
454                let mut lines = Vec::new();
455                if !content.is_empty() {
456                    lines.push(content);
457                }
458                // Add spacing after paragraph if next event isn't a list or space-like
459                if *pos < events.len() {
460                    let next_is_list = matches!(
461                        &events[*pos],
462                        Event::Start(Tag::List(_)) | Event::End(TagEnd::List(_))
463                    );
464                    if !next_is_list {
465                        lines.push(String::new());
466                    }
467                }
468                lines
469            }
470
471            Tag::Heading { level, .. } => {
472                let ctx = self.heading_ctx(*level);
473                let mut content = self.render_inline(events, pos, TagEnd::Heading(*level), &ctx);
474
475                // For h3+, add the heading prefix marker
476                if *level >= HeadingLevel::H3 {
477                    let prefix_marker = format!("{} ", "#".repeat(level_to_usize(*level)));
478                    content = format!("{}{}", (ctx.apply_text)(&prefix_marker), content);
479                }
480
481                let mut lines = vec![content];
482                // Add spacing if next event isn't a space
483                if *pos < events.len() {
484                    let next_is_para_or_space = matches!(
485                        &events[*pos],
486                        Event::Start(Tag::Paragraph)
487                            | Event::Start(Tag::List(_))
488                            | Event::End(TagEnd::List(_))
489                            | Event::End(TagEnd::BlockQuote(None))
490                    );
491                    if !next_is_para_or_space && !inside_quote {
492                        lines.push(String::new());
493                    }
494                }
495                lines
496            }
497
498            Tag::BlockQuote(kind) => {
499                // Blockquotes contain block-level tokens
500                let quote_content_width = width.saturating_sub(2).max(1); // "│ " = 2 chars
501                let quote_ctx = self.quote_ctx();
502
503                let mut inner_lines: Vec<String> = Vec::new();
504                loop {
505                    if *pos >= events.len() {
506                        break;
507                    }
508                    match &events[*pos] {
509                        Event::End(TagEnd::BlockQuote(k)) if *k == *kind => {
510                            *pos += 1;
511                            break;
512                        }
513                        Event::Start(inner_tag) => {
514                            *pos += 1;
515                            let block_lines = self.render_block(
516                                events,
517                                pos,
518                                inner_tag,
519                                quote_content_width,
520                                true,
521                                0,
522                            );
523                            inner_lines.extend(block_lines);
524                        }
525                        Event::End(_) => {
526                            *pos += 1;
527                        }
528                        _ => {
529                            // Flush remaining (text directly in blockquote)
530                            let text = self.render_inline(
531                                events,
532                                pos,
533                                TagEnd::BlockQuote(*kind),
534                                &quote_ctx,
535                            );
536                            if !text.is_empty() {
537                                inner_lines.push(text);
538                            }
539                        }
540                    }
541                }
542
543                // Remove trailing blank lines from inner content
544                while inner_lines.last().is_some_and(|l| l.is_empty()) {
545                    inner_lines.pop();
546                }
547
548                // Apply the quote style to each line and add "│ " prefix
549                let quote_style_prefix = get_style_prefix(&|s: &str| (quote_ctx.apply_text)(s));
550                let qborder = self.theme.quote_border.clone();
551
552                let mut result: Vec<String> = Vec::new();
553                for line in &inner_lines {
554                    let restyled = if !quote_style_prefix.is_empty() {
555                        line.replace("\x1b[0m", &format!("\x1b[0m{}", quote_style_prefix))
556                    } else {
557                        line.clone()
558                    };
559                    let styled = (quote_ctx.apply_text)(&restyled);
560                    let wrapped = wrap_text_with_ansi(&styled, quote_content_width);
561                    for wl in wrapped {
562                        result.push(format!("{} {}", qborder("│"), wl));
563                    }
564                }
565
566                // Add spacing after blockquote
567                if *pos < events.len() && !inside_quote {
568                    let next_is_space_or_end = matches!(
569                        &events[*pos],
570                        Event::End(_) | Event::SoftBreak | Event::HardBreak
571                    );
572                    if !next_is_space_or_end {
573                        result.push(String::new());
574                    }
575                }
576                result
577            }
578
579            Tag::CodeBlock(kind) => {
580                let info = match kind {
581                    CodeBlockKind::Fenced(info) => {
582                        if info.is_empty() {
583                            None
584                        } else {
585                            Some(info.as_ref())
586                        }
587                    }
588                    CodeBlockKind::Indented => None,
589                };
590
591                // Collect code content until End(CodeBlock)
592                let mut code_text = String::new();
593                loop {
594                    if *pos >= events.len() {
595                        break;
596                    }
597                    match &events[*pos] {
598                        Event::End(TagEnd::CodeBlock) => {
599                            *pos += 1;
600                            break;
601                        }
602                        Event::Text(t) => {
603                            code_text.push_str(t);
604                            *pos += 1;
605                        }
606                        Event::SoftBreak | Event::HardBreak => {
607                            code_text.push('\n');
608                            *pos += 1;
609                        }
610                        _ => {
611                            *pos += 1;
612                        }
613                    }
614                }
615
616                let indent = &self.theme.code_block_indent;
617                let border = self.theme.code_block_border.clone();
618                let code_fn = self.theme.code_block.clone();
619
620                // Show language in opening fence
621                let lang_label = info.unwrap_or("");
622                let mut lines = vec![border(&format!("```{}", lang_label))];
623
624                // Syntax highlighting or plain
625                if let Some(ref highlight) = self.theme.highlight_code {
626                    let hl_lines = highlight(&code_text, info);
627                    for hl in hl_lines {
628                        lines.push(format!("{}{}", indent, hl));
629                    }
630                } else {
631                    for code_line in code_text.split('\n') {
632                        lines.push(format!("{}{}", indent, code_fn(code_line)));
633                    }
634                }
635
636                lines.push(border("```"));
637
638                // Add spacing after code block
639                if *pos < events.len() {
640                    let next_is_space = matches!(
641                        &events[*pos],
642                        Event::Start(Tag::Paragraph)
643                            | Event::End(_)
644                            | Event::SoftBreak
645                            | Event::HardBreak
646                    );
647                    if !next_is_space {
648                        lines.push(String::new());
649                    }
650                }
651                lines
652            }
653
654            Tag::List(start) => self.render_list(events, pos, *start, width, list_depth),
655
656            Tag::Item => {
657                // Items are handled in render_list; skip to End(Item)
658                let mut depth = 1;
659                loop {
660                    if *pos >= events.len() {
661                        break;
662                    }
663                    match &events[*pos] {
664                        Event::Start(Tag::Item) => {
665                            depth += 1;
666                            *pos += 1;
667                        }
668                        Event::End(TagEnd::Item) => {
669                            depth -= 1;
670                            *pos += 1;
671                            if depth == 0 {
672                                break;
673                            }
674                        }
675                        Event::Start(_) => {
676                            *pos += 1;
677                            // Skip inner content
678                            let _ = self.render_block(
679                                events,
680                                pos,
681                                &Tag::Paragraph,
682                                width,
683                                false,
684                                list_depth + 1,
685                            );
686                        }
687                        _ => {
688                            *pos += 1;
689                        }
690                    }
691                }
692                Vec::new()
693            }
694
695            Tag::Table(alignments) => self.render_table(events, pos, alignments, width),
696
697            Tag::HtmlBlock => {
698                // Collect HTML content until End(HtmlBlock)
699                let mut html_text = String::new();
700                loop {
701                    if *pos >= events.len() {
702                        break;
703                    }
704                    match &events[*pos] {
705                        Event::End(TagEnd::HtmlBlock) => {
706                            *pos += 1;
707                            break;
708                        }
709                        Event::Text(t) | Event::Html(t) => {
710                            html_text.push_str(t);
711                            *pos += 1;
712                        }
713                        Event::SoftBreak | Event::HardBreak => {
714                            html_text.push('\n');
715                            *pos += 1;
716                        }
717                        _ => {
718                            *pos += 1;
719                        }
720                    }
721                }
722                let ctx = self.build_default_ctx();
723                let mut lines = Vec::new();
724                for line in html_text.lines() {
725                    let trimmed = line.trim();
726                    if !trimmed.is_empty() {
727                        lines.push((ctx.apply_text)(trimmed));
728                    }
729                }
730                lines
731            }
732
733            Tag::TableHead | Tag::TableRow | Tag::TableCell => {
734                // Should be handled by render_table - skip to End
735                let end = tag.to_end();
736                loop {
737                    if *pos >= events.len() {
738                        break;
739                    }
740                    if matches!(&events[*pos], Event::End(e) if *e == end) {
741                        *pos += 1;
742                        break;
743                    }
744                    // For TableCell, render inline content
745                    if matches!(tag, Tag::TableCell)
746                        && let Event::Start(_) = &events[*pos]
747                    {
748                        // Skip nested starts
749                        *pos += 1;
750                        continue;
751                    }
752                    *pos += 1;
753                }
754                Vec::new()
755            }
756
757            Tag::FootnoteDefinition(_)
758            | Tag::MetadataBlock(_)
759            | Tag::DefinitionList
760            | Tag::DefinitionListTitle
761            | Tag::DefinitionListDefinition => {
762                // Skip unsupported block types
763                let end = tag.to_end();
764                skip_until(events, pos, end);
765                Vec::new()
766            }
767
768            // Inline tags at block level - render inline
769            Tag::Emphasis
770            | Tag::Strong
771            | Tag::Strikethrough
772            | Tag::Superscript
773            | Tag::Subscript
774            | Tag::Link { .. }
775            | Tag::Image { .. } => {
776                let content =
777                    self.render_inline(events, pos, tag.to_end(), &self.build_default_ctx());
778                vec![content]
779            }
780        }
781    }
782
783    /// Render inline-level events into a single styled string.
784    fn render_inline(
785        &self,
786        events: &[Event],
787        pos: &mut usize,
788        end: TagEnd,
789        ctx: &InlineCtx,
790    ) -> String {
791        let mut result = String::new();
792
793        loop {
794            if *pos >= events.len() {
795                break;
796            }
797
798            match &events[*pos] {
799                Event::End(tag_end) if *tag_end == end => {
800                    *pos += 1;
801                    break;
802                }
803
804                Event::Text(text) => {
805                    *pos += 1;
806                    // Text may contain newlines (from soft breaks in parsed markdown)
807                    result.push_str(&split_newline_apply(text, &*ctx.apply_text));
808                }
809
810                Event::Code(code) => {
811                    *pos += 1;
812                    result.push_str(&(self.theme.code)(code));
813                    result.push_str(&ctx.style_prefix);
814                }
815
816                Event::Start(Tag::Emphasis) => {
817                    *pos += 1;
818                    let inner = self.render_inline(events, pos, TagEnd::Emphasis, ctx);
819                    result.push_str(&(self.theme.italic)(&inner));
820                    result.push_str(&ctx.style_prefix);
821                }
822
823                Event::Start(Tag::Strong) => {
824                    *pos += 1;
825                    let inner = self.render_inline(events, pos, TagEnd::Strong, ctx);
826                    result.push_str(&(self.theme.bold)(&inner));
827                    result.push_str(&ctx.style_prefix);
828                }
829
830                Event::Start(Tag::Strikethrough) => {
831                    *pos += 1;
832                    let inner = self.render_inline(events, pos, TagEnd::Strikethrough, ctx);
833                    result.push_str(&(self.theme.strikethrough)(&inner));
834                    result.push_str(&ctx.style_prefix);
835                }
836
837                Event::Start(Tag::Link {
838                    dest_url, title: _, ..
839                }) => {
840                    *pos += 1;
841                    let inner = self.render_inline(events, pos, TagEnd::Link, ctx);
842
843                    let styled_link = (self.theme.link)(&(self.theme.underline)(&inner));
844
845                    if hyperlinks_supported() {
846                        result.push_str(&hyperlink(&styled_link, dest_url));
847                    } else {
848                        // Fallback: print URL in parentheses when text differs from href
849                        let href = dest_url.as_ref();
850                        let href_clean = if let Some(mailto) = href.strip_prefix("mailto:") {
851                            mailto
852                        } else {
853                            href
854                        };
855                        if inner.trim() == href_clean || inner.trim() == href {
856                            result.push_str(&styled_link);
857                        } else {
858                            result.push_str(&styled_link);
859                            result.push_str(&(self.theme.link_url)(&format!(" ({})", href)));
860                        }
861                    }
862                    result.push_str(&ctx.style_prefix);
863                }
864
865                Event::Start(Tag::Image { .. }) => {
866                    // Skip image content until End(Image)
867                    *pos += 1;
868                    let _ = self.render_inline(events, pos, TagEnd::Image, ctx);
869                }
870
871                Event::SoftBreak => {
872                    *pos += 1;
873                    result.push('\n');
874                }
875
876                Event::HardBreak => {
877                    *pos += 1;
878                    result.push('\n');
879                }
880
881                Event::InlineHtml(html) | Event::Html(html) => {
882                    *pos += 1;
883                    result.push_str(&(ctx.apply_text)(html.trim()));
884                }
885
886                // Task list marker
887                Event::TaskListMarker(checked) => {
888                    *pos += 1;
889                    let marker = if *checked { "[x] " } else { "[ ] " };
890                    let styled = (self.theme.list_bullet)(marker);
891                    result.push_str(&styled);
892                }
893
894                // Handle inline math as plain text
895                Event::InlineMath(math) | Event::DisplayMath(math) => {
896                    *pos += 1;
897                    result.push_str(&(ctx.apply_text)(math));
898                }
899
900                // Footnote reference - render as text
901                Event::FootnoteReference(ref_id) => {
902                    *pos += 1;
903                    result.push_str(&(ctx.apply_text)(&format!("[^{}]", ref_id)));
904                }
905
906                // Nested starts in inline context (rare: paragraph inside list item etc.)
907                Event::Start(tag) => {
908                    *pos += 1;
909                    let content = self.render_block(events, pos, tag, 80, false, 0);
910                    for (i, line) in content.iter().enumerate() {
911                        if i > 0 {
912                            result.push('\n');
913                        }
914                        result.push_str(line);
915                    }
916                }
917
918                _ => {
919                    *pos += 1;
920                }
921            }
922        }
923
924        // Trim trailing style prefix from result (matching pi)
925        while result.ends_with(&ctx.style_prefix) && !ctx.style_prefix.is_empty() {
926            result = result[..result.len() - ctx.style_prefix.len()].to_string();
927        }
928
929        result
930    }
931
932    /// Render a list (ordered or unordered).
933    fn render_list(
934        &self,
935        events: &[Event],
936        pos: &mut usize,
937        start: Option<u64>,
938        width: usize,
939        depth: usize,
940    ) -> Vec<String> {
941        let mut lines: Vec<String> = Vec::new();
942        let indent_str = "    ".repeat(depth);
943        let start_number = start.unwrap_or(1);
944        let mut item_index: u64 = 0;
945
946        loop {
947            if *pos >= events.len() {
948                break;
949            }
950
951            match &events[*pos] {
952                Event::End(TagEnd::List(ordered)) => {
953                    if *ordered == start.is_some() {
954                        *pos += 1;
955                        break;
956                    }
957                    *pos += 1;
958                }
959
960                Event::Start(Tag::Item) => {
961                    *pos += 1;
962                    item_index += 1;
963
964                    // Collect task list marker if present
965                    let task_marker = if *pos < events.len() {
966                        match &events[*pos] {
967                            Event::TaskListMarker(checked) => {
968                                *pos += 1;
969                                let checked_str = if *checked { "[x] " } else { "[ ] " };
970                                Some(checked_str.to_string())
971                            }
972                            _ => None,
973                        }
974                    } else {
975                        None
976                    };
977
978                    let is_ordered = start.is_some();
979                    let marker = if is_ordered {
980                        let num_str = (start_number + item_index - 1).to_string();
981                        format!("{}. ", num_str)
982                    } else {
983                        "- ".to_string()
984                    };
985                    let marker = task_marker
986                        .map(|tm| format!("{}{}", marker, tm))
987                        .unwrap_or(marker);
988
989                    let bullet_prefix = indent_str.clone() + &(self.theme.list_bullet)(&marker);
990                    let continuation_prefix =
991                        indent_str.clone() + &" ".repeat(visible_width(&marker));
992                    let item_width = width.saturating_sub(visible_width(&bullet_prefix)).max(1);
993                    let mut rendered_any = false;
994
995                    // Render item content (paragraphs, nested lists, etc.)
996                    loop {
997                        if *pos >= events.len() {
998                            break;
999                        }
1000
1001                        match &events[*pos] {
1002                            Event::End(TagEnd::Item) => {
1003                                *pos += 1;
1004                                break;
1005                            }
1006
1007                            Event::Start(Tag::List(lst)) => {
1008                                *pos += 1;
1009                                let nested = self.render_list(events, pos, *lst, width, depth + 1);
1010                                for nl in nested {
1011                                    lines.push(nl);
1012                                }
1013                                rendered_any = true;
1014                            }
1015
1016                            Event::Start(Tag::Item) => {
1017                                // Next item started - break to outer loop
1018                                break;
1019                            }
1020
1021                            Event::Start(tag) => {
1022                                *pos += 1;
1023                                let block_lines =
1024                                    self.render_block(events, pos, tag, item_width, false, depth);
1025                                for bl in block_lines.iter() {
1026                                    for wl in wrap_text_with_ansi(bl, item_width) {
1027                                        let prefix = if rendered_any {
1028                                            &continuation_prefix
1029                                        } else {
1030                                            &bullet_prefix
1031                                        };
1032                                        lines.push(format!("{}{}", prefix, wl));
1033                                        rendered_any = true;
1034                                    }
1035                                }
1036                            }
1037
1038                            // Inline content directly in list item (no Paragraph wrapper)
1039                            Event::Text(_)
1040                            | Event::Code(_)
1041                            | Event::SoftBreak
1042                            | Event::HardBreak
1043                            | Event::InlineHtml(_)
1044                            | Event::InlineMath(_)
1045                            | Event::DisplayMath(_) => {
1046                                let inline = self.render_inline(
1047                                    events,
1048                                    pos,
1049                                    TagEnd::Item,
1050                                    &self.build_default_ctx(),
1051                                );
1052                                for wl in wrap_text_with_ansi(&inline, item_width) {
1053                                    let prefix = if rendered_any {
1054                                        &continuation_prefix
1055                                    } else {
1056                                        &bullet_prefix
1057                                    };
1058                                    lines.push(format!("{}{}", prefix, wl));
1059                                    rendered_any = true;
1060                                }
1061                            }
1062
1063                            Event::End(TagEnd::Paragraph) => {
1064                                // Skip paragraph end if present
1065                                *pos += 1;
1066                            }
1067
1068                            _ => {
1069                                *pos += 1;
1070                            }
1071                        }
1072                    }
1073
1074                    if !rendered_any {
1075                        lines.push(bullet_prefix);
1076                    }
1077                }
1078
1079                _ => {
1080                    *pos += 1;
1081                }
1082            }
1083        }
1084
1085        lines
1086    }
1087
1088    /// Render a table with width-aware column sizing and box-drawing borders.
1089    fn render_table(
1090        &self,
1091        events: &[Event],
1092        pos: &mut usize,
1093        alignments: &[pulldown_cmark::Alignment],
1094        width: usize,
1095    ) -> Vec<String> {
1096        let ctx = self.build_default_ctx();
1097        let num_cols = alignments.len();
1098
1099        if num_cols == 0 {
1100            // Skip the table
1101            skip_until(events, pos, TagEnd::Table);
1102            return Vec::new();
1103        }
1104
1105        // Collect header cells
1106        let mut headers: Vec<Vec<String>> = Vec::new(); // header row, each cell has rendered lines (1 per cell)
1107        let mut body: Vec<Vec<Vec<String>>> = Vec::new(); // rows, each row has cells, each cell has rendered lines
1108
1109        let mut current_cell_content: Vec<Event> = Vec::new(); // collected inline events for current cell
1110        let mut current_row: Vec<Vec<String>> = Vec::new(); // cells of current row
1111        let mut _current_cell_idx: usize = 0;
1112        let mut in_body = false;
1113
1114        loop {
1115            if *pos >= events.len() {
1116                break;
1117            }
1118
1119            match &events[*pos] {
1120                Event::End(TagEnd::Table) => {
1121                    // Flush last cell if any
1122                    if !current_cell_content.is_empty() {
1123                        let cell_text = self.render_collected_inline(&current_cell_content, &ctx);
1124                        current_row.push(cell_text);
1125                        current_cell_content.clear();
1126                    }
1127                    if !current_row.is_empty() {
1128                        body.push(current_row.clone());
1129                    }
1130                    *pos += 1;
1131                    break;
1132                }
1133
1134                Event::Start(Tag::TableHead) => {
1135                    *pos += 1;
1136                    // TableHead started, next events are row/cells
1137                }
1138
1139                Event::End(TagEnd::TableHead) => {
1140                    *pos += 1;
1141                    if !current_row.is_empty() {
1142                        headers = current_row.clone();
1143                        current_row.clear();
1144                    }
1145                    in_body = true;
1146                }
1147
1148                Event::Start(Tag::TableRow) => {
1149                    *pos += 1;
1150                    _current_cell_idx = 0;
1151                }
1152
1153                Event::End(TagEnd::TableRow) => {
1154                    *pos += 1;
1155                    // Flush last cell
1156                    if !current_cell_content.is_empty() {
1157                        let cell_text = self.render_collected_inline(&current_cell_content, &ctx);
1158                        current_row.push(cell_text);
1159                        current_cell_content.clear();
1160                    }
1161                    if !current_row.is_empty() {
1162                        if !in_body {
1163                            headers = current_row.clone();
1164                        } else {
1165                            body.push(current_row.clone());
1166                        }
1167                        current_row.clear();
1168                    }
1169                    _current_cell_idx = 0;
1170                }
1171
1172                Event::Start(Tag::TableCell) => {
1173                    *pos += 1;
1174                    // Flush previous cell content if any
1175                    if !current_cell_content.is_empty() {
1176                        let cell_text = self.render_collected_inline(&current_cell_content, &ctx);
1177                        current_row.push(cell_text);
1178                        current_cell_content.clear();
1179                        _current_cell_idx += 1;
1180                    }
1181                }
1182
1183                Event::End(TagEnd::TableCell) => {
1184                    *pos += 1;
1185                    // Flush this cell
1186                    let cell_text = self.render_collected_inline(&current_cell_content, &ctx);
1187                    current_row.push(cell_text);
1188                    current_cell_content.clear();
1189                    _current_cell_idx += 1;
1190                }
1191
1192                // Collect inline events during cell processing
1193                Event::Text(_t) => {
1194                    current_cell_content.push(events[*pos].clone());
1195                    *pos += 1;
1196                }
1197                Event::Code(_c) => {
1198                    current_cell_content.push(events[*pos].clone());
1199                    *pos += 1;
1200                }
1201                Event::Start(Tag::Emphasis)
1202                | Event::Start(Tag::Strong)
1203                | Event::Start(Tag::Strikethrough)
1204                | Event::Start(Tag::Link { .. }) => {
1205                    current_cell_content.push(events[*pos].clone());
1206                    *pos += 1;
1207                }
1208                Event::End(TagEnd::Emphasis)
1209                | Event::End(TagEnd::Strong)
1210                | Event::End(TagEnd::Strikethrough)
1211                | Event::End(TagEnd::Link) => {
1212                    current_cell_content.push(events[*pos].clone());
1213                    *pos += 1;
1214                }
1215                Event::SoftBreak | Event::HardBreak => {
1216                    current_cell_content.push(events[*pos].clone());
1217                    *pos += 1;
1218                }
1219                Event::InlineHtml(_h) => {
1220                    current_cell_content.push(events[*pos].clone());
1221                    *pos += 1;
1222                }
1223
1224                // Skip any other events
1225                Event::Start(_) => {
1226                    current_cell_content.push(events[*pos].clone());
1227                    *pos += 1;
1228                }
1229                Event::End(_) => {
1230                    *pos += 1;
1231                }
1232                _ => {
1233                    *pos += 1;
1234                }
1235            }
1236        }
1237
1238        // Now render the collected table data
1239        let border_overhead = 3 * num_cols + 1;
1240        let available = width.saturating_sub(border_overhead);
1241        if available < num_cols {
1242            // Too narrow, skip
1243            return Vec::new();
1244        }
1245
1246        // Calculate natural widths (max visible width of each cell across all rows)
1247        let max_unbroken_word_width = 30;
1248        let mut natural_widths = vec![0usize; num_cols];
1249        let mut min_word_widths = vec![1usize; num_cols];
1250
1251        // Helper to update widths from a set of cells
1252        let update_widths =
1253            |cells: &[Vec<String>], natural: &mut [usize], min_word: &mut [usize]| {
1254                for (i, cell_lines) in cells.iter().enumerate() {
1255                    if i >= num_cols {
1256                        break;
1257                    }
1258                    for cl in cell_lines {
1259                        let vw = visible_width(cl);
1260                        natural[i] = natural[i].max(vw);
1261                        // Longest word width
1262                        let longest = cl
1263                            .split_whitespace()
1264                            .map(visible_width)
1265                            .max()
1266                            .unwrap_or(0)
1267                            .min(max_unbroken_word_width);
1268                        min_word[i] = min_word[i].max(longest.max(1));
1269                    }
1270                }
1271            };
1272
1273        // Apply headers for width calculation
1274        update_widths(&headers, &mut natural_widths, &mut min_word_widths);
1275
1276        for row_cells in &body {
1277            update_widths(row_cells, &mut natural_widths, &mut min_word_widths);
1278        }
1279
1280        // Calculate final column widths
1281        let total_natural: usize = natural_widths.iter().sum();
1282        let mut column_widths = vec![0usize; num_cols];
1283
1284        if total_natural + border_overhead <= width {
1285            // Everything fits
1286            for i in 0..num_cols {
1287                column_widths[i] = natural_widths[i].max(min_word_widths[i]);
1288            }
1289        } else {
1290            // Need to shrink - start from min widths and distribute remaining space
1291            let min_total: usize = min_word_widths.iter().sum();
1292            let extra = available.saturating_sub(min_total);
1293
1294            let grow_potential: usize = natural_widths
1295                .iter()
1296                .zip(min_word_widths.iter())
1297                .map(|(n, m)| n.saturating_sub(*m))
1298                .sum();
1299
1300            if min_total <= available {
1301                for i in 0..num_cols {
1302                    let n = natural_widths[i];
1303                    let m = min_word_widths[i];
1304                    let potential = n.saturating_sub(m);
1305                    let grow = if grow_potential > 0 {
1306                        extra
1307                            .checked_mul(potential)
1308                            .map(|p| p / grow_potential)
1309                            .unwrap_or(0)
1310                    } else {
1311                        0
1312                    };
1313                    column_widths[i] = m + grow;
1314                }
1315                // Distribute rounding remainder
1316                let allocated: usize = column_widths.iter().sum();
1317                let mut remaining = available.saturating_sub(allocated);
1318                for i in 0..num_cols {
1319                    if remaining == 0 {
1320                        break;
1321                    }
1322                    if column_widths[i] < natural_widths[i] {
1323                        column_widths[i] += 1;
1324                        remaining -= 1;
1325                    }
1326                }
1327            } else {
1328                // Even min widths don't fit - equal distribution
1329                let base = available / num_cols;
1330                let rem = available % num_cols;
1331                for (i, cw) in column_widths.iter_mut().enumerate() {
1332                    *cw = base + if i < rem { 1 } else { 0 };
1333                }
1334            }
1335        }
1336
1337        let mut result: Vec<String> = Vec::new();
1338
1339        // Top border
1340        let top_cells: Vec<String> = column_widths.iter().map(|w| "─".repeat(*w)).collect();
1341        result.push(format!("┌─{}─┐", top_cells.join("─┬─")));
1342
1343        // Header row (with bold)
1344        let header_lines = self.render_table_row(&headers, &column_widths, num_cols, &ctx, true);
1345        result.extend(header_lines);
1346
1347        // Separator
1348        let sep_cells: Vec<String> = column_widths.iter().map(|w| "─".repeat(*w)).collect();
1349        result.push(format!("├─{}─┤", sep_cells.join("─┼─")));
1350
1351        // Body rows
1352        for (ri, row_cells) in body.iter().enumerate() {
1353            let row_lines = self.render_table_row(row_cells, &column_widths, num_cols, &ctx, false);
1354            result.extend(row_lines);
1355            if ri < body.len() - 1 {
1356                // Row separator (same as header separator)
1357                result.push(format!("├─{}─┤", sep_cells.join("─┼─")));
1358            }
1359        }
1360
1361        // Bottom border
1362        let bottom_cells: Vec<String> = column_widths.iter().map(|w| "─".repeat(*w)).collect();
1363        result.push(format!("└─{}─┘", bottom_cells.join("─┴─")));
1364
1365        // Spacing after table
1366        if *pos < events.len() {
1367            let next_is_space = matches!(
1368                &events[*pos],
1369                Event::End(_) | Event::SoftBreak | Event::HardBreak
1370            );
1371            if !next_is_space {
1372                result.push(String::new());
1373            }
1374        }
1375
1376        result
1377    }
1378
1379    /// Render a single table row (header or body) with cell wrapping.
1380    fn render_table_row(
1381        &self,
1382        cells: &[Vec<String>],
1383        column_widths: &[usize],
1384        num_cols: usize,
1385        _ctx: &InlineCtx,
1386        is_header: bool,
1387    ) -> Vec<String> {
1388        if cells.is_empty() {
1389            return Vec::new();
1390        }
1391
1392        // Wrap each cell to column width
1393        let mut wrapped_cells: Vec<Vec<String>> = Vec::new();
1394        for (i, cell_lines) in cells.iter().enumerate() {
1395            if i >= num_cols {
1396                break;
1397            }
1398            let col_width = column_widths[i];
1399            let mut wrapped: Vec<String> = Vec::new();
1400            for cl in cell_lines {
1401                for wl in wrap_text_with_ansi(cl, col_width) {
1402                    wrapped.push(wl);
1403                }
1404            }
1405            if wrapped.is_empty() {
1406                wrapped.push(String::new());
1407            }
1408            wrapped_cells.push(wrapped);
1409        }
1410
1411        // Pad all cells to same number of lines
1412        let max_lines = wrapped_cells.iter().map(|c| c.len()).max().unwrap_or(1);
1413        for cell in &mut wrapped_cells {
1414            while cell.len() < max_lines {
1415                cell.push(String::new());
1416            }
1417        }
1418
1419        let mut result: Vec<String> = Vec::new();
1420        for line_idx in 0..max_lines {
1421            let mut row_parts: Vec<String> = Vec::new();
1422            for (col_idx, cell) in wrapped_cells.iter().enumerate() {
1423                let text = cell.get(line_idx).map(|s| s.as_str()).unwrap_or("");
1424                let vw = visible_width(text);
1425                let padding = column_widths[col_idx].saturating_sub(vw);
1426                let padded = if is_header {
1427                    (self.theme.bold)(&format!("{}{}", text, " ".repeat(padding)))
1428                } else {
1429                    format!("{}{}", text, " ".repeat(padding))
1430                };
1431                row_parts.push(padded);
1432            }
1433            result.push(format!("│ {} │", row_parts.join(" │ ")));
1434        }
1435
1436        result
1437    }
1438
1439    /// Render a collected sequence of inline events into a styled string.
1440    fn render_collected_inline(&self, events: &[Event], ctx: &InlineCtx) -> Vec<String> {
1441        if events.is_empty() {
1442            return vec![String::new()];
1443        }
1444        // Group events into inline rendering and return as lines
1445        let mut pos = 0usize;
1446        let rendered = self.render_inline(events, &mut pos, TagEnd::TableCell, ctx);
1447        if rendered.is_empty() {
1448            vec![String::new()]
1449        } else {
1450            rendered.split('\n').map(|s| s.to_string()).collect()
1451        }
1452    }
1453}
1454
1455// ── Helper functions ─────────────────────────────────────────────
1456
1457/// Skip events until the matching `End` tag is found.
1458fn skip_until(events: &[Event], pos: &mut usize, end: TagEnd) {
1459    let mut depth = 0;
1460    loop {
1461        if *pos >= events.len() {
1462            break;
1463        }
1464        match &events[*pos] {
1465            Event::End(tag_end) if *tag_end == end => {
1466                if depth == 0 {
1467                    *pos += 1;
1468                    break;
1469                }
1470                depth -= 1;
1471                *pos += 1;
1472            }
1473            Event::Start(_) => {
1474                depth += 1;
1475                *pos += 1;
1476            }
1477            _ => {
1478                *pos += 1;
1479            }
1480        }
1481    }
1482}
1483
1484/// Convert a `HeadingLevel` to its numeric value.
1485fn level_to_usize(level: HeadingLevel) -> usize {
1486    match level {
1487        HeadingLevel::H1 => 1,
1488        HeadingLevel::H2 => 2,
1489        HeadingLevel::H3 => 3,
1490        HeadingLevel::H4 => 4,
1491        HeadingLevel::H5 => 5,
1492        HeadingLevel::H6 => 6,
1493    }
1494}
1495
1496/// Split text by newlines and apply style to each segment.
1497/// Preserves newlines between styled segments.
1498fn split_newline_apply(text: &str, apply: &dyn Fn(&str) -> String) -> String {
1499    let segments: Vec<&str> = text.split('\n').collect();
1500    segments
1501        .iter()
1502        .enumerate()
1503        .map(|(i, s)| {
1504            if i > 0 {
1505                format!("\n{}", apply(s))
1506            } else {
1507                apply(s)
1508            }
1509        })
1510        .collect()
1511}
1512
1513// ── Syntax Highlighting (feature-gated) ─────────────────────────
1514
1515/// Create a syntax highlighting function.
1516/// Returns `Some(..)` when the `syntect` feature is enabled, `None` otherwise.
1517pub fn create_highlight_fn() -> Option<HighlightFn> {
1518    #[cfg(feature = "syntect")]
1519    {
1520        Some(Arc::new(highlight_code))
1521    }
1522    #[cfg(not(feature = "syntect"))]
1523    {
1524        None
1525    }
1526}
1527
1528#[cfg(feature = "syntect")]
1529pub fn highlight_code(code: &str, lang: Option<&str>) -> Vec<String> {
1530    use std::sync::LazyLock;
1531
1532    use syntect::{
1533        easy::HighlightLines,
1534        highlighting::ThemeSet,
1535        parsing::SyntaxSet,
1536        util::{LinesWithEndings, as_24_bit_terminal_escaped},
1537    };
1538
1539    static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
1540
1541    static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
1542
1543    let ss = &SYNTAX_SET;
1544    let ts = &THEME_SET;
1545
1546    // Find the syntax by language name/extension
1547    let syntax = lang
1548        .and_then(|l| ss.find_syntax_by_token(l))
1549        .unwrap_or_else(|| ss.find_syntax_plain_text());
1550
1551    // Pick a theme (base16-ocean.dark works well for dark terminals)
1552    let theme = ts
1553        .themes
1554        .get("base16-ocean.dark")
1555        .or_else(|| ts.themes.iter().next().map(|(_, t)| t));
1556
1557    let Some(theme) = theme else {
1558        // No themes available - return plain text
1559        return code.split('\n').map(|s| s.to_string()).collect();
1560    };
1561
1562    let mut highlighter = HighlightLines::new(syntax, theme);
1563    let mut result = Vec::new();
1564
1565    for line in LinesWithEndings::from(code) {
1566        match highlighter.highlight_line(line, ss) {
1567            Ok(ranges) => {
1568                let escaped = as_24_bit_terminal_escaped(&ranges, false);
1569                // Strip the trailing newline that LinesWithEndings includes
1570                let trimmed = escaped.trim_end_matches('\n');
1571                if trimmed.is_empty() {
1572                    result.push(String::new());
1573                } else {
1574                    result.push(format!("{}\x1b[0m", trimmed));
1575                }
1576            }
1577            Err(_) => {
1578                result.push(line.trim_end_matches('\n').to_string());
1579            }
1580        }
1581    }
1582
1583    result
1584}
1585
1586/// Map a file path to a language identifier for syntax highlighting.
1587pub fn path_to_language(path: &str) -> Option<&'static str> {
1588    let ext = path.rsplit('.').next()?.to_lowercase();
1589    let lang = match ext.as_str() {
1590        "ts" | "tsx" => "typescript",
1591        "js" | "jsx" | "mjs" | "cjs" => "javascript",
1592        "py" => "python",
1593        "rb" => "ruby",
1594        "rs" => "rust",
1595        "go" => "go",
1596        "java" => "java",
1597        "kt" => "kotlin",
1598        "swift" => "swift",
1599        "c" | "h" => "c",
1600        "cpp" | "cc" | "cxx" | "hpp" => "cpp",
1601        "cs" => "csharp",
1602        "php" => "php",
1603        "sh" | "bash" | "zsh" => "bash",
1604        "ps1" => "powershell",
1605        "sql" => "sql",
1606        "html" | "htm" => "html",
1607        "css" | "scss" | "sass" | "less" => "css",
1608        "json" => "json",
1609        "yaml" | "yml" => "yaml",
1610        "toml" => "toml",
1611        "xml" => "xml",
1612        "md" | "markdown" => "markdown",
1613        "clj" | "cljs" | "cljc" => "clojure",
1614        "ex" | "exs" => "elixir",
1615        "hs" => "haskell",
1616        "lua" => "lua",
1617        _ => return None,
1618    };
1619    Some(lang)
1620}
1621
1622// ── Tests ────────────────────────────────────────────────────────
1623
1624#[cfg(test)]
1625mod tests {
1626    use super::*;
1627
1628    /// Create a minimal theme for testing.
1629    fn test_theme() -> MarkdownTheme {
1630        MarkdownTheme::new(
1631            Arc::new(|s| format!("\x1b[33m{}\x1b[39m", s)), // heading: yellow
1632            Arc::new(|s| format!("\x1b[34m{}\x1b[39m", s)), // link: blue
1633            Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)), // link_url: bright black
1634            Arc::new(|s| format!("\x1b[36m{}\x1b[39m", s)), // code: cyan
1635            Arc::new(|s| format!("\x1b[32m{}\x1b[39m", s)), // code_block: green
1636            Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)), // code_block_border: gray
1637            Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)), // quote: gray
1638            Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)), // quote_border: gray
1639            Arc::new(|s| format!("\x1b[90m{}\x1b[39m", s)), // hr: gray
1640            Arc::new(|s| format!("\x1b[33m{}\x1b[39m", s)), // list_bullet: yellow
1641            Arc::new(|s| format!("\x1b[1m{}\x1b[22m", s)),  // bold
1642            Arc::new(|s| format!("\x1b[3m{}\x1b[23m", s)),  // italic
1643            Arc::new(|s| format!("\x1b[9m{}\x1b[29m", s)),  // strikethrough
1644            Arc::new(|s| format!("\x1b[4m{}\x1b[24m", s)),  // underline
1645        )
1646    }
1647
1648    #[test]
1649    fn test_basic_paragraph() {
1650        let theme = test_theme();
1651        let md = Markdown::new("hello world", 0, 0, theme, None, None);
1652        let lines = md.render(80);
1653        let all = lines.join("\n");
1654        assert!(all.contains("hello world"));
1655        assert!(!all.contains("\x1b[")); // no styling
1656    }
1657
1658    #[test]
1659    fn test_heading_h1() {
1660        let theme = test_theme();
1661        let md = Markdown::new("# Heading 1", 0, 0, theme, None, None);
1662        let lines = md.render(80);
1663        let all = lines.join("\n");
1664        assert!(all.contains("Heading 1"), "Should contain heading text");
1665        assert!(all.contains("\x1b[1m"), "Should have bold for h1");
1666        assert!(all.contains("\x1b[33m"), "Should have heading color");
1667    }
1668
1669    #[test]
1670    fn test_heading_h3_marker() {
1671        let theme = test_theme();
1672        let md = Markdown::new("### Heading 3", 0, 0, theme, None, None);
1673        let lines = md.render(80);
1674        let all = lines.join("\n");
1675        assert!(all.contains("### Heading 3") || all.contains("Heading 3"));
1676        // h3 should have ### prefix
1677        assert!(
1678            !all.contains("### ") || all.contains("###"),
1679            "h3 should show ### marker"
1680        );
1681    }
1682
1683    #[test]
1684    fn test_bold_italic() {
1685        let theme = test_theme();
1686        let md = Markdown::new("**bold** and *italic*", 0, 0, theme, None, None);
1687        let lines = md.render(80);
1688        let all = lines.join("\n");
1689        assert!(all.contains("bold"), "Should contain bold text");
1690        assert!(all.contains("italic"), "Should contain italic text");
1691        assert!(all.contains("\x1b[1m"), "Should contain bold ANSI");
1692        assert!(all.contains("\x1b[3m"), "Should contain italic ANSI");
1693    }
1694
1695    #[test]
1696    fn test_codespan() {
1697        let theme = test_theme();
1698        let md = Markdown::new("use `code` here", 0, 0, theme, None, None);
1699        let lines = md.render(80);
1700        let all = lines.join("\n");
1701        assert!(all.contains("code"), "Should contain code text");
1702        assert!(all.contains("\x1b[36m"), "Should contain code color (cyan)");
1703    }
1704
1705    #[test]
1706    fn test_inline_code_style_restore() {
1707        let theme = test_theme();
1708        let md = Markdown::new("**bold `code` end**", 0, 0, theme, None, None);
1709        let lines = md.render(80);
1710        let all = lines.join("\n");
1711        assert!(all.contains("bold"), "Should contain bold text");
1712        assert!(all.contains("code"), "Should contain code text");
1713        assert!(all.contains("end"), "Should contain 'end' text");
1714        // The 'end' should be bold (style restored after codespan)
1715    }
1716
1717    #[test]
1718    fn test_code_block() {
1719        let theme = test_theme();
1720        let md = Markdown::new("```\nlet x = 1;\n```", 0, 0, theme, None, None);
1721        let lines = md.render(80);
1722        let all = lines.join("\n");
1723        assert!(all.contains("let x = 1;"), "Should contain code");
1724        assert!(all.contains("\x1b[32m"), "Should have code block color");
1725        assert!(all.contains("```"), "Should have fence markers");
1726    }
1727
1728    #[test]
1729    fn test_fenced_code_with_language() {
1730        let theme = test_theme();
1731        let md = Markdown::new("```rust\nfn main() {}\n```", 0, 0, theme, None, None);
1732        let lines = md.render(80);
1733        let all = lines.join("\n");
1734        assert!(all.contains("```rust"), "Should show language tag");
1735        assert!(all.contains("fn main() {}"), "Should contain code");
1736    }
1737
1738    #[test]
1739    fn test_unordered_list() {
1740        let theme = test_theme();
1741        let md = Markdown::new("- item 1\n- item 2\n- item 3", 0, 0, theme, None, None);
1742        let lines = md.render(80);
1743        let all = lines.join("\n");
1744        assert!(all.contains("item 1"), "Should contain first item");
1745        assert!(all.contains("item 2"), "Should contain second item");
1746        assert!(all.contains("item 3"), "Should contain third item");
1747    }
1748
1749    #[test]
1750    fn test_strikethrough() {
1751        let theme = test_theme();
1752        let md = Markdown::new("~~struck~~", 0, 0, theme, None, None);
1753        let lines = md.render(80);
1754        let all = lines.join("\n");
1755        assert!(all.contains("struck"), "Should contain text");
1756        assert!(all.contains("\x1b[9m"), "Should contain strikethrough");
1757    }
1758
1759    #[test]
1760    fn test_link_inline() {
1761        let theme = test_theme();
1762        let md = Markdown::new("[text](https://example.com)", 0, 0, theme, None, None);
1763        let lines = md.render(80);
1764        let all = lines.join("\n");
1765        assert!(all.contains("text"), "Should contain link text");
1766        assert!(
1767            all.contains("https://example.com"),
1768            "Should contain URL in fallback"
1769        );
1770    }
1771
1772    #[test]
1773    fn test_empty_text() {
1774        let theme = test_theme();
1775        let md = Markdown::new("", 0, 0, theme, None, None);
1776        let lines = md.render(80);
1777        assert!(lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()));
1778    }
1779
1780    #[test]
1781    fn test_whitespace_only() {
1782        let theme = test_theme();
1783        let md = Markdown::new("   ", 0, 0, theme, None, None);
1784        let lines = md.render(80);
1785        assert!(lines.is_empty() || (lines.len() == 1 && lines[0].is_empty()));
1786    }
1787
1788    #[test]
1789    fn test_horizontal_rule() {
1790        let theme = test_theme();
1791        let md = Markdown::new("---", 0, 0, theme, None, None);
1792        let lines = md.render(80);
1793        let all = lines.join("\n");
1794        assert!(all.contains('─'), "Should have horizontal rule");
1795    }
1796
1797    #[test]
1798    fn test_padding_x() {
1799        let theme = test_theme();
1800        let md = Markdown::new("hello", 2, 0, theme, None, None);
1801        let lines = md.render(20);
1802        assert_eq!(
1803            visible_width(&lines[0]),
1804            20,
1805            "Should be padded to full width"
1806        );
1807        assert!(lines[0].starts_with("  "), "Should have left padding");
1808    }
1809
1810    #[test]
1811    fn test_padding_y() {
1812        let theme = test_theme();
1813        let md = Markdown::new("hello", 0, 1, theme, None, None);
1814        let lines = md.render(20);
1815        assert_eq!(
1816            lines.len(),
1817            3,
1818            "Should have top padding + content + bottom padding"
1819        );
1820    }
1821
1822    #[test]
1823    fn test_cache_hit() {
1824        let theme = test_theme();
1825        let md = Markdown::new("hello", 1, 0, theme, None, None);
1826        let a = md.render(20);
1827        let b = md.render(20);
1828        assert_eq!(a, b, "Cache should return same result");
1829    }
1830
1831    #[test]
1832    fn test_cache_invalidation() {
1833        let theme = test_theme();
1834        let mut md = Markdown::new("hello", 1, 0, theme, None, None);
1835        let a = md.render(20);
1836        md.set_text("world");
1837        let b = md.render(20);
1838        assert_ne!(a, b, "Cache should be invalidated on set_text");
1839    }
1840
1841    #[test]
1842    fn test_strikethrough_not_enabled_without_tilde() {
1843        // Without the ~ markers, strikethrough shouldn't trigger
1844        let theme = test_theme();
1845        let md = Markdown::new("~not struck~", 0, 0, theme, None, None);
1846        let lines = md.render(80);
1847        let all = lines.join("\n");
1848        // With ENABLE_STRIKETHROUGH, ~ should work as strikethrough in pulldown-cmark
1849        // But with ~~ being the correct syntax for GFM, ~ alone might not trigger
1850        assert!(
1851            all.contains("~not struck~") || all.contains("not struck"),
1852            "~ should work as plain text or strikethrough"
1853        );
1854    }
1855
1856    #[test]
1857    fn test_blockquote() {
1858        let theme = test_theme();
1859        let md = Markdown::new("> quoted text", 0, 0, theme, None, None);
1860        let lines = md.render(80);
1861        let all = lines.join("\n");
1862        assert!(all.contains("quoted text"), "Should contain quote text");
1863        assert!(all.contains("│"), "Should have blockquote border");
1864    }
1865
1866    #[test]
1867    fn test_task_list() {
1868        let theme = test_theme();
1869        let md = Markdown::new("- [x] done\n- [ ] todo", 0, 0, theme, None, None);
1870        let lines = md.render(80);
1871        let all = lines.join("\n");
1872        assert!(all.contains("[x]"), "Should show done marker");
1873        assert!(all.contains("[ ]"), "Should show todo marker");
1874        assert!(all.contains("done"), "Should contain done text");
1875        assert!(all.contains("todo"), "Should contain todo text");
1876    }
1877
1878    #[test]
1879    fn test_paragraph_spacing() {
1880        let theme = test_theme();
1881        let md = Markdown::new("para one\n\npara two", 0, 0, theme, None, None);
1882        let lines = md.render(80);
1883        assert!(lines.len() >= 2, "Should have multiple lines");
1884    }
1885
1886    #[test]
1887    fn test_tabs_replaced() {
1888        let theme = test_theme();
1889        let md = Markdown::new("\tindented", 0, 0, theme, None, None);
1890        let lines = md.render(80);
1891        let all = lines.join("\n");
1892        assert!(
1893            all.contains("indented"),
1894            "Tabs should be replaced with 3 spaces"
1895        );
1896    }
1897
1898    #[test]
1899    fn test_default_text_style() {
1900        let theme = test_theme();
1901        let default_style = DefaultTextStyle {
1902            color: Some(Arc::new(|s| format!("\x1b[33m{}\x1b[39m", s))),
1903            bg_color: None,
1904            bold: true,
1905            italic: false,
1906            strikethrough: false,
1907            underline: false,
1908        };
1909        let md = Markdown::new("styled text", 0, 0, theme, Some(default_style), None);
1910        let lines = md.render(80);
1911        let all = lines.join("\n");
1912        assert!(all.contains("styled text"));
1913        assert!(
1914            all.contains("\x1b[1m"),
1915            "Should have bold from default style"
1916        );
1917        assert!(
1918            all.contains("\x1b[33m"),
1919            "Should have yellow from default style"
1920        );
1921    }
1922
1923    #[test]
1924    fn test_table_basic() {
1925        let theme = test_theme();
1926        let md = Markdown::new(
1927            "| H1 | H2 |\n| --- | --- |\n| A1 | B1 |\n| A2 | B2 |",
1928            0,
1929            0,
1930            theme,
1931            None,
1932            None,
1933        );
1934        let lines = md.render(80);
1935        let all = lines.join("\n");
1936        assert!(all.contains("H1"), "Should contain header");
1937        assert!(all.contains("H2"), "Should contain header");
1938        assert!(all.contains("A1"), "Should contain cell");
1939        assert!(all.contains("B1"), "Should contain cell");
1940        assert!(all.contains("┌"), "Should have top border");
1941        assert!(all.contains("└"), "Should have bottom border");
1942        assert!(all.contains("│"), "Should have column separators");
1943    }
1944
1945    #[test]
1946    fn test_table_narrow_fallback() {
1947        let theme = test_theme();
1948        let md = Markdown::new(
1949            "| A | B |\n| --- | --- |\n| 1 | 2 |",
1950            0,
1951            0,
1952            theme,
1953            None,
1954            None,
1955        );
1956        // Render at very narrow width
1957        let lines = md.render(10);
1958        // Should not panic, either renders tiny table or nothing
1959        assert!(!lines.is_empty());
1960    }
1961
1962    #[test]
1963    fn test_ordered_list() {
1964        let theme = test_theme();
1965        let md = Markdown::new("1. first\n2. second\n3. third", 0, 0, theme, None, None);
1966        let lines = md.render(80);
1967        let all = lines.join("\n");
1968        assert!(all.contains("first"), "Should contain first");
1969        assert!(all.contains("second"), "Should contain second");
1970        assert!(all.contains("third"), "Should contain third");
1971    }
1972
1973    #[test]
1974    fn test_nested_list() {
1975        let theme = test_theme();
1976        let md = Markdown::new("- outer\n  - inner\n- more", 0, 0, theme, None, None);
1977        let lines = md.render(80);
1978        let all = lines.join("\n");
1979        assert!(all.contains("outer"), "Should contain outer");
1980        assert!(all.contains("inner"), "Should contain nested");
1981        assert!(all.contains("more"), "Should contain more");
1982    }
1983
1984    #[test]
1985    fn test_blockquote_nested() {
1986        let theme = test_theme();
1987        let md = Markdown::new("> outer\n> > nested\n> back", 0, 0, theme, None, None);
1988        let lines = md.render(80);
1989        let all = lines.join("\n");
1990        assert!(all.contains("outer"), "Should contain outer text");
1991        assert!(all.contains("nested"), "Should contain nested text");
1992        assert!(all.contains("back"), "Should contain text after nested");
1993        assert!(all.contains("│"), "Should have blockquote border");
1994    }
1995
1996    #[test]
1997    fn test_link_with_dest() {
1998        let theme = test_theme();
1999        let md = Markdown::new(
2000            "[example](https://example.com/page)",
2001            0,
2002            0,
2003            theme,
2004            None,
2005            None,
2006        );
2007        let lines = md.render(80);
2008        let all = lines.join("\n");
2009        assert!(all.contains("example"), "Should contain link text");
2010        assert!(all.contains("example.com/page"), "Should contain URL");
2011    }
2012
2013    #[test]
2014    fn test_autolink() {
2015        let theme = test_theme();
2016        let md = Markdown::new("<https://example.com>", 0, 0, theme, None, None);
2017        let lines = md.render(80);
2018        let all = lines.join("\n");
2019        assert!(all.contains("example.com"), "Should contain URL");
2020    }
2021
2022    #[test]
2023    fn test_heading_h2_spacing() {
2024        let theme = test_theme();
2025        let md = Markdown::new("## Heading\n\nParagraph", 0, 0, theme, None, None);
2026        let lines = md.render(80);
2027        let all = lines.join("\n");
2028        assert!(all.contains("Heading"), "Should contain heading");
2029        assert!(all.contains("Paragraph"), "Should contain paragraph");
2030    }
2031
2032    #[test]
2033    fn test_code_block_markers() {
2034        let theme = test_theme();
2035        let md = Markdown::new("```rust\nfn hello() {}\n```", 0, 0, theme, None, None);
2036        let lines = md.render(80);
2037        let all = lines.join("\n");
2038        assert!(all.contains("```rust"), "Should show language in fence");
2039        assert!(all.contains("fn hello() {}"), "Should contain code");
2040    }
2041
2042    #[test]
2043    fn test_strikethrough_markers() {
2044        let theme = test_theme();
2045        let md = Markdown::new("~~struck text~~", 0, 0, theme, None, None);
2046        let lines = md.render(80);
2047        let all = lines.join("\n");
2048        assert!(all.contains("struck text"), "Should contain text");
2049        assert!(all.contains("\x1b[9m"), "Should have strikethrough ANSI");
2050    }
2051
2052    #[test]
2053    fn test_wrap_long_text() {
2054        let theme = test_theme();
2055        let long = "this is a very long line that should definitely wrap to multiple lines when rendered in a narrow terminal column";
2056        let md = Markdown::new(long, 0, 0, theme, None, None);
2057        let lines = md.render(30);
2058        assert!(lines.len() > 1, "Long text should wrap");
2059        for line in &lines {
2060            assert!(visible_width(line) <= 30, "Each line should fit width");
2061        }
2062    }
2063
2064    #[test]
2065    fn test_cache_different_width() {
2066        let theme = test_theme();
2067        let md = Markdown::new("hello world", 1, 0, theme, None, None);
2068        let a = md.render(30);
2069        let b = md.render(50);
2070        assert_ne!(a, b, "Different widths should produce different output");
2071    }
2072
2073    #[test]
2074    fn test_html_block_plain() {
2075        let theme = test_theme();
2076        let md = Markdown::new("<div>plain html</div>", 0, 0, theme, None, None);
2077        let lines = md.render(80);
2078        let all = lines.join("\n");
2079        assert!(
2080            all.contains("plain html"),
2081            "Should render HTML as plain text"
2082        );
2083    }
2084
2085    #[test]
2086    fn test_bold_italic_style_restore() {
2087        let theme = test_theme();
2088        let md = Markdown::new("**bold `code` more bold**", 0, 0, theme, None, None);
2089        let lines = md.render(80);
2090        let all = lines.join("\n");
2091        assert!(all.contains("bold"), "Should contain bold text");
2092        assert!(all.contains("code"), "Should contain code");
2093        assert!(all.contains("more"), "Should contain text after code");
2094        // The "more" should still be bold after the inline code reset
2095        assert!(
2096            all.contains("\x1b[22m") || all.contains("more bold"),
2097            "Style should be restored after codespan"
2098        );
2099    }
2100
2101    #[test]
2102    fn test_heading_h4_marker() {
2103        let theme = test_theme();
2104        let md = Markdown::new("#### Heading 4", 0, 0, theme, None, None);
2105        let lines = md.render(80);
2106        let all = lines.join("\n");
2107        assert!(all.contains("####"), "h4 should show prefix marker");
2108        assert!(all.contains("Heading 4"), "Should contain heading text");
2109    }
2110
2111    #[test]
2112    fn test_heading_h5_marker() {
2113        let theme = test_theme();
2114        let md = Markdown::new("##### Heading 5", 0, 0, theme, None, None);
2115        let lines = md.render(80);
2116        let all = lines.join("\n");
2117        assert!(all.contains("#####"), "h5 should show prefix marker");
2118        assert!(all.contains("Heading 5"), "Should contain heading text");
2119    }
2120
2121    #[test]
2122    fn test_heading_h6_marker() {
2123        let theme = test_theme();
2124        let md = Markdown::new("###### Heading 6", 0, 0, theme, None, None);
2125        let lines = md.render(80);
2126        let all = lines.join("\n");
2127        assert!(all.contains("######"), "h6 should show prefix marker");
2128        assert!(all.contains("Heading 6"), "Should contain heading text");
2129    }
2130}