aico/ui/
markdown_streamer.rs

1// This implementation is loosly based on https://github.com/day50-dev/Streamdown
2use crate::console::ANSI_REGEX_PATTERN;
3use crossterm::{
4    queue,
5    style::{
6        Attribute, Color, Print, ResetColor, SetAttribute, SetBackgroundColor, SetForegroundColor,
7    },
8};
9use regex::Regex;
10use std::fmt::Write as _;
11use std::io::{self, Write};
12use std::sync::LazyLock;
13use syntect::easy::HighlightLines;
14use syntect::highlighting::{Theme, ThemeSet};
15use syntect::parsing::SyntaxSet;
16use unicode_width::UnicodeWidthStr;
17
18// --- Static Resources ---
19static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
20static THEME: LazyLock<Theme> = LazyLock::new(|| {
21    let ts = ThemeSet::load_defaults();
22    ts.themes
23        .get("base16-ocean.dark")
24        .or_else(|| ts.themes.values().next())
25        .expect("No themes found")
26        .clone()
27});
28
29// Regexes
30static RE_CODE_FENCE: LazyLock<Regex> =
31    LazyLock::new(|| Regex::new(r"^(\s*)([`~]{3,})(.*)$").unwrap());
32static RE_HEADER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(#{1,6})\s+(.*)").unwrap());
33static RE_HR: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*[-*_]){3,}\s*$").unwrap());
34static RE_LIST: LazyLock<Regex> =
35    LazyLock::new(|| Regex::new(r"^(\s*)([-*+]|\d+\.)(?:(\s+)(.*)|$)").unwrap());
36static RE_BLOCKQUOTE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*>\s?)(.*)").unwrap());
37
38static RE_TABLE_ROW: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\|(.*)\|\s*$").unwrap());
39static RE_TABLE_SEP: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[\s\|\-\:]+$").unwrap());
40
41static RE_MATH_BLOCK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\$\$\s*$").unwrap());
42
43// Single pattern for "Invisible" content (ANSI codes + OSC8 links) used for width calculation
44static RE_INVISIBLE: LazyLock<Regex> =
45    LazyLock::new(|| Regex::new(&format!("({}|{})", OSC8_PATTERN, ANSI_REGEX_PATTERN)).unwrap());
46
47// --- ANSI & Links ---
48// Shared pattern for OSC 8 links: \x1b]8;; ... \x1b\
49const OSC8_PATTERN: &str = r"\x1b]8;;.*?\x1b\\";
50
51static RE_LINK: LazyLock<Regex> =
52    LazyLock::new(|| Regex::new(r"\[([^\]]+)\]\(([^\)]+)\)").unwrap());
53static RE_OSC8: LazyLock<Regex> = LazyLock::new(|| Regex::new(OSC8_PATTERN).unwrap());
54
55static RE_TOKENIZER: LazyLock<Regex> = LazyLock::new(|| {
56    Regex::new(&format!(
57        r"({}|{}|`+|\\[\s\S]|\$[^\$\s](?:[^\$\n]*?[^\$\s])?\$|~~|~|\*\*\*|___|\*\*|__|\*|_|\$|[^~*_`$\\\x1b]+)",
58        OSC8_PATTERN, ANSI_REGEX_PATTERN
59    ))
60    .unwrap()
61});
62
63static RE_SPLIT_ANSI: LazyLock<Regex> = LazyLock::new(|| {
64    let pattern = format!(
65        "({}|{}|\\s+|[^\\s\\x1b]+)",
66        OSC8_PATTERN, ANSI_REGEX_PATTERN
67    );
68    Regex::new(&pattern).unwrap()
69});
70static RE_ANSI_PARTS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[([0-9;]*)m").unwrap());
71
72// --- Helper Structs ---
73
74struct InlinePart {
75    content: String,
76    is_delim: bool,
77    char: char,
78    len: usize,
79    can_open: bool,
80    can_close: bool,
81    pre_style: Vec<String>,
82    post_style: Vec<String>,
83}
84
85impl InlinePart {
86    fn text(content: String) -> Self {
87        Self {
88            content,
89            is_delim: false,
90            char: '\0',
91            len: 0,
92            can_open: false,
93            can_close: false,
94            pre_style: vec![],
95            post_style: vec![],
96        }
97    }
98}
99
100pub struct MarkdownStreamer {
101    // Code State
102    active_fence: Option<(char, usize, usize)>, // char, min_len, indent
103    code_lang: String,
104
105    // Inline Code State
106    inline_code_ticks: Option<usize>,
107    inline_code_buffer: String,
108
109    // Math State
110    in_math_block: bool,
111    math_buffer: String,
112
113    // Table State
114    in_table: bool,
115    table_header_printed: bool,
116
117    // Parsing State
118    highlighter: Option<HighlightLines<'static>>,
119    line_buffer: String,
120
121    // Layout State
122    margin: usize,
123    blockquote_depth: usize,
124    list_stack: Vec<(usize, bool, usize, usize)>, // (indent_len, is_ordered, counter, marker_width)
125
126    // Configuration
127    manual_width: Option<usize>,
128
129    // Reusable buffer
130    scratch_buffer: String,
131}
132
133impl Default for MarkdownStreamer {
134    fn default() -> Self {
135        Self::new()
136    }
137}
138
139impl MarkdownStreamer {
140    pub fn new() -> Self {
141        Self {
142            active_fence: None,
143            code_lang: "bash".to_string(),
144            inline_code_ticks: None,
145            inline_code_buffer: String::new(),
146            in_math_block: false,
147            math_buffer: String::new(),
148            in_table: false,
149            table_header_printed: false,
150            highlighter: None,
151            line_buffer: String::new(),
152            margin: 2,
153            blockquote_depth: 0,
154            list_stack: Vec::new(),
155            manual_width: None,
156            scratch_buffer: String::with_capacity(1024),
157        }
158    }
159
160    /// Set a fixed width for rendering. If not set, terminal size is queried.
161    pub fn set_width(&mut self, width: usize) {
162        self.manual_width = Some(width);
163    }
164
165    /// Set the margin (default 2)
166    pub fn set_margin(&mut self, margin: usize) {
167        self.margin = margin;
168    }
169
170    fn get_width(&self) -> usize {
171        self.manual_width
172            .unwrap_or_else(crate::console::get_terminal_width)
173    }
174
175    fn visible_width(&self, text: &str) -> usize {
176        UnicodeWidthStr::width(RE_INVISIBLE.replace_all(text, "").as_ref())
177    }
178
179    /// Main entry point: Process a chunk of text and write to the provided writer.
180    pub fn print_chunk<W: Write>(&mut self, writer: &mut W, text: &str) -> io::Result<()> {
181        self.line_buffer.push_str(text);
182        while let Some(pos) = self.line_buffer.find('\n') {
183            let line = self.line_buffer[..pos + 1].to_string();
184            self.line_buffer.drain(..pos + 1);
185            self.process_line(writer, &line)?;
186        }
187        Ok(())
188    }
189
190    /// Flush remaining buffer (useful at end of stream).
191    pub fn flush<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
192        if !self.line_buffer.is_empty() {
193            let line = std::mem::take(&mut self.line_buffer);
194            self.process_line(writer, &line)?;
195        }
196
197        // Handle unclosed inline code at EOF
198        if let Some(ticks) = self.inline_code_ticks {
199            // Print the opening ticks
200            queue!(writer, Print("`".repeat(ticks)))?;
201            // Print the buffer content
202            queue!(writer, Print(&self.inline_code_buffer))?;
203            self.inline_code_ticks = None;
204            self.inline_code_buffer.clear();
205        }
206
207        writer.flush()
208    }
209
210    // --- Pipeline Controller ---
211    fn process_line<W: Write>(&mut self, w: &mut W, raw_line: &str) -> io::Result<()> {
212        let expanded = raw_line.replace('\t', "  ");
213        let trimmed = expanded.trim_end();
214
215        // 1. Context-Specific Handlers (return true if consumed)
216        if self.try_handle_fence(w, &expanded, trimmed)? {
217            return Ok(());
218        }
219        if self.try_handle_math(w, trimmed)? {
220            return Ok(());
221        }
222        if self.try_handle_table(w, trimmed)? {
223            return Ok(());
224        }
225
226        // 2. Global Layout Calculation (Blockquotes & Margins)
227        let mut content = expanded.as_str();
228        self.blockquote_depth = 0;
229        while let Some(caps) = RE_BLOCKQUOTE.captures(content) {
230            self.blockquote_depth += 1;
231            content = caps.get(2).map_or("", |m| m.as_str());
232        }
233
234        let mut prefix = " ".repeat(self.margin);
235        if self.blockquote_depth > 0 {
236            prefix.push_str("\x1b[38;5;240m");
237            for _ in 0..self.blockquote_depth {
238                prefix.push_str("│ ");
239            }
240            prefix.push_str("\x1b[0m");
241        }
242
243        let term_width = self.get_width();
244        let prefix_width = self.margin + (self.blockquote_depth * 2);
245        let avail_width = term_width.saturating_sub(prefix_width + self.margin);
246
247        // 3. Block Start Handlers
248        if self.inline_code_ticks.is_none() {
249            let clean = content.trim_end();
250            if self.try_handle_header(w, clean, &prefix, avail_width)? {
251                return Ok(());
252            }
253            if self.try_handle_list(w, clean, &prefix, avail_width)? {
254                return Ok(());
255            }
256            if self.try_handle_hr(w, clean, &prefix, avail_width)? {
257                return Ok(());
258            }
259        }
260
261        // 4. Standard Text / Lazy Continuation
262        self.render_standard_text(w, content, &prefix, avail_width)
263    }
264
265    // --- Specific Handlers ---
266
267    fn try_handle_fence<W: Write>(
268        &mut self,
269        w: &mut W,
270        full: &str,
271        trimmed: &str,
272    ) -> io::Result<bool> {
273        let match_data = RE_CODE_FENCE.captures(trimmed);
274
275        // Closing Fence
276        if let Some((f_char, min_len, _)) = self.active_fence {
277            if let Some(caps) = &match_data {
278                let fence = &caps[2];
279                if fence.starts_with(f_char) && fence.len() >= min_len && caps[3].trim().is_empty()
280                {
281                    self.active_fence = None;
282                    queue!(w, ResetColor, Print("\n"))?;
283                    return Ok(true);
284                }
285            }
286            self.render_code_line(w, full)?;
287            return Ok(true);
288        }
289
290        // Opening Fence
291        if let Some(caps) = match_data {
292            let fence = &caps[2];
293            let indent_len = caps[1].len();
294            let info = caps[3].trim();
295            if let Some(f_char) = fence.chars().next()
296                && (f_char != '`' || !info.contains('`'))
297            {
298                while self
299                    .list_stack
300                    .last()
301                    .is_some_and(|(d, _, _, _)| *d > indent_len)
302                {
303                    self.list_stack.pop();
304                }
305                self.active_fence = Some((f_char, fence.len(), indent_len));
306                let lang = info.split_whitespace().next().unwrap_or("bash");
307                self.code_lang = lang.to_string();
308                self.start_highlighter(&self.code_lang.clone());
309                return Ok(true);
310            }
311        }
312        Ok(false)
313    }
314
315    fn try_handle_math<W: Write>(&mut self, w: &mut W, trimmed: &str) -> io::Result<bool> {
316        if RE_MATH_BLOCK.is_match(trimmed) {
317            if self.in_math_block {
318                self.in_math_block = false;
319                let converted = unicodeit::replace(&self.math_buffer);
320                let p_width = self.margin + (self.blockquote_depth * 2);
321                let avail = self.get_width().saturating_sub(p_width + self.margin);
322                let padding = avail.saturating_sub(self.visible_width(&converted)) / 2;
323
324                queue!(
325                    w,
326                    Print(" ".repeat(self.margin + padding)),
327                    SetForegroundColor(Color::Cyan),
328                    SetAttribute(Attribute::Italic),
329                    Print(converted),
330                    ResetColor,
331                    SetAttribute(Attribute::Reset),
332                    Print("\n")
333                )?;
334                self.math_buffer.clear();
335            } else {
336                self.reset_block_context();
337                self.in_math_block = true;
338            }
339            return Ok(true);
340        }
341        if self.in_math_block {
342            self.math_buffer.push_str(trimmed);
343            self.math_buffer.push(' ');
344            return Ok(true);
345        }
346        Ok(false)
347    }
348
349    fn try_handle_table<W: Write>(&mut self, w: &mut W, trimmed: &str) -> io::Result<bool> {
350        if self.in_table && RE_TABLE_SEP.is_match(trimmed) {
351            self.table_header_printed = true;
352            return Ok(true);
353        }
354        if RE_TABLE_ROW.is_match(trimmed) {
355            if !self.in_table {
356                self.reset_block_context();
357                self.in_table = true;
358            }
359            self.render_stream_table_row(w, trimmed)?;
360            return Ok(true);
361        }
362        self.in_table = false;
363        self.table_header_printed = false;
364        Ok(false)
365    }
366
367    fn try_handle_header<W: Write>(
368        &mut self,
369        w: &mut W,
370        clean: &str,
371        prefix: &str,
372        avail: usize,
373    ) -> io::Result<bool> {
374        if let Some(caps) = RE_HEADER.captures(clean) {
375            let level = caps.get(1).map_or(0, |m| m.len());
376            let text = caps.get(2).map_or("", |m| m.as_str());
377            self.reset_block_context();
378
379            queue!(w, Print(prefix))?;
380            if level <= 2 {
381                queue!(w, Print("\n"))?;
382            }
383
384            self.scratch_buffer.clear();
385            let style = match level {
386                1 => "\x1b[1m",
387                2 => "\x1b[1;94m",
388                3 => "\x1b[1;36m",
389                _ => "\x1b[1;33m",
390            };
391            self.render_inline(text, None, Some(style));
392
393            if level <= 2 {
394                let lines = self.wrap_ansi(&self.scratch_buffer, avail);
395                for line in lines {
396                    let pad = avail.saturating_sub(self.visible_width(&line)) / 2;
397                    queue!(
398                        w,
399                        Print(" ".repeat(pad)),
400                        Print(format!("{}{}\x1b[0m", style, line)),
401                        ResetColor,
402                        Print("\n")
403                    )?;
404                    if level == 1 {
405                        queue!(w, Print(prefix))?;
406                    }
407                }
408            } else {
409                queue!(
410                    w,
411                    Print(style),
412                    Print(&self.scratch_buffer),
413                    Print("\x1b[0m\n")
414                )?;
415            }
416            return Ok(true);
417        }
418        Ok(false)
419    }
420
421    fn try_handle_list<W: Write>(
422        &mut self,
423        w: &mut W,
424        clean: &str,
425        prefix: &str,
426        avail: usize,
427    ) -> io::Result<bool> {
428        if let Some(caps) = RE_LIST.captures(clean) {
429            let indent = caps.get(1).map_or(0, |m| m.len());
430            let bullet = caps.get(2).map_or("-", |m| m.as_str());
431            let separator = caps.get(3).map_or(" ", |m| m.as_str());
432            let text = caps.get(4).map_or("", |m| m.as_str());
433
434            // Heuristic: Strict CommonMark compliance.
435            // A line like "* a *" should be literal emphasis, not a list item.
436            // If the bullet is '*' and the text ends with '*' treat as text.
437            if bullet == "*" && text.trim_end().ends_with('*') && text.len() < 20 {
438                return Ok(false);
439            }
440
441            let is_ord = bullet.chars().any(|c| c.is_numeric());
442            let disp_bullet = if is_ord { bullet } else { "•" };
443            let marker_width = self.visible_width(disp_bullet) + separator.len();
444
445            let last_indent = self.list_stack.last().map(|(d, _, _, _)| *d).unwrap_or(0);
446            if self.list_stack.is_empty() || indent > last_indent {
447                self.list_stack.push((indent, is_ord, 0, marker_width));
448            } else if indent < last_indent {
449                while self
450                    .list_stack
451                    .last()
452                    .is_some_and(|(d, _, _, _)| *d > indent)
453                {
454                    self.list_stack.pop();
455                }
456                if self
457                    .list_stack
458                    .last()
459                    .is_some_and(|(d, _, _, _)| *d != indent)
460                {
461                    self.list_stack.push((indent, is_ord, 0, marker_width));
462                }
463            } else {
464                // Same level: update width in case marker size changed (e.g. 9. -> 10.)
465                if let Some(last) = self.list_stack.last_mut() {
466                    last.3 = marker_width;
467                }
468            }
469
470            let full_stack_width: usize = self.list_stack.iter().map(|(_, _, _, w)| *w).sum();
471            let parent_width = full_stack_width.saturating_sub(marker_width);
472
473            let hang_indent = " ".repeat(full_stack_width);
474            let content_width = avail.saturating_sub(full_stack_width);
475
476            queue!(
477                w,
478                Print(prefix),
479                Print(" ".repeat(parent_width)),
480                SetForegroundColor(Color::Yellow),
481                Print(disp_bullet),
482                Print(separator),
483                ResetColor
484            )?;
485
486            // Check if the text portion looks like a code fence start (e.g., "```ruby")
487            if let Some(fcaps) = RE_CODE_FENCE.captures(text) {
488                queue!(w, Print("\n"))?;
489
490                let fence_chars = &fcaps[2];
491                let info = fcaps[3].trim();
492
493                if let Some(f_char) = fence_chars.chars().next() {
494                    self.active_fence = Some((f_char, fence_chars.len(), 0));
495
496                    let lang = info.split_whitespace().next().unwrap_or("bash");
497                    self.code_lang = lang.to_string();
498                    self.start_highlighter(&self.code_lang.clone());
499                }
500                return Ok(true);
501            }
502
503            self.scratch_buffer.clear();
504            self.render_inline(text, None, None);
505            let lines = self.wrap_ansi(&self.scratch_buffer, content_width);
506
507            if lines.is_empty() {
508                queue!(w, Print("\n"))?;
509            } else if let Some(first) = lines.first() {
510                queue!(w, Print(first), ResetColor, Print("\n"))?;
511            }
512            for line in lines.iter().skip(1) {
513                queue!(
514                    w,
515                    Print(prefix),
516                    Print(&hang_indent),
517                    Print(line),
518                    ResetColor,
519                    Print("\n")
520                )?;
521            }
522            return Ok(true);
523        }
524        Ok(false)
525    }
526
527    fn try_handle_hr<W: Write>(
528        &mut self,
529        w: &mut W,
530        clean: &str,
531        prefix: &str,
532        avail: usize,
533    ) -> io::Result<bool> {
534        if RE_HR.is_match(clean) {
535            queue!(
536                w,
537                Print(prefix),
538                SetForegroundColor(Color::DarkGrey),
539                Print("─".repeat(avail)),
540                ResetColor,
541                Print("\n")
542            )?;
543            self.reset_block_context();
544            return Ok(true);
545        }
546        Ok(false)
547    }
548
549    fn render_standard_text<W: Write>(
550        &mut self,
551        w: &mut W,
552        content: &str,
553        prefix: &str,
554        avail: usize,
555    ) -> io::Result<()> {
556        let mut line_content = content.trim_end_matches(['\n', '\r']);
557        if line_content.trim().is_empty() && content.ends_with('\n') {
558            self.reset_block_context();
559            queue!(
560                w,
561                Print(if self.blockquote_depth > 0 {
562                    prefix
563                } else {
564                    ""
565                }),
566                Print("\n")
567            )?;
568            return Ok(());
569        }
570
571        if !line_content.is_empty() || self.inline_code_ticks.is_some() {
572            let mut eff_prefix = prefix.to_string();
573            if !self.list_stack.is_empty() {
574                let current_indent = line_content.chars().take_while(|c| *c == ' ').count();
575                if current_indent == 0 {
576                    self.list_stack.clear();
577                } else {
578                    while self
579                        .list_stack
580                        .last()
581                        .is_some_and(|(d, _, _, _)| *d > current_indent)
582                    {
583                        self.list_stack.pop();
584                    }
585                }
586
587                if !self.list_stack.is_empty() {
588                    let structural_indent: usize =
589                        self.list_stack.iter().map(|(_, _, _, w)| *w).sum();
590                    eff_prefix.push_str(&" ".repeat(structural_indent));
591
592                    // To avoid double-indenting, we skip the source indentation that matches
593                    // the structural indentation we just applied via eff_prefix.
594                    let skip = current_indent.min(structural_indent);
595                    line_content = &line_content[skip..];
596                }
597            }
598
599            self.scratch_buffer.clear();
600            self.render_inline(line_content, None, None);
601            if self.inline_code_ticks.is_some() {
602                self.inline_code_buffer.push(' ');
603            }
604
605            let lines = self.wrap_ansi(&self.scratch_buffer, avail);
606            for line in lines {
607                queue!(
608                    w,
609                    ResetColor,
610                    SetAttribute(Attribute::Reset),
611                    Print(&eff_prefix),
612                    Print(&line),
613                    ResetColor,
614                    Print("\n")
615                )?;
616            }
617        }
618        Ok(())
619    }
620
621    fn reset_block_context(&mut self) {
622        self.list_stack.clear();
623        self.table_header_printed = false;
624    }
625
626    fn wrap_ansi(&self, text: &str, width: usize) -> Vec<String> {
627        let mut lines = Vec::new();
628        let mut current_line = String::new();
629        let mut current_len = 0;
630        let mut active_codes: Vec<String> = Vec::new();
631
632        for caps in RE_SPLIT_ANSI.captures_iter(text) {
633            let token = caps.get(1).unwrap().as_str();
634            if token.starts_with("\x1b") {
635                current_line.push_str(token);
636                // If it's an OSC8 link sequence, it has no visible width.
637                // update_ansi_state already ignores it for state tracking, but we must
638                // ensure we don't accidentally treat it as visible text below.
639                self.update_ansi_state(&mut active_codes, token);
640            } else {
641                let mut token_str = token;
642                let mut token_len = UnicodeWidthStr::width(token_str);
643
644                while current_len + token_len > width && width > 0 {
645                    if current_len == 0 {
646                        // Force split long word
647                        let mut split_idx = 0;
648                        let mut split_len = 0;
649                        for (idx, c) in token_str.char_indices() {
650                            let c_w = UnicodeWidthStr::width(c.to_string().as_str());
651                            if split_len + c_w > width {
652                                break;
653                            }
654                            split_idx = idx + c.len_utf8();
655                            split_len += c_w;
656                        }
657                        if split_idx == 0 {
658                            split_idx = token_str.chars().next().map_or(0, |c| c.len_utf8());
659                        }
660                        if split_idx == 0 {
661                            break;
662                        } // Empty string safety
663
664                        current_line.push_str(&token_str[..split_idx]);
665                        lines.push(current_line);
666                        current_line = active_codes.join("");
667                        token_str = &token_str[split_idx..];
668                        token_len = UnicodeWidthStr::width(token_str);
669                        current_len = 0;
670                    } else if !token_str.trim().is_empty() {
671                        lines.push(current_line);
672                        current_line = active_codes.join("");
673                        current_len = 0;
674                    } else {
675                        token_str = "";
676                        token_len = 0;
677                    }
678                }
679                if !token_str.is_empty() {
680                    current_line.push_str(token_str);
681                    current_len += token_len;
682                }
683            }
684        }
685        if !current_line.is_empty() {
686            lines.push(current_line);
687        }
688        lines
689    }
690
691    fn update_ansi_state(&self, state: &mut Vec<String>, code: &str) {
692        if RE_OSC8.is_match(code) {
693            return;
694        }
695        if let Some(caps) = RE_ANSI_PARTS.captures(code) {
696            let content = caps.get(1).map_or("", |m| m.as_str());
697            if content == "0" || content.is_empty() {
698                state.clear();
699                return;
700            }
701
702            let num: i32 = content
703                .split(';')
704                .next()
705                .unwrap_or("0")
706                .parse()
707                .unwrap_or(0);
708            let category = match num {
709                1 | 22 => "bold",
710                3 | 23 => "italic",
711                4 | 24 => "underline",
712                30..=39 | 90..=97 => "fg",
713                40..=49 | 100..=107 => "bg",
714                _ => "other",
715            };
716            if category != "other" {
717                state.retain(|exist| {
718                    let e_num: i32 = RE_ANSI_PARTS
719                        .captures(exist)
720                        .and_then(|c| c.get(1))
721                        .map_or("0", |m| m.as_str())
722                        .split(';')
723                        .next()
724                        .unwrap_or("0")
725                        .parse()
726                        .unwrap_or(0);
727                    let e_cat = match e_num {
728                        1 | 22 => "bold",
729                        3 | 23 => "italic",
730                        4 | 24 => "underline",
731                        30..=39 | 90..=97 => "fg",
732                        40..=49 | 100..=107 => "bg",
733                        _ => "other",
734                    };
735                    e_cat != category
736                });
737            }
738            state.push(code.to_string());
739        }
740    }
741
742    fn render_code_line<W: Write>(&mut self, w: &mut W, line: &str) -> io::Result<()> {
743        let raw_line = line.trim_end_matches(&['\r', '\n'][..]);
744
745        let fence_indent = self.active_fence.map(|(_, _, i)| i).unwrap_or(0);
746
747        // Strip the fence's indentation from the content line
748        let mut chars = raw_line.chars();
749        let mut skipped = 0;
750        while skipped < fence_indent {
751            let as_str = chars.as_str();
752            if as_str.starts_with(' ') {
753                chars.next();
754                skipped += 1;
755            } else {
756                break;
757            }
758        }
759        let line_content = chars.as_str();
760
761        let mut prefix = " ".repeat(self.margin);
762        if !self.list_stack.is_empty() {
763            let indent_width: usize = self.list_stack.iter().map(|(_, _, _, w)| *w).sum();
764            prefix.push_str(&" ".repeat(indent_width));
765        }
766
767        let avail_width = self.get_width().saturating_sub(prefix.len() + self.margin);
768
769        let mut spans = Vec::new();
770        if let Some(h) = &mut self.highlighter {
771            if let Ok(ranges) = h.highlight_line(line_content, &SYNTAX_SET) {
772                spans = ranges;
773            } else {
774                spans.push((syntect::highlighting::Style::default(), line_content));
775            }
776        } else {
777            spans.push((syntect::highlighting::Style::default(), line_content));
778        }
779
780        // 1. Build the full colored line in memory first
781        self.scratch_buffer.clear();
782        for (style, text) in spans {
783            let _ = write!(
784                self.scratch_buffer,
785                "\x1b[38;2;{};{};{}m{}",
786                style.foreground.r, style.foreground.g, style.foreground.b, text
787            );
788        }
789
790        // 2. Wrap the colored string manually
791        let wrapped_lines = self.wrap_ansi(&self.scratch_buffer, avail_width);
792
793        // 3. Render each wrapped segment with consistent background
794        if wrapped_lines.is_empty() {
795            queue!(
796                w,
797                Print(&prefix),
798                SetBackgroundColor(Color::Rgb {
799                    r: 30,
800                    g: 30,
801                    b: 30
802                }),
803                Print(" ".repeat(avail_width)),
804                ResetColor,
805                Print("\n")
806            )?;
807        } else {
808            for line in wrapped_lines {
809                let vis_len = self.visible_width(&line);
810                let pad = avail_width.saturating_sub(vis_len);
811
812                queue!(
813                    w,
814                    Print(&prefix),
815                    SetBackgroundColor(Color::Rgb {
816                        r: 30,
817                        g: 30,
818                        b: 30
819                    }),
820                    Print(&line),
821                    Print(" ".repeat(pad)), // Fill remaining width with bg color
822                    ResetColor,
823                    Print("\n")
824                )?;
825            }
826        }
827
828        Ok(())
829    }
830
831    fn render_stream_table_row<W: Write>(&mut self, w: &mut W, row_str: &str) -> io::Result<()> {
832        let term_width = self.get_width();
833        let cells: Vec<&str> = row_str.trim().trim_matches('|').split('|').collect();
834        if cells.is_empty() {
835            return Ok(());
836        }
837
838        let prefix_width = self.margin + (self.blockquote_depth * 2);
839        let avail = term_width.saturating_sub(prefix_width + self.margin + 1 + (cells.len() * 3));
840        if avail == 0 {
841            return Ok(());
842        }
843        let base_w = avail / cells.len();
844        let rem = avail % cells.len();
845
846        let bg = if !self.table_header_printed {
847            Color::Rgb {
848                r: 60,
849                g: 60,
850                b: 80,
851            }
852        } else {
853            Color::Rgb {
854                r: 30,
855                g: 30,
856                b: 30,
857            }
858        };
859        let mut wrapped_cells = Vec::new();
860        let mut max_h = 1;
861
862        for (i, cell) in cells.iter().enumerate() {
863            let width = std::cmp::max(
864                1,
865                if i == cells.len() - 1 {
866                    base_w + rem
867                } else {
868                    base_w
869                },
870            );
871            self.scratch_buffer.clear();
872            if !self.table_header_printed {
873                self.scratch_buffer.push_str("\x1b[1;33m");
874            }
875            self.render_inline(
876                cell.trim(),
877                Some(bg),
878                if !self.table_header_printed {
879                    Some("\x1b[1;33m")
880                } else {
881                    None
882                },
883            );
884            if !self.table_header_printed {
885                self.scratch_buffer.push_str("\x1b[0m");
886            }
887
888            let lines = self.wrap_ansi(&self.scratch_buffer, width);
889            if lines.len() > max_h {
890                max_h = lines.len();
891            }
892            wrapped_cells.push((lines, width));
893        }
894
895        let mut prefix = " ".repeat(self.margin);
896        if self.blockquote_depth > 0 {
897            prefix.push_str("\x1b[38;5;240m");
898            for _ in 0..self.blockquote_depth {
899                prefix.push_str("│ ");
900            }
901            prefix.push_str("\x1b[0m");
902        }
903
904        for i in 0..max_h {
905            queue!(w, Print(&prefix))?;
906            for (col, (lines, width)) in wrapped_cells.iter().enumerate() {
907                let text = lines.get(i).map(|s| s.as_str()).unwrap_or("");
908                let pad = width.saturating_sub(self.visible_width(text));
909                queue!(
910                    w,
911                    SetBackgroundColor(bg),
912                    Print(" "),
913                    Print(text),
914                    SetBackgroundColor(bg),
915                    Print(" ".repeat(pad + 1)),
916                    ResetColor
917                )?;
918                if col < cells.len() - 1 {
919                    queue!(
920                        w,
921                        SetBackgroundColor(bg),
922                        SetForegroundColor(Color::White),
923                        Print("│"),
924                        ResetColor
925                    )?;
926                }
927            }
928            queue!(w, Print("\n"))?;
929        }
930        self.table_header_printed = true;
931        Ok(())
932    }
933
934    pub fn render_inline(&mut self, text: &str, def_bg: Option<Color>, restore_fg: Option<&str>) {
935        // Pre-process links
936        let text_linked = RE_LINK.replace_all(text, |c: &regex::Captures| {
937            format!(
938                "\x1b]8;;{}\x1b\\\x1b[33;4m{}\x1b[24;39m\x1b]8;;\x1b\\",
939                &c[2], &c[1]
940            )
941        });
942
943        let mut parts: Vec<InlinePart> = Vec::new();
944        let caps_iter = RE_TOKENIZER.captures_iter(&text_linked);
945        let tokens_raw: Vec<&str> = caps_iter.map(|c| c.get(1).unwrap().as_str()).collect();
946
947        // Pass 1: Build basic tokens
948        for (i, tok) in tokens_raw.iter().enumerate() {
949            if self.inline_code_ticks.is_some() {
950                if tok.starts_with('`') {
951                    if let Some(n) = self.inline_code_ticks {
952                        if n == tok.len() {
953                            let formatted = self.format_inline_code(def_bg, restore_fg);
954                            parts.push(InlinePart::text(formatted));
955                            self.inline_code_ticks = None;
956                            self.inline_code_buffer.clear();
957                        } else {
958                            self.inline_code_buffer.push_str(tok);
959                        }
960                    }
961                } else {
962                    self.inline_code_buffer.push_str(tok);
963                }
964                continue;
965            }
966
967            if tok.starts_with('`') {
968                self.inline_code_ticks = Some(tok.len());
969                self.inline_code_buffer.clear();
970                continue;
971            }
972
973            if tok.starts_with('\\') && tok.len() > 1 {
974                parts.push(InlinePart::text(tok[1..].to_string()));
975                continue;
976            }
977
978            if tok.starts_with('$') && tok.ends_with('$') && tok.len() > 1 {
979                parts.push(InlinePart::text(unicodeit::replace(&tok[1..tok.len() - 1])));
980                continue;
981            }
982
983            if let Some(c) = tok.chars().next()
984                && (c == '*' || c == '_' || c == '~')
985            {
986                let prev_char = if i > 0 {
987                    tokens_raw[i - 1].chars().last().unwrap_or(' ')
988                } else {
989                    ' '
990                };
991                let next_char = if i + 1 < tokens_raw.len() {
992                    tokens_raw[i + 1].chars().next().unwrap_or(' ')
993                } else {
994                    ' '
995                };
996
997                // Inline Flanking Logic (Optimization #3: Lazy Calculation)
998                let is_ws_next = next_char.is_whitespace();
999                let is_ws_prev = prev_char.is_whitespace();
1000                let is_punct_next = !next_char.is_alphanumeric() && !is_ws_next;
1001                let is_punct_prev = !prev_char.is_alphanumeric() && !is_ws_prev;
1002                let left_flanking =
1003                    !is_ws_next && (!is_punct_next || (is_ws_prev || is_punct_prev));
1004                let right_flanking =
1005                    !is_ws_prev && (!is_punct_prev || (is_ws_next || is_punct_next));
1006
1007                let (can_open, can_close) = if c == '_' {
1008                    (
1009                        left_flanking && (!right_flanking || is_punct_prev),
1010                        right_flanking && (!left_flanking || is_punct_next),
1011                    )
1012                } else {
1013                    (left_flanking, right_flanking)
1014                };
1015
1016                parts.push(InlinePart {
1017                    content: tok.to_string(),
1018                    is_delim: true,
1019                    char: c,
1020                    len: tok.len(),
1021                    can_open,
1022                    can_close,
1023                    pre_style: vec![],
1024                    post_style: vec![],
1025                });
1026            } else {
1027                parts.push(InlinePart::text(tok.to_string()));
1028            }
1029        }
1030
1031        // Pass 2: Delimiter Matching (Extracted Logic)
1032        self.resolve_delimiters(&mut parts);
1033
1034        // Pass 3: Render
1035        for part in parts {
1036            for s in &part.pre_style {
1037                self.scratch_buffer.push_str(s);
1038            }
1039            self.scratch_buffer.push_str(&part.content);
1040            for s in &part.post_style {
1041                self.scratch_buffer.push_str(s);
1042            }
1043        }
1044    }
1045
1046    fn resolve_delimiters(&self, parts: &mut [InlinePart]) {
1047        let mut stack: Vec<usize> = Vec::new();
1048
1049        for i in 0..parts.len() {
1050            if !parts[i].is_delim {
1051                continue;
1052            }
1053
1054            if parts[i].can_close {
1055                // Iterate stack in reverse
1056                let mut stack_idx = stack.len();
1057                while stack_idx > 0 {
1058                    let open_pos = stack_idx - 1;
1059                    let open_idx = stack[open_pos];
1060
1061                    // Check if match is possible
1062                    if parts[open_idx].char == parts[i].char && parts[open_idx].can_open {
1063                        // Determine consumption length
1064                        // Rule 14: Use length 1 first to satisfy Italic-outer (<em><strong>...</strong></em>)
1065                        // If total length is 3 and we match 3, 1 is preferred as outer.
1066                        let use_len = if parts[i].len == 3 && parts[open_idx].len == 3 {
1067                            1
1068                        } else if parts[i].len >= 2 && parts[open_idx].len >= 2 {
1069                            2
1070                        } else {
1071                            1
1072                        };
1073
1074                        let (style_on, style_off) = match (parts[open_idx].char, use_len) {
1075                            ('~', _) => ("\x1b[9m", "\x1b[29m"),
1076                            ('_', 1) => ("\x1b[4m", "\x1b[24m"),
1077                            (_, 1) => ("\x1b[3m", "\x1b[23m"),
1078                            (_, 2) => ("\x1b[1m", "\x1b[22m"),
1079                            _ => ("", ""),
1080                        };
1081
1082                        let char_str = parts[open_idx].char.to_string();
1083
1084                        // APPLY STYLES
1085                        // CommonMark Rule 14 and delimiter pairing logic.
1086                        // Order of application depends on whether we match inner or outer pairs first.
1087                        // For openers: post_style is closer to the text (inner).
1088                        // For closers: pre_style is closer to the text (inner).
1089
1090                        // Heuristic: Italic (len 1) is outer, Bold (len 2) is inner for ***.
1091                        if use_len == 1 {
1092                            // Italic is Outer: Outer edges.
1093                            parts[open_idx].pre_style.push(style_on.to_string());
1094                            parts[i].post_style.push(style_off.to_string());
1095                        } else {
1096                            // Bold is Inner: Inner edges (near text).
1097                            parts[open_idx].post_style.push(style_on.to_string());
1098                            parts[i].pre_style.push(style_off.to_string());
1099                        }
1100
1101                        // Consume tokens
1102                        parts[open_idx].len -= use_len;
1103                        parts[i].len -= use_len;
1104                        parts[open_idx].content = char_str.repeat(parts[open_idx].len);
1105                        parts[i].content = char_str.repeat(parts[i].len);
1106
1107                        // Stack Management
1108                        if parts[open_idx].len == 0 {
1109                            stack.remove(open_pos);
1110                            // Stack shifted, so current stack_idx is now the *next* item.
1111                            // Decrementing continues the loop correctly down the stack.
1112                            stack_idx -= 1;
1113                        } else {
1114                            // Opener still has length (e.g. *** matched ** -> * left).
1115                            // We do NOT decrement stack_idx here, effectively retrying this opener
1116                            // against the *same* closer (if closer has len) or next iteration.
1117                            // BUT: Current closer might be exhausted.
1118                        }
1119
1120                        if parts[i].len == 0 {
1121                            break;
1122                        }
1123                        // If closer still has length, we continue loop to find another opener?
1124                        // CommonMark says: "If the closer is not exhausted... continue searching...".
1125                        // So we continue the while loop.
1126                    } else {
1127                        stack_idx -= 1;
1128                    }
1129                }
1130            }
1131
1132            if parts[i].len > 0 && parts[i].can_open {
1133                stack.push(i);
1134            }
1135        }
1136    }
1137
1138    fn push_style(&mut self, active: bool, on: &str, off: &str) {
1139        self.scratch_buffer.push_str(if active { on } else { off });
1140    }
1141
1142    fn format_inline_code(&self, def_bg: Option<Color>, restore_fg: Option<&str>) -> String {
1143        // Reuse buffer for speed? No, string is small.
1144        let mut out = String::new();
1145        let norm = if self.inline_code_buffer.len() >= 2
1146            && self.inline_code_buffer.starts_with(' ')
1147            && self.inline_code_buffer.ends_with(' ')
1148            && !self.inline_code_buffer.trim().is_empty()
1149        {
1150            &self.inline_code_buffer[1..self.inline_code_buffer.len() - 1]
1151        } else {
1152            &self.inline_code_buffer
1153        };
1154
1155        let _ = write!(out, "\x1b[48;2;60;60;60m\x1b[38;2;255;255;255m{}", norm);
1156        if let Some(Color::Rgb { r, g, b }) = def_bg {
1157            let _ = write!(out, "\x1b[48;2;{};{};{}m", r, g, b);
1158        } else {
1159            out.push_str("\x1b[49m");
1160        }
1161        out.push_str(restore_fg.unwrap_or("\x1b[39m"));
1162        out
1163    }
1164
1165    fn start_highlighter(&mut self, lang: &str) {
1166        let ss = &*SYNTAX_SET;
1167        let syntax = ss
1168            .find_syntax_by_token(lang)
1169            .unwrap_or_else(|| ss.find_syntax_plain_text());
1170        self.highlighter = Some(HighlightLines::new(syntax, &THEME));
1171    }
1172}