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            let is_ord = bullet.chars().any(|c| c.is_numeric());
435            let disp_bullet = if is_ord { bullet } else { "•" };
436            let marker_width = self.visible_width(disp_bullet) + separator.len();
437
438            let last_indent = self.list_stack.last().map(|(d, _, _, _)| *d).unwrap_or(0);
439            if self.list_stack.is_empty() || indent > last_indent {
440                self.list_stack.push((indent, is_ord, 0, marker_width));
441            } else if indent < last_indent {
442                while self
443                    .list_stack
444                    .last()
445                    .is_some_and(|(d, _, _, _)| *d > indent)
446                {
447                    self.list_stack.pop();
448                }
449                if self
450                    .list_stack
451                    .last()
452                    .is_some_and(|(d, _, _, _)| *d != indent)
453                {
454                    self.list_stack.push((indent, is_ord, 0, marker_width));
455                }
456            } else {
457                // Same level: update width in case marker size changed (e.g. 9. -> 10.)
458                if let Some(last) = self.list_stack.last_mut() {
459                    last.3 = marker_width;
460                }
461            }
462
463            let full_stack_width: usize = self.list_stack.iter().map(|(_, _, _, w)| *w).sum();
464            let parent_width = full_stack_width.saturating_sub(marker_width);
465
466            let hang_indent = " ".repeat(full_stack_width);
467            let content_width = avail.saturating_sub(full_stack_width);
468
469            queue!(
470                w,
471                Print(prefix),
472                Print(" ".repeat(parent_width)),
473                SetForegroundColor(Color::Yellow),
474                Print(disp_bullet),
475                Print(separator),
476                ResetColor
477            )?;
478
479            // Check if the text portion looks like a code fence start (e.g., "```ruby")
480            if let Some(fcaps) = RE_CODE_FENCE.captures(text) {
481                queue!(w, Print("\n"))?;
482
483                let fence_chars = &fcaps[2];
484                let info = fcaps[3].trim();
485
486                if let Some(f_char) = fence_chars.chars().next() {
487                    self.active_fence = Some((f_char, fence_chars.len(), 0));
488
489                    let lang = info.split_whitespace().next().unwrap_or("bash");
490                    self.code_lang = lang.to_string();
491                    self.start_highlighter(&self.code_lang.clone());
492                }
493                return Ok(true);
494            }
495
496            self.scratch_buffer.clear();
497            self.render_inline(text, None, None);
498            let lines = self.wrap_ansi(&self.scratch_buffer, content_width);
499
500            if lines.is_empty() {
501                queue!(w, Print("\n"))?;
502            } else if let Some(first) = lines.first() {
503                queue!(w, Print(first), ResetColor, Print("\n"))?;
504            }
505            for line in lines.iter().skip(1) {
506                queue!(
507                    w,
508                    Print(prefix),
509                    Print(&hang_indent),
510                    Print(line),
511                    ResetColor,
512                    Print("\n")
513                )?;
514            }
515            return Ok(true);
516        }
517        Ok(false)
518    }
519
520    fn try_handle_hr<W: Write>(
521        &mut self,
522        w: &mut W,
523        clean: &str,
524        prefix: &str,
525        avail: usize,
526    ) -> io::Result<bool> {
527        if RE_HR.is_match(clean) {
528            queue!(
529                w,
530                Print(prefix),
531                SetForegroundColor(Color::DarkGrey),
532                Print("─".repeat(avail)),
533                ResetColor,
534                Print("\n")
535            )?;
536            self.reset_block_context();
537            return Ok(true);
538        }
539        Ok(false)
540    }
541
542    fn render_standard_text<W: Write>(
543        &mut self,
544        w: &mut W,
545        content: &str,
546        prefix: &str,
547        avail: usize,
548    ) -> io::Result<()> {
549        let mut line_content = content.trim_end_matches(['\n', '\r']);
550        if line_content.trim().is_empty() && content.ends_with('\n') {
551            self.reset_block_context();
552            queue!(
553                w,
554                Print(if self.blockquote_depth > 0 {
555                    prefix
556                } else {
557                    ""
558                }),
559                Print("\n")
560            )?;
561            return Ok(());
562        }
563
564        if !line_content.is_empty() || self.inline_code_ticks.is_some() {
565            let mut eff_prefix = prefix.to_string();
566            if !self.list_stack.is_empty() {
567                let current_indent = line_content.chars().take_while(|c| *c == ' ').count();
568                if current_indent == 0 {
569                    self.list_stack.clear();
570                } else {
571                    while self
572                        .list_stack
573                        .last()
574                        .is_some_and(|(d, _, _, _)| *d > current_indent)
575                    {
576                        self.list_stack.pop();
577                    }
578                }
579
580                if !self.list_stack.is_empty() {
581                    let structural_indent: usize =
582                        self.list_stack.iter().map(|(_, _, _, w)| *w).sum();
583                    eff_prefix.push_str(&" ".repeat(structural_indent));
584
585                    // To avoid double-indenting, we skip the source indentation that matches
586                    // the structural indentation we just applied via eff_prefix.
587                    let skip = current_indent.min(structural_indent);
588                    line_content = &line_content[skip..];
589                }
590            }
591
592            self.scratch_buffer.clear();
593            self.render_inline(line_content, None, None);
594            if self.inline_code_ticks.is_some() {
595                self.inline_code_buffer.push(' ');
596            }
597
598            let lines = self.wrap_ansi(&self.scratch_buffer, avail);
599            for line in lines {
600                queue!(
601                    w,
602                    ResetColor,
603                    SetAttribute(Attribute::Reset),
604                    Print(&eff_prefix),
605                    Print(&line),
606                    ResetColor,
607                    Print("\n")
608                )?;
609            }
610        }
611        Ok(())
612    }
613
614    fn reset_block_context(&mut self) {
615        self.list_stack.clear();
616        self.table_header_printed = false;
617    }
618
619    fn wrap_ansi(&self, text: &str, width: usize) -> Vec<String> {
620        let mut lines = Vec::new();
621        let mut current_line = String::new();
622        let mut current_len = 0;
623        let mut active_codes: Vec<String> = Vec::new();
624
625        for caps in RE_SPLIT_ANSI.captures_iter(text) {
626            let token = caps.get(1).unwrap().as_str();
627            if token.starts_with("\x1b") {
628                current_line.push_str(token);
629                // If it's an OSC8 link sequence, it has no visible width.
630                // update_ansi_state already ignores it for state tracking, but we must
631                // ensure we don't accidentally treat it as visible text below.
632                self.update_ansi_state(&mut active_codes, token);
633            } else {
634                let mut token_str = token;
635                let mut token_len = UnicodeWidthStr::width(token_str);
636
637                while current_len + token_len > width && width > 0 {
638                    if current_len == 0 {
639                        // Force split long word
640                        let mut split_idx = 0;
641                        let mut split_len = 0;
642                        for (idx, c) in token_str.char_indices() {
643                            let c_w = UnicodeWidthStr::width(c.to_string().as_str());
644                            if split_len + c_w > width {
645                                break;
646                            }
647                            split_idx = idx + c.len_utf8();
648                            split_len += c_w;
649                        }
650                        if split_idx == 0 {
651                            split_idx = token_str.chars().next().map_or(0, |c| c.len_utf8());
652                        }
653                        if split_idx == 0 {
654                            break;
655                        } // Empty string safety
656
657                        current_line.push_str(&token_str[..split_idx]);
658                        lines.push(current_line);
659                        current_line = active_codes.join("");
660                        token_str = &token_str[split_idx..];
661                        token_len = UnicodeWidthStr::width(token_str);
662                        current_len = 0;
663                    } else if !token_str.trim().is_empty() {
664                        lines.push(current_line);
665                        current_line = active_codes.join("");
666                        current_len = 0;
667                    } else {
668                        token_str = "";
669                        token_len = 0;
670                    }
671                }
672                if !token_str.is_empty() {
673                    current_line.push_str(token_str);
674                    current_len += token_len;
675                }
676            }
677        }
678        if !current_line.is_empty() {
679            lines.push(current_line);
680        }
681        lines
682    }
683
684    fn update_ansi_state(&self, state: &mut Vec<String>, code: &str) {
685        if RE_OSC8.is_match(code) {
686            return;
687        }
688        if let Some(caps) = RE_ANSI_PARTS.captures(code) {
689            let content = caps.get(1).map_or("", |m| m.as_str());
690            if content == "0" || content.is_empty() {
691                state.clear();
692                return;
693            }
694
695            let num: i32 = content
696                .split(';')
697                .next()
698                .unwrap_or("0")
699                .parse()
700                .unwrap_or(0);
701            let category = match num {
702                1 | 22 => "bold",
703                3 | 23 => "italic",
704                4 | 24 => "underline",
705                30..=39 | 90..=97 => "fg",
706                40..=49 | 100..=107 => "bg",
707                _ => "other",
708            };
709            if category != "other" {
710                state.retain(|exist| {
711                    let e_num: i32 = RE_ANSI_PARTS
712                        .captures(exist)
713                        .and_then(|c| c.get(1))
714                        .map_or("0", |m| m.as_str())
715                        .split(';')
716                        .next()
717                        .unwrap_or("0")
718                        .parse()
719                        .unwrap_or(0);
720                    let e_cat = match e_num {
721                        1 | 22 => "bold",
722                        3 | 23 => "italic",
723                        4 | 24 => "underline",
724                        30..=39 | 90..=97 => "fg",
725                        40..=49 | 100..=107 => "bg",
726                        _ => "other",
727                    };
728                    e_cat != category
729                });
730            }
731            state.push(code.to_string());
732        }
733    }
734
735    fn render_code_line<W: Write>(&mut self, w: &mut W, line: &str) -> io::Result<()> {
736        let raw_line = line.trim_end_matches(&['\r', '\n'][..]);
737
738        let fence_indent = self.active_fence.map(|(_, _, i)| i).unwrap_or(0);
739
740        // Strip the fence's indentation from the content line
741        let mut chars = raw_line.chars();
742        let mut skipped = 0;
743        while skipped < fence_indent {
744            let as_str = chars.as_str();
745            if as_str.starts_with(' ') {
746                chars.next();
747                skipped += 1;
748            } else {
749                break;
750            }
751        }
752        let line_content = chars.as_str();
753
754        let mut prefix = " ".repeat(self.margin);
755        if !self.list_stack.is_empty() {
756            let indent_width: usize = self.list_stack.iter().map(|(_, _, _, w)| *w).sum();
757            prefix.push_str(&" ".repeat(indent_width));
758        }
759
760        let avail_width = self.get_width().saturating_sub(prefix.len() + self.margin);
761
762        let mut spans = Vec::new();
763        if let Some(h) = &mut self.highlighter {
764            if let Ok(ranges) = h.highlight_line(line_content, &SYNTAX_SET) {
765                spans = ranges;
766            } else {
767                spans.push((syntect::highlighting::Style::default(), line_content));
768            }
769        } else {
770            spans.push((syntect::highlighting::Style::default(), line_content));
771        }
772
773        // 1. Build the full colored line in memory first
774        self.scratch_buffer.clear();
775        for (style, text) in spans {
776            let _ = write!(
777                self.scratch_buffer,
778                "\x1b[38;2;{};{};{}m{}",
779                style.foreground.r, style.foreground.g, style.foreground.b, text
780            );
781        }
782
783        // 2. Wrap the colored string manually
784        let wrapped_lines = self.wrap_ansi(&self.scratch_buffer, avail_width);
785
786        // 3. Render each wrapped segment with consistent background
787        if wrapped_lines.is_empty() {
788            queue!(
789                w,
790                Print(&prefix),
791                SetBackgroundColor(Color::Rgb {
792                    r: 30,
793                    g: 30,
794                    b: 30
795                }),
796                Print(" ".repeat(avail_width)),
797                ResetColor,
798                Print("\n")
799            )?;
800        } else {
801            for line in wrapped_lines {
802                let vis_len = self.visible_width(&line);
803                let pad = avail_width.saturating_sub(vis_len);
804
805                queue!(
806                    w,
807                    Print(&prefix),
808                    SetBackgroundColor(Color::Rgb {
809                        r: 30,
810                        g: 30,
811                        b: 30
812                    }),
813                    Print(&line),
814                    Print(" ".repeat(pad)), // Fill remaining width with bg color
815                    ResetColor,
816                    Print("\n")
817                )?;
818            }
819        }
820
821        Ok(())
822    }
823
824    fn render_stream_table_row<W: Write>(&mut self, w: &mut W, row_str: &str) -> io::Result<()> {
825        let term_width = self.get_width();
826        let cells: Vec<&str> = row_str.trim().trim_matches('|').split('|').collect();
827        if cells.is_empty() {
828            return Ok(());
829        }
830
831        let prefix_width = self.margin + (self.blockquote_depth * 2);
832        let avail = term_width.saturating_sub(prefix_width + self.margin + 1 + (cells.len() * 3));
833        if avail == 0 {
834            return Ok(());
835        }
836        let base_w = avail / cells.len();
837        let rem = avail % cells.len();
838
839        let bg = if !self.table_header_printed {
840            Color::Rgb {
841                r: 60,
842                g: 60,
843                b: 80,
844            }
845        } else {
846            Color::Rgb {
847                r: 30,
848                g: 30,
849                b: 30,
850            }
851        };
852        let mut wrapped_cells = Vec::new();
853        let mut max_h = 1;
854
855        for (i, cell) in cells.iter().enumerate() {
856            let width = std::cmp::max(
857                1,
858                if i == cells.len() - 1 {
859                    base_w + rem
860                } else {
861                    base_w
862                },
863            );
864            self.scratch_buffer.clear();
865            if !self.table_header_printed {
866                self.scratch_buffer.push_str("\x1b[1;33m");
867            }
868            self.render_inline(
869                cell.trim(),
870                Some(bg),
871                if !self.table_header_printed {
872                    Some("\x1b[1;33m")
873                } else {
874                    None
875                },
876            );
877            if !self.table_header_printed {
878                self.scratch_buffer.push_str("\x1b[0m");
879            }
880
881            let lines = self.wrap_ansi(&self.scratch_buffer, width);
882            if lines.len() > max_h {
883                max_h = lines.len();
884            }
885            wrapped_cells.push((lines, width));
886        }
887
888        let mut prefix = " ".repeat(self.margin);
889        if self.blockquote_depth > 0 {
890            prefix.push_str("\x1b[38;5;240m");
891            for _ in 0..self.blockquote_depth {
892                prefix.push_str("│ ");
893            }
894            prefix.push_str("\x1b[0m");
895        }
896
897        for i in 0..max_h {
898            queue!(w, Print(&prefix))?;
899            for (col, (lines, width)) in wrapped_cells.iter().enumerate() {
900                let text = lines.get(i).map(|s| s.as_str()).unwrap_or("");
901                let pad = width.saturating_sub(self.visible_width(text));
902                queue!(
903                    w,
904                    SetBackgroundColor(bg),
905                    Print(" "),
906                    Print(text),
907                    SetBackgroundColor(bg),
908                    Print(" ".repeat(pad + 1)),
909                    ResetColor
910                )?;
911                if col < cells.len() - 1 {
912                    queue!(
913                        w,
914                        SetBackgroundColor(bg),
915                        SetForegroundColor(Color::White),
916                        Print("│"),
917                        ResetColor
918                    )?;
919                }
920            }
921            queue!(w, Print("\n"))?;
922        }
923        self.table_header_printed = true;
924        Ok(())
925    }
926
927    pub fn render_inline(&mut self, text: &str, def_bg: Option<Color>, restore_fg: Option<&str>) {
928        // Pre-process links
929        let text_linked = RE_LINK.replace_all(text, |c: &regex::Captures| {
930            format!(
931                "\x1b]8;;{}\x1b\\\x1b[33;4m{}\x1b[24;39m\x1b]8;;\x1b\\",
932                &c[2], &c[1]
933            )
934        });
935
936        let mut parts: Vec<InlinePart> = Vec::new();
937        let caps_iter = RE_TOKENIZER.captures_iter(&text_linked);
938        let tokens_raw: Vec<&str> = caps_iter.map(|c| c.get(1).unwrap().as_str()).collect();
939
940        // Pass 1: Build basic tokens
941        for (i, tok) in tokens_raw.iter().enumerate() {
942            if self.inline_code_ticks.is_some() {
943                if tok.starts_with('`') {
944                    if let Some(n) = self.inline_code_ticks {
945                        if n == tok.len() {
946                            let formatted = self.format_inline_code(def_bg, restore_fg);
947                            parts.push(InlinePart::text(formatted));
948                            self.inline_code_ticks = None;
949                            self.inline_code_buffer.clear();
950                        } else {
951                            self.inline_code_buffer.push_str(tok);
952                        }
953                    }
954                } else {
955                    self.inline_code_buffer.push_str(tok);
956                }
957                continue;
958            }
959
960            if tok.starts_with('`') {
961                self.inline_code_ticks = Some(tok.len());
962                self.inline_code_buffer.clear();
963                continue;
964            }
965
966            if tok.starts_with('\\') && tok.len() > 1 {
967                parts.push(InlinePart::text(tok[1..].to_string()));
968                continue;
969            }
970
971            if tok.starts_with('$') && tok.ends_with('$') && tok.len() > 1 {
972                parts.push(InlinePart::text(unicodeit::replace(&tok[1..tok.len() - 1])));
973                continue;
974            }
975
976            if let Some(c) = tok.chars().next()
977                && (c == '*' || c == '_' || c == '~')
978            {
979                let prev_char = if i > 0 {
980                    tokens_raw[i - 1].chars().last().unwrap_or(' ')
981                } else {
982                    ' '
983                };
984                let next_char = if i + 1 < tokens_raw.len() {
985                    tokens_raw[i + 1].chars().next().unwrap_or(' ')
986                } else {
987                    ' '
988                };
989
990                // Inline Flanking Logic (Optimization #3: Lazy Calculation)
991                let is_ws_next = next_char.is_whitespace();
992                let is_ws_prev = prev_char.is_whitespace();
993                let is_punct_next = !next_char.is_alphanumeric() && !is_ws_next;
994                let is_punct_prev = !prev_char.is_alphanumeric() && !is_ws_prev;
995                let left_flanking =
996                    !is_ws_next && (!is_punct_next || (is_ws_prev || is_punct_prev));
997                let right_flanking =
998                    !is_ws_prev && (!is_punct_prev || (is_ws_next || is_punct_next));
999
1000                let (can_open, can_close) = if c == '_' {
1001                    (
1002                        left_flanking && (!right_flanking || is_punct_prev),
1003                        right_flanking && (!left_flanking || is_punct_next),
1004                    )
1005                } else {
1006                    (left_flanking, right_flanking)
1007                };
1008
1009                parts.push(InlinePart {
1010                    content: tok.to_string(),
1011                    is_delim: true,
1012                    char: c,
1013                    len: tok.len(),
1014                    can_open,
1015                    can_close,
1016                    pre_style: vec![],
1017                    post_style: vec![],
1018                });
1019            } else {
1020                parts.push(InlinePart::text(tok.to_string()));
1021            }
1022        }
1023
1024        // Pass 2: Delimiter Matching (Extracted Logic)
1025        self.resolve_delimiters(&mut parts);
1026
1027        // Pass 3: Render
1028        for part in parts {
1029            for s in &part.pre_style {
1030                self.scratch_buffer.push_str(s);
1031            }
1032            self.scratch_buffer.push_str(&part.content);
1033            for s in &part.post_style {
1034                self.scratch_buffer.push_str(s);
1035            }
1036        }
1037    }
1038
1039    fn resolve_delimiters(&self, parts: &mut [InlinePart]) {
1040        let mut stack: Vec<usize> = Vec::new();
1041
1042        for i in 0..parts.len() {
1043            if !parts[i].is_delim {
1044                continue;
1045            }
1046
1047            if parts[i].can_close {
1048                // Iterate stack in reverse
1049                let mut stack_idx = stack.len();
1050                while stack_idx > 0 {
1051                    let open_pos = stack_idx - 1;
1052                    let open_idx = stack[open_pos];
1053
1054                    // Check if match is possible
1055                    if parts[open_idx].char == parts[i].char && parts[open_idx].can_open {
1056                        // Determine consumption length
1057                        // Rule 14: Use length 1 first to satisfy Italic-outer (<em><strong>...</strong></em>)
1058                        // If total length is 3 and we match 3, 1 is preferred as outer.
1059                        let use_len = if parts[i].len == 3 && parts[open_idx].len == 3 {
1060                            1
1061                        } else if parts[i].len >= 2 && parts[open_idx].len >= 2 {
1062                            2
1063                        } else {
1064                            1
1065                        };
1066
1067                        let (style_on, style_off) = match (parts[open_idx].char, use_len) {
1068                            ('~', _) => ("\x1b[9m", "\x1b[29m"),
1069                            ('_', 1) => ("\x1b[4m", "\x1b[24m"),
1070                            (_, 1) => ("\x1b[3m", "\x1b[23m"),
1071                            (_, 2) => ("\x1b[1m", "\x1b[22m"),
1072                            _ => ("", ""),
1073                        };
1074
1075                        let char_str = parts[open_idx].char.to_string();
1076
1077                        // APPLY STYLES
1078                        // CommonMark Rule 14 and delimiter pairing logic.
1079                        // Order of application depends on whether we match inner or outer pairs first.
1080                        // For openers: post_style is closer to the text (inner).
1081                        // For closers: pre_style is closer to the text (inner).
1082
1083                        // Heuristic: Italic (len 1) is outer, Bold (len 2) is inner for ***.
1084                        if use_len == 1 {
1085                            // Italic is Outer: Outer edges.
1086                            parts[open_idx].pre_style.push(style_on.to_string());
1087                            parts[i].post_style.push(style_off.to_string());
1088                        } else {
1089                            // Bold is Inner: Inner edges (near text).
1090                            parts[open_idx].post_style.push(style_on.to_string());
1091                            parts[i].pre_style.push(style_off.to_string());
1092                        }
1093
1094                        // Consume tokens
1095                        parts[open_idx].len -= use_len;
1096                        parts[i].len -= use_len;
1097                        parts[open_idx].content = char_str.repeat(parts[open_idx].len);
1098                        parts[i].content = char_str.repeat(parts[i].len);
1099
1100                        // Stack Management
1101                        if parts[open_idx].len == 0 {
1102                            stack.remove(open_pos);
1103                            // Stack shifted, so current stack_idx is now the *next* item.
1104                            // Decrementing continues the loop correctly down the stack.
1105                            stack_idx -= 1;
1106                        } else {
1107                            // Opener still has length (e.g. *** matched ** -> * left).
1108                            // We do NOT decrement stack_idx here, effectively retrying this opener
1109                            // against the *same* closer (if closer has len) or next iteration.
1110                            // BUT: Current closer might be exhausted.
1111                        }
1112
1113                        if parts[i].len == 0 {
1114                            break;
1115                        }
1116                        // If closer still has length, we continue loop to find another opener?
1117                        // CommonMark says: "If the closer is not exhausted... continue searching...".
1118                        // So we continue the while loop.
1119                    } else {
1120                        stack_idx -= 1;
1121                    }
1122                }
1123            }
1124
1125            if parts[i].len > 0 && parts[i].can_open {
1126                stack.push(i);
1127            }
1128        }
1129    }
1130
1131    fn push_style(&mut self, active: bool, on: &str, off: &str) {
1132        self.scratch_buffer.push_str(if active { on } else { off });
1133    }
1134
1135    fn format_inline_code(&self, def_bg: Option<Color>, restore_fg: Option<&str>) -> String {
1136        // Reuse buffer for speed? No, string is small.
1137        let mut out = String::new();
1138        let norm = if self.inline_code_buffer.len() >= 2
1139            && self.inline_code_buffer.starts_with(' ')
1140            && self.inline_code_buffer.ends_with(' ')
1141            && !self.inline_code_buffer.trim().is_empty()
1142        {
1143            &self.inline_code_buffer[1..self.inline_code_buffer.len() - 1]
1144        } else {
1145            &self.inline_code_buffer
1146        };
1147
1148        let _ = write!(out, "\x1b[48;2;60;60;60m\x1b[38;2;255;255;255m{}", norm);
1149        if let Some(Color::Rgb { r, g, b }) = def_bg {
1150            let _ = write!(out, "\x1b[48;2;{};{};{}m", r, g, b);
1151        } else {
1152            out.push_str("\x1b[49m");
1153        }
1154        out.push_str(restore_fg.unwrap_or("\x1b[39m"));
1155        out
1156    }
1157
1158    fn start_highlighter(&mut self, lang: &str) {
1159        let ss = &*SYNTAX_SET;
1160        let syntax = ss
1161            .find_syntax_by_token(lang)
1162            .unwrap_or_else(|| ss.find_syntax_plain_text());
1163        self.highlighter = Some(HighlightLines::new(syntax, &THEME));
1164    }
1165}