Skip to main content

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::ops::Range;
13use std::sync::LazyLock;
14use syntect::easy::HighlightLines;
15use syntect::highlighting::{Theme, ThemeSet};
16use syntect::parsing::SyntaxSet;
17use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
18
19// --- Static Resources ---
20static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(two_face::syntax::extra_no_newlines);
21static THEME: LazyLock<Theme> = LazyLock::new(|| {
22    let ts = ThemeSet::load_defaults();
23    ts.themes
24        .get("base16-ocean.dark")
25        .or_else(|| ts.themes.values().next())
26        .expect("No themes found")
27        .clone()
28});
29
30// Regexes
31static RE_CODE_FENCE: LazyLock<Regex> =
32    LazyLock::new(|| Regex::new(r"^(\s*)([`~]{3,})(.*)$").unwrap());
33static RE_HEADER: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(#{1,6})\s+(.*)").unwrap());
34static RE_HR: LazyLock<Regex> =
35    LazyLock::new(|| Regex::new(r"^[ ]{0,3}(?:[-*_][ \t]*){3,}$").unwrap());
36static RE_LIST: LazyLock<Regex> =
37    LazyLock::new(|| Regex::new(r"^(\s*)([-*+]|\d+[.)])(?:(\s+)(.*)|$)").unwrap());
38static RE_BLOCKQUOTE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^(\s*>\s?)(.*)").unwrap());
39
40static RE_TABLE_ROW: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\|(.*)\|\s*$").unwrap());
41static RE_TABLE_SEP: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[\s\|\-\:]+$").unwrap());
42
43static RE_MATH_BLOCK: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^\s*\$\$\s*$").unwrap());
44
45// --- UI Styling ---
46const STYLE_H1: &str = "\x1b[1m";
47const STYLE_H2: &str = "\x1b[1m\x1b[94m";
48const STYLE_H3: &str = "\x1b[1m\x1b[36m";
49const STYLE_H_DEFAULT: &str = "\x1b[1m\x1b[33m";
50const STYLE_INLINE_CODE: &str = "\x1b[48;2;60;60;60m\x1b[38;2;255;255;255m";
51const STYLE_BLOCKQUOTE: &str = "\x1b[38;5;240m";
52const STYLE_LIST_BULLET: &str = "\x1b[33m";
53const STYLE_MATH: &str = "\x1b[36;3m";
54const STYLE_RESET: &str = "\x1b[0m";
55const STYLE_RESET_BG: &str = "\x1b[49m";
56const STYLE_RESET_FG: &str = "\x1b[39m";
57
58const COLOR_CODE_BG: Color = Color::Rgb {
59    r: 30,
60    g: 30,
61    b: 30,
62};
63
64// Single pattern for "Invisible" content (ANSI codes + OSC8 links) used for width calculation
65static RE_INVISIBLE: LazyLock<Regex> =
66    LazyLock::new(|| Regex::new(&format!("({}|{})", OSC8_PATTERN, ANSI_REGEX_PATTERN)).unwrap());
67
68// --- ANSI & Links ---
69// Shared pattern for OSC 8 links: \x1b]8;; ... \x1b\
70const OSC8_PATTERN: &str = r"\x1b]8;;.*?\x1b\\";
71
72// Regex allows up to 2 levels of nested brackets/parentheses
73static RE_LINK: LazyLock<Regex> = LazyLock::new(|| {
74    Regex::new(
75        r"\[((?:[^\[\]]|\[(?:[^\[\]]|\[[^\[\]]*\])*\])*)\]\(((?:[^()\s]|\((?:[^()\s]|\([^()\s]*\))*\))*)\)",
76    )
77    .unwrap()
78});
79static RE_OSC8: LazyLock<Regex> = LazyLock::new(|| Regex::new(OSC8_PATTERN).unwrap());
80
81static RE_AUTOLINK: LazyLock<Regex> =
82    LazyLock::new(|| Regex::new(r"<([a-zA-Z][a-zA-Z0-9+.-]{1,31}:[^<> \x00-\x1f]+)>").unwrap());
83
84static RE_OPAQUE: LazyLock<Regex> = LazyLock::new(|| {
85    Regex::new(&format!(
86        r"(?x)
87        (?P<code>`+) |
88        (?P<link>{}) |
89        (?P<autolink>{}) |
90        (?P<math>\$[^\$\s](?:[^\$\n]*?[^\$\s])?\$ | \$) |
91        (?P<escape>\\[\s\S]) |
92        (?P<ansi>{}|{}) |
93        (?P<delim>~~|~|\*\*\*|___|\*\*|__|\*|_)",
94        RE_LINK.as_str(),
95        RE_AUTOLINK.as_str(),
96        OSC8_PATTERN,
97        ANSI_REGEX_PATTERN
98    ))
99    .unwrap()
100});
101
102static RE_SPLIT_ANSI: LazyLock<Regex> = LazyLock::new(|| {
103    let pattern = format!(
104        "({}|{}|\\s+|[^\\s\\x1b]+)",
105        OSC8_PATTERN, ANSI_REGEX_PATTERN
106    );
107    Regex::new(&pattern).unwrap()
108});
109static RE_ANSI_PARTS: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\x1b\[([0-9;]*)m").unwrap());
110
111// --- Helper Structs ---
112
113struct BlockLayout {
114    prefix: String,
115    avail_width: usize,
116}
117
118struct ListLevel {
119    source_indent: usize,
120    marker_width: usize,
121}
122
123impl ListLevel {
124    fn new(source_indent: usize, marker_width: usize) -> Self {
125        Self {
126            source_indent,
127            marker_width,
128        }
129    }
130}
131
132struct ListContext {
133    levels: Vec<ListLevel>,
134}
135
136impl ListContext {
137    fn new() -> Self {
138        Self { levels: Vec::new() }
139    }
140
141    fn is_empty(&self) -> bool {
142        self.levels.is_empty()
143    }
144
145    fn structural_width(&self) -> usize {
146        self.levels.iter().map(|l| l.marker_width).sum()
147    }
148
149    fn parent_width(&self) -> usize {
150        if self.levels.is_empty() {
151            0
152        } else {
153            self.levels[..self.levels.len() - 1]
154                .iter()
155                .map(|l| l.marker_width)
156                .sum()
157        }
158    }
159
160    fn last_indent(&self) -> Option<usize> {
161        self.levels.last().map(|l| l.source_indent)
162    }
163
164    fn push(&mut self, source_indent: usize, marker_width: usize) {
165        self.levels
166            .push(ListLevel::new(source_indent, marker_width));
167    }
168
169    fn pop_to_indent(&mut self, indent: usize) {
170        while self.levels.last().is_some_and(|l| l.source_indent > indent) {
171            self.levels.pop();
172        }
173    }
174
175    fn update_last_marker_width(&mut self, marker_width: usize) {
176        if let Some(last) = self.levels.last_mut() {
177            last.marker_width = marker_width;
178        }
179    }
180
181    fn clear(&mut self) {
182        self.levels.clear();
183    }
184}
185
186struct InlineCodeState {
187    ticks: Option<usize>,
188    buffer: String,
189}
190
191impl InlineCodeState {
192    fn new() -> Self {
193        Self {
194            ticks: None,
195            buffer: String::new(),
196        }
197    }
198
199    fn is_active(&self) -> bool {
200        self.ticks.is_some()
201    }
202
203    fn open(&mut self, tick_count: usize) {
204        self.ticks = Some(tick_count);
205        self.buffer.clear();
206    }
207
208    fn push_content(&mut self, content: &str) {
209        self.buffer.push_str(&content.replace('\n', " "));
210    }
211
212    fn close(&mut self) -> String {
213        let result = Self::normalize_content_static(&self.buffer);
214        self.ticks = None;
215        self.buffer.clear();
216        result
217    }
218
219    fn append_space(&mut self) {
220        if self.is_active() {
221            self.buffer.push(' ');
222        }
223    }
224
225    fn normalize_content_static(s: &str) -> String {
226        if s.len() >= 2 && s.starts_with(' ') && s.ends_with(' ') && s.chars().any(|c| c != ' ') {
227            s[1..s.len() - 1].to_string()
228        } else {
229            s.to_string()
230        }
231    }
232
233    fn flush_incomplete(&self) -> Option<(usize, String)> {
234        self.ticks.map(|n| (n, self.buffer.clone()))
235    }
236
237    fn reset(&mut self) {
238        self.ticks = None;
239        self.buffer.clear();
240    }
241}
242
243enum InlineToken {
244    Text(String),
245    Delimiter {
246        char: char,
247        len: usize,
248        can_open: bool,
249        can_close: bool,
250    },
251}
252
253struct InlinePart {
254    token: InlineToken,
255    pre_style: Vec<String>,
256    post_style: Vec<String>,
257}
258
259impl InlinePart {
260    fn text(content: String) -> Self {
261        Self {
262            token: InlineToken::Text(content),
263            pre_style: vec![],
264            post_style: vec![],
265        }
266    }
267
268    fn delimiter(char: char, len: usize, can_open: bool, can_close: bool) -> Self {
269        Self {
270            token: InlineToken::Delimiter {
271                char,
272                len,
273                can_open,
274                can_close,
275            },
276            pre_style: vec![],
277            post_style: vec![],
278        }
279    }
280
281    fn content(&self) -> String {
282        match &self.token {
283            InlineToken::Text(s) => s.clone(),
284            InlineToken::Delimiter { char, len, .. } => char.to_string().repeat(*len),
285        }
286    }
287
288    fn is_delim(&self) -> bool {
289        matches!(self.token, InlineToken::Delimiter { .. })
290    }
291
292    fn delim_char(&self) -> char {
293        match &self.token {
294            InlineToken::Delimiter { char, .. } => *char,
295            _ => '\0',
296        }
297    }
298
299    fn delim_len(&self) -> usize {
300        match &self.token {
301            InlineToken::Delimiter { len, .. } => *len,
302            _ => 0,
303        }
304    }
305
306    fn can_open(&self) -> bool {
307        match &self.token {
308            InlineToken::Delimiter { can_open, .. } => *can_open,
309            _ => false,
310        }
311    }
312
313    fn can_close(&self) -> bool {
314        match &self.token {
315            InlineToken::Delimiter { can_close, .. } => *can_close,
316            _ => false,
317        }
318    }
319
320    fn consume(&mut self, amount: usize) {
321        if let InlineToken::Delimiter { len, .. } = &mut self.token {
322            *len = len.saturating_sub(amount);
323        }
324    }
325}
326
327// --- Block Classification ---
328
329#[derive(Debug, Clone, PartialEq)]
330pub enum BlockKind {
331    FenceOpen {
332        fence_char: char,
333        fence_len: usize,
334        indent: usize,
335        lang: String,
336    },
337    FenceClose,
338    FenceContent,
339    MathOpen,
340    MathClose,
341    MathContent,
342    TableSeparator,
343    TableRow,
344    Header {
345        level: usize,
346        text: String,
347    },
348    ThematicBreak,
349    ListItem {
350        indent: usize,
351        marker: String,
352        separator: String,
353        content: String,
354        is_ordered: bool,
355    },
356    BlankLine,
357    Paragraph,
358}
359
360#[derive(Debug, Clone, PartialEq)]
361pub struct ClassifiedLine {
362    pub blockquote_depth: usize,
363    pub content: String,
364    pub kind: BlockKind,
365}
366
367#[derive(Debug, Clone)]
368enum ParsedSegment {
369    /// A complete inline code span on a single line (e.g., `foo`).
370    CodeSpan {
371        range: Range<usize>,
372        delimiter_len: usize,
373    },
374    /// The opening backticks of a multi-line span.
375    CodeSpanOpener {
376        range: Range<usize>,
377        delimiter_len: usize,
378    },
379    /// Text content inside an active multi-line code span.
380    CodeSpanContent(Range<usize>),
381    /// The closing backticks of a multi-line span.
382    CodeSpanCloser {
383        range: Range<usize>,
384        delimiter_len: usize,
385    },
386    Link(Range<usize>),
387    Autolink(Range<usize>),
388    Math(Range<usize>),
389    Escape(Range<usize>),
390    Ansi(Range<usize>),
391    Delim(Range<usize>),
392    Text(Range<usize>),
393}
394
395fn find_backtick_closer(text: &str, n: usize) -> Option<usize> {
396    let bytes = text.as_bytes();
397    let mut i = 0;
398    while i < bytes.len() {
399        if bytes[i] == b'`' {
400            let mut count = 0;
401            while i + count < bytes.len() && bytes[i + count] == b'`' {
402                count += 1;
403            }
404            if count == n {
405                return Some(i);
406            }
407            i += count;
408        } else {
409            i += 1;
410        }
411    }
412    None
413}
414
415fn parse_segments(text: &str, active_ticks: Option<usize>) -> Vec<ParsedSegment> {
416    let mut segments = Vec::new();
417    let mut pos = 0;
418
419    // Handle active multi-line code span
420    if let Some(n) = active_ticks {
421        if let Some(close_idx) = find_backtick_closer(text, n) {
422            if close_idx > 0 {
423                segments.push(ParsedSegment::CodeSpanContent(pos..close_idx));
424            }
425            let close_start = close_idx;
426            let close_end = close_idx + n;
427            segments.push(ParsedSegment::CodeSpanCloser {
428                range: close_start..close_end,
429                delimiter_len: n,
430            });
431            pos = close_end;
432        } else {
433            if !text.is_empty() {
434                segments.push(ParsedSegment::CodeSpanContent(pos..text.len()));
435            }
436            return segments;
437        }
438    }
439
440    let rest = &text[pos..];
441    let offset = pos;
442    let mut it = RE_OPAQUE.captures_iter(rest).peekable();
443    let mut last_match_end = 0;
444
445    while let Some(caps) = it.next() {
446        let m = caps.get(0).unwrap();
447        if m.start() > last_match_end {
448            segments.push(ParsedSegment::Text(
449                offset + last_match_end..offset + m.start(),
450            ));
451        }
452
453        let start = offset + m.start();
454        let mut end = offset + m.end();
455
456        if let Some(code) = caps.name("code") {
457            let ticks = code.as_str();
458            let n = ticks.len();
459            let search_start = m.end();
460            if let Some(close_idx) = find_backtick_closer(&rest[search_start..], n) {
461                end = offset + search_start + close_idx + n;
462                while it
463                    .peek()
464                    .is_some_and(|next| offset + next.get(0).unwrap().start() < end)
465                {
466                    it.next();
467                }
468                segments.push(ParsedSegment::CodeSpan {
469                    range: start..end,
470                    delimiter_len: n,
471                });
472                last_match_end = search_start + close_idx + n;
473                continue;
474            } else {
475                segments.push(ParsedSegment::CodeSpanOpener {
476                    range: start..end,
477                    delimiter_len: n,
478                });
479                if end < text.len() {
480                    segments.push(ParsedSegment::CodeSpanContent(end..text.len()));
481                }
482                return segments;
483            }
484        } else if caps.name("link").is_some() {
485            segments.push(ParsedSegment::Link(start..end));
486        } else if caps.name("autolink").is_some() {
487            segments.push(ParsedSegment::Autolink(start..end));
488        } else if caps.name("math").is_some() {
489            segments.push(ParsedSegment::Math(start..end));
490        } else if caps.name("escape").is_some() {
491            segments.push(ParsedSegment::Escape(start..end));
492        } else if caps.name("ansi").is_some() {
493            segments.push(ParsedSegment::Ansi(start..end));
494        } else if caps.name("delim").is_some() {
495            segments.push(ParsedSegment::Delim(start..end));
496        }
497        last_match_end = m.end();
498    }
499
500    if offset + last_match_end < text.len() {
501        segments.push(ParsedSegment::Text(offset + last_match_end..text.len()));
502    }
503    segments
504}
505
506fn split_table_row<'a>(text: &'a str, segments: &[ParsedSegment]) -> Vec<&'a str> {
507    let mut cells = Vec::new();
508    let mut start = 0;
509
510    for seg in segments {
511        if let ParsedSegment::Text(range) = seg {
512            for (i, c) in text[range.clone()].char_indices() {
513                if c == '|' {
514                    cells.push(&text[start..range.start + i]);
515                    start = range.start + i + 1;
516                }
517            }
518        }
519    }
520    cells.push(&text[start..]);
521    cells
522}
523
524pub struct MarkdownStreamer {
525    // Code State
526    active_fence: Option<(char, usize, usize)>, // char, min_len, indent
527    code_lang: String,
528
529    // Inline Code State
530    inline_code: InlineCodeState,
531
532    // Math State
533    in_math_block: bool,
534    math_buffer: String,
535
536    // Table State
537    in_table: bool,
538    table_header_printed: bool,
539
540    // Parsing State
541    highlighter: Option<HighlightLines<'static>>,
542    line_buffer: String,
543
544    // Layout State
545    margin: usize,
546    blockquote_depth: usize,
547    list_context: ListContext,
548    pending_newline: bool,
549
550    // Configuration
551    manual_width: Option<usize>,
552
553    // Reusable buffer
554    scratch_buffer: String,
555}
556
557impl Default for MarkdownStreamer {
558    fn default() -> Self {
559        Self::new()
560    }
561}
562
563impl MarkdownStreamer {
564    pub fn new() -> Self {
565        Self {
566            active_fence: None,
567            code_lang: "bash".to_string(),
568            inline_code: InlineCodeState::new(),
569            in_math_block: false,
570            math_buffer: String::new(),
571            in_table: false,
572            table_header_printed: false,
573            highlighter: None,
574            line_buffer: String::new(),
575            margin: 2,
576            blockquote_depth: 0,
577            list_context: ListContext::new(),
578            pending_newline: false,
579            manual_width: None,
580            scratch_buffer: String::with_capacity(1024),
581        }
582    }
583
584    /// Set a fixed width for rendering. If not set, terminal size is queried.
585    pub fn set_width(&mut self, width: usize) {
586        self.manual_width = Some(width);
587    }
588
589    /// Set the margin (default 2)
590    pub fn set_margin(&mut self, margin: usize) {
591        self.margin = margin;
592    }
593
594    fn get_width(&self) -> usize {
595        self.manual_width
596            .unwrap_or_else(crate::console::get_terminal_width)
597    }
598
599    fn visible_width(&self, text: &str) -> usize {
600        UnicodeWidthStr::width(RE_INVISIBLE.replace_all(text, "").as_ref())
601    }
602
603    /// Main entry point: Process a chunk of text and write to the provided writer.
604    pub fn print_chunk<W: Write>(&mut self, writer: &mut W, text: &str) -> io::Result<()> {
605        self.line_buffer.push_str(text);
606        while let Some(pos) = self.line_buffer.find('\n') {
607            let line = self.line_buffer[..pos + 1].to_string();
608            self.line_buffer.drain(..pos + 1);
609            self.process_line(writer, &line)?;
610        }
611        Ok(())
612    }
613
614    /// Flush remaining buffer (useful at end of stream).
615    pub fn flush<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
616        if !self.line_buffer.is_empty() {
617            let line = std::mem::take(&mut self.line_buffer);
618            self.process_line(writer, &line)?;
619        }
620
621        self.flush_pending_inline(writer)?;
622        self.commit_newline(writer)?;
623        writer.flush()
624    }
625
626    fn commit_newline<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
627        if self.pending_newline {
628            queue!(writer, Print("\n"))?;
629            self.pending_newline = false;
630        }
631        Ok(())
632    }
633
634    fn flush_pending_inline<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
635        if let Some((ticks, buffer)) = self.inline_code.flush_incomplete() {
636            let prefix = "`".repeat(ticks);
637            let formatted =
638                self.format_inline_code_content(&format!("{}{}", prefix, buffer), None, None);
639            queue!(writer, Print(formatted))?;
640            self.inline_code.reset();
641        }
642        Ok(())
643    }
644
645    // --- Pipeline Controller ---
646    fn process_line<W: Write>(&mut self, w: &mut W, raw_line: &str) -> io::Result<()> {
647        let expanded = self.expand_tabs(raw_line);
648        let classified = self.classify_line(&expanded);
649
650        self.blockquote_depth = classified.blockquote_depth;
651        self.update_block_state(&classified);
652
653        match classified.kind {
654            BlockKind::FenceOpen { lang, .. } => {
655                self.render_fence_open(w, &lang)?;
656            }
657            BlockKind::FenceClose => {
658                self.render_fence_close(w)?;
659            }
660            BlockKind::FenceContent => {
661                self.render_code_line(w, &expanded)?;
662            }
663            BlockKind::MathOpen => {
664                self.render_math_open(w)?;
665            }
666            BlockKind::MathClose => {
667                self.render_math_close(w)?;
668            }
669            BlockKind::MathContent => {
670                let trimmed = expanded.trim_end();
671                self.render_math_content(trimmed);
672            }
673            BlockKind::TableSeparator => {
674                // Separator line is consumed (sets table_header_printed in update_block_state)
675            }
676            BlockKind::TableRow => {
677                let trimmed = expanded.trim_end();
678                self.render_stream_table_row(w, trimmed)?;
679            }
680            BlockKind::Header { level, text } => {
681                let layout = self.compute_block_layout();
682                self.render_header(w, level, &text, &layout)?;
683            }
684            BlockKind::ThematicBreak => {
685                let layout = self.compute_block_layout();
686                self.render_thematic_break(w, &layout)?;
687            }
688            BlockKind::ListItem {
689                indent,
690                marker,
691                separator,
692                content,
693                is_ordered,
694            } => {
695                let layout = self.compute_block_layout();
696                self.render_list_item(
697                    w, indent, &marker, &separator, &content, is_ordered, &layout,
698                )?;
699            }
700            BlockKind::BlankLine | BlockKind::Paragraph => {
701                let layout = self.compute_block_layout();
702                self.render_standard_text(w, &classified.content, &layout)?;
703            }
704        }
705        Ok(())
706    }
707
708    // --- Pure Renderers ---
709
710    fn render_fence_open<W: Write>(&mut self, w: &mut W, lang: &str) -> io::Result<()> {
711        self.flush_pending_inline(w)?;
712        self.commit_newline(w)?;
713        self.start_highlighter(lang);
714        Ok(())
715    }
716
717    fn render_fence_close<W: Write>(&mut self, w: &mut W) -> io::Result<()> {
718        self.commit_newline(w)?;
719        queue!(w, ResetColor)?;
720        self.pending_newline = true;
721        Ok(())
722    }
723
724    fn render_math_open<W: Write>(&mut self, w: &mut W) -> io::Result<()> {
725        self.flush_pending_inline(w)?;
726        self.commit_newline(w)?;
727        Ok(())
728    }
729
730    fn render_math_close<W: Write>(&mut self, w: &mut W) -> io::Result<()> {
731        let converted = unicodeit::replace(&self.math_buffer);
732        let p_width = self.margin + (self.blockquote_depth * 2);
733        let avail = self.get_width().saturating_sub(p_width + self.margin);
734        let padding = avail.saturating_sub(self.visible_width(&converted)) / 2;
735
736        self.commit_newline(w)?;
737        queue!(
738            w,
739            Print(" ".repeat(self.margin + padding)),
740            Print(STYLE_MATH),
741            Print(converted),
742            Print(STYLE_RESET)
743        )?;
744        self.pending_newline = true;
745        self.math_buffer.clear();
746        Ok(())
747    }
748
749    fn render_math_content(&mut self, trimmed: &str) {
750        self.math_buffer.push_str(trimmed);
751        self.math_buffer.push(' ');
752    }
753
754    fn compute_block_layout(&self) -> BlockLayout {
755        let prefix = self.build_block_prefix();
756        let term_width = self.get_width();
757        let prefix_width = self.margin + (self.blockquote_depth * 2);
758        let avail_width = term_width.saturating_sub(prefix_width + self.margin);
759        BlockLayout {
760            prefix,
761            avail_width,
762        }
763    }
764
765    fn render_header<W: Write>(
766        &mut self,
767        w: &mut W,
768        level: usize,
769        text: &str,
770        layout: &BlockLayout,
771    ) -> io::Result<()> {
772        self.flush_pending_inline(w)?;
773        self.commit_newline(w)?;
774
775        let prefix = &layout.prefix;
776        let avail = layout.avail_width;
777
778        queue!(w, Print(prefix))?;
779        if level <= 2 {
780            queue!(w, Print("\n"))?;
781        }
782
783        self.scratch_buffer.clear();
784        let style = match level {
785            1 => STYLE_H1,
786            2 => STYLE_H2,
787            3 => STYLE_H3,
788            _ => STYLE_H_DEFAULT,
789        };
790        self.render_inline(text, None, Some(style));
791
792        if level <= 2 {
793            let lines = self.wrap_ansi(&self.scratch_buffer, avail);
794            for (i, line) in lines.iter().enumerate() {
795                let pad = avail.saturating_sub(self.visible_width(line)) / 2;
796                if i > 0 {
797                    queue!(w, Print("\n"), Print(prefix))?;
798                }
799                queue!(
800                    w,
801                    Print(" ".repeat(pad)),
802                    Print(format!("{}{}{}", style, line, STYLE_RESET)),
803                    ResetColor
804                )?;
805            }
806            if level == 1 {
807                queue!(w, Print("\n"), Print(prefix), Print("─".repeat(avail)))?;
808            }
809            self.pending_newline = true;
810        } else {
811            queue!(
812                w,
813                Print(style),
814                Print(&self.scratch_buffer),
815                Print(STYLE_RESET)
816            )?;
817            self.pending_newline = true;
818        }
819        Ok(())
820    }
821
822    #[allow(clippy::too_many_arguments)]
823    fn render_list_item<W: Write>(
824        &mut self,
825        w: &mut W,
826        indent: usize,
827        marker: &str,
828        separator: &str,
829        text: &str,
830        is_ordered: bool,
831        layout: &BlockLayout,
832    ) -> io::Result<()> {
833        let prefix = &layout.prefix;
834        let avail = layout.avail_width;
835
836        self.flush_pending_inline(w)?;
837        self.commit_newline(w)?;
838
839        let disp_bullet = if is_ordered { marker } else { "•" };
840        let marker_width = self.visible_width(disp_bullet) + separator.len();
841
842        let last_indent = self.list_context.last_indent().unwrap_or(0);
843        if self.list_context.is_empty() || indent > last_indent {
844            self.list_context.push(indent, marker_width);
845        } else if indent < last_indent {
846            self.list_context.pop_to_indent(indent);
847            if self.list_context.last_indent().is_some_and(|d| d != indent) {
848                self.list_context.push(indent, marker_width);
849            }
850        } else {
851            self.list_context.update_last_marker_width(marker_width);
852        }
853
854        let full_stack_width = self.list_context.structural_width();
855        let parent_width = self.list_context.parent_width();
856
857        let hang_indent = " ".repeat(full_stack_width);
858        let content_width = avail.saturating_sub(full_stack_width);
859
860        queue!(
861            w,
862            Print(prefix),
863            Print(" ".repeat(parent_width)),
864            Print(STYLE_LIST_BULLET),
865            Print(disp_bullet),
866            Print(STYLE_RESET),
867            Print(separator)
868        )?;
869
870        if let Some(fcaps) = RE_CODE_FENCE.captures(text) {
871            queue!(w, Print("\n"))?;
872
873            let fence_chars = &fcaps[2];
874            let info = fcaps[3].trim();
875
876            if let Some(f_char) = fence_chars.chars().next() {
877                self.active_fence = Some((f_char, fence_chars.len(), 0));
878
879                let lang = info.split_whitespace().next().unwrap_or("bash");
880                self.code_lang = lang.to_string();
881                self.start_highlighter(&self.code_lang.clone());
882            }
883            return Ok(());
884        }
885
886        self.scratch_buffer.clear();
887        self.render_inline(text, None, None);
888        let lines = self.wrap_ansi(&self.scratch_buffer, content_width);
889
890        if lines.is_empty() {
891            self.pending_newline = true;
892        } else {
893            for (i, line) in lines.iter().enumerate() {
894                if i > 0 {
895                    queue!(w, Print("\n"), Print(prefix), Print(&hang_indent))?;
896                }
897                queue!(w, Print(line), ResetColor)?;
898            }
899            self.pending_newline = true;
900        }
901        Ok(())
902    }
903
904    fn render_thematic_break<W: Write>(
905        &mut self,
906        w: &mut W,
907        layout: &BlockLayout,
908    ) -> io::Result<()> {
909        let prefix = &layout.prefix;
910        let avail = layout.avail_width;
911
912        self.flush_pending_inline(w)?;
913        self.commit_newline(w)?;
914        queue!(
915            w,
916            Print(prefix),
917            SetForegroundColor(Color::DarkGrey),
918            Print("─".repeat(avail)),
919            ResetColor
920        )?;
921        self.pending_newline = true;
922        Ok(())
923    }
924
925    fn render_standard_text<W: Write>(
926        &mut self,
927        w: &mut W,
928        content: &str,
929        layout: &BlockLayout,
930    ) -> io::Result<()> {
931        let prefix = &layout.prefix;
932        let avail = layout.avail_width;
933
934        self.commit_newline(w)?;
935        let mut line_content = content.trim_end_matches(['\n', '\r']);
936        if line_content.trim().is_empty() {
937            self.exit_block_context();
938            if self.blockquote_depth > 0 {
939                queue!(w, Print(prefix))?;
940            }
941            self.pending_newline = true;
942            return Ok(());
943        }
944
945        if !line_content.is_empty() || self.inline_code.is_active() {
946            let mut eff_prefix = self.build_block_prefix();
947            if !self.list_context.is_empty() {
948                let current_indent = line_content.chars().take_while(|c| *c == ' ').count();
949                if current_indent == 0 {
950                    self.list_context.clear();
951                } else {
952                    self.list_context.pop_to_indent(current_indent);
953                }
954
955                if !self.list_context.is_empty() {
956                    let structural_indent = self.list_context.structural_width();
957                    eff_prefix.push_str(&" ".repeat(structural_indent));
958
959                    // To avoid double-indenting, we skip the source indentation that matches
960                    // the structural indentation we just applied via eff_prefix.
961                    let skip = current_indent.min(structural_indent);
962                    line_content = &line_content[skip..];
963                }
964            }
965
966            self.scratch_buffer.clear();
967            if self.inline_code.is_active() {
968                self.inline_code.append_space();
969            }
970            self.render_inline(line_content, None, None);
971
972            let lines = self.wrap_ansi(&self.scratch_buffer, avail);
973            let has_visible_content = self.visible_width(&self.scratch_buffer) > 0;
974
975            for (i, line) in lines.iter().enumerate() {
976                if i > 0 {
977                    queue!(w, Print("\n"))?;
978                }
979                queue!(
980                    w,
981                    ResetColor,
982                    SetAttribute(Attribute::Reset),
983                    Print(&eff_prefix),
984                    Print(line),
985                    ResetColor
986                )?;
987            }
988            if !lines.is_empty() && has_visible_content {
989                self.pending_newline = true;
990            }
991        }
992        Ok(())
993    }
994
995    fn update_block_state(&mut self, classified: &ClassifiedLine) {
996        match &classified.kind {
997            BlockKind::FenceOpen {
998                fence_char,
999                fence_len,
1000                indent,
1001                lang,
1002            } => {
1003                self.list_context.pop_to_indent(*indent);
1004                self.active_fence = Some((*fence_char, *fence_len, *indent));
1005                self.code_lang = lang.clone();
1006            }
1007            BlockKind::FenceClose => {
1008                self.active_fence = None;
1009            }
1010            BlockKind::FenceContent => {}
1011            BlockKind::MathOpen => {
1012                self.exit_block_context();
1013                self.in_math_block = true;
1014            }
1015            BlockKind::MathClose => {
1016                self.in_math_block = false;
1017            }
1018            BlockKind::MathContent => {}
1019            BlockKind::TableSeparator => {
1020                self.table_header_printed = true;
1021            }
1022            BlockKind::TableRow => {
1023                if !self.in_table {
1024                    self.exit_block_context();
1025                    self.in_table = true;
1026                }
1027            }
1028            BlockKind::Header { .. } => {
1029                self.exit_block_context();
1030            }
1031            BlockKind::ThematicBreak => {
1032                self.exit_block_context();
1033            }
1034            BlockKind::ListItem { .. } => {
1035                self.in_table = false;
1036                self.table_header_printed = false;
1037            }
1038            BlockKind::BlankLine | BlockKind::Paragraph => {
1039                self.in_table = false;
1040                self.table_header_printed = false;
1041            }
1042        }
1043    }
1044
1045    fn exit_block_context(&mut self) {
1046        self.list_context.clear();
1047        self.in_table = false;
1048        self.table_header_printed = false;
1049    }
1050
1051    fn wrap_ansi(&self, text: &str, width: usize) -> Vec<String> {
1052        let mut lines = Vec::new();
1053        let mut current_line = String::new();
1054        let mut current_len = 0;
1055        let mut active_codes: Vec<String> = Vec::new();
1056
1057        for caps in RE_SPLIT_ANSI.captures_iter(text) {
1058            let token = caps.get(1).unwrap().as_str();
1059            if token.starts_with("\x1b") {
1060                current_line.push_str(token);
1061                self.update_ansi_state(&mut active_codes, token);
1062            } else {
1063                let mut token_str = token;
1064                let mut token_len = UnicodeWidthStr::width(token_str);
1065
1066                while current_len + token_len > width && width > 0 {
1067                    if current_len == 0 {
1068                        // Force split long word
1069                        let mut split_idx = 0;
1070                        let mut split_len = 0;
1071                        for (idx, c) in token_str.char_indices() {
1072                            let c_w = c.width().unwrap_or(0);
1073                            if split_len + c_w > width {
1074                                break;
1075                            }
1076                            split_idx = idx + c.len_utf8();
1077                            split_len += c_w;
1078                        }
1079                        if split_idx == 0 {
1080                            split_idx = token_str.chars().next().map_or(0, |c| c.len_utf8());
1081                        }
1082                        if split_idx == 0 {
1083                            break;
1084                        }
1085
1086                        current_line.push_str(&token_str[..split_idx]);
1087                        lines.push(current_line);
1088                        current_line = active_codes.join("");
1089                        token_str = &token_str[split_idx..];
1090                        token_len = UnicodeWidthStr::width(token_str);
1091                        current_len = 0;
1092                    } else if !token_str.trim().is_empty() {
1093                        lines.push(current_line);
1094                        current_line = active_codes.join("");
1095                        current_len = 0;
1096                    } else {
1097                        token_str = "";
1098                        token_len = 0;
1099                    }
1100                }
1101                if !token_str.is_empty() {
1102                    current_line.push_str(token_str);
1103                    current_len += token_len;
1104                }
1105            }
1106        }
1107        if !current_line.is_empty() {
1108            lines.push(current_line);
1109        }
1110        lines
1111    }
1112
1113    fn update_ansi_state(&self, state: &mut Vec<String>, code: &str) {
1114        if RE_OSC8.is_match(code) {
1115            return;
1116        }
1117        if let Some(caps) = RE_ANSI_PARTS.captures(code) {
1118            let content = caps.get(1).map_or("", |m| m.as_str());
1119            if content == "0" || content.is_empty() {
1120                state.clear();
1121                return;
1122            }
1123
1124            let num: i32 = content
1125                .split(';')
1126                .next()
1127                .unwrap_or("0")
1128                .parse()
1129                .unwrap_or(0);
1130            let category = match num {
1131                1 | 22 => "bold",
1132                3 | 23 => "italic",
1133                4 | 24 => "underline",
1134                30..=39 | 90..=97 => "fg",
1135                40..=49 | 100..=107 => "bg",
1136                _ => "other",
1137            };
1138            if category != "other" {
1139                state.retain(|exist| {
1140                    let e_num: i32 = RE_ANSI_PARTS
1141                        .captures(exist)
1142                        .and_then(|c| c.get(1))
1143                        .map_or("0", |m| m.as_str())
1144                        .split(';')
1145                        .next()
1146                        .unwrap_or("0")
1147                        .parse()
1148                        .unwrap_or(0);
1149                    let e_cat = match e_num {
1150                        1 | 22 => "bold",
1151                        3 | 23 => "italic",
1152                        4 | 24 => "underline",
1153                        30..=39 | 90..=97 => "fg",
1154                        40..=49 | 100..=107 => "bg",
1155                        _ => "other",
1156                    };
1157                    e_cat != category
1158                });
1159            }
1160            state.push(code.to_string());
1161        }
1162    }
1163
1164    fn render_code_line<W: Write>(&mut self, w: &mut W, line: &str) -> io::Result<()> {
1165        self.commit_newline(w)?;
1166        let raw_line = line.trim_end_matches(&['\r', '\n'][..]);
1167
1168        let fence_indent = self.active_fence.map(|(_, _, i)| i).unwrap_or(0);
1169
1170        // Strip the fence's indentation from the content line (Spec §4.5)
1171        let skip = raw_line
1172            .chars()
1173            .take(fence_indent)
1174            .take_while(|&c| c == ' ')
1175            .count();
1176        let line_content = &raw_line[skip..];
1177
1178        let mut prefix = " ".repeat(self.margin);
1179        if !self.list_context.is_empty() {
1180            let indent_width = self.list_context.structural_width();
1181            prefix.push_str(&" ".repeat(indent_width));
1182        }
1183
1184        let avail_width = self.get_width().saturating_sub(prefix.len() + self.margin);
1185
1186        let mut spans = Vec::new();
1187        if let Some(h) = &mut self.highlighter {
1188            if let Ok(ranges) = h.highlight_line(line_content, &SYNTAX_SET) {
1189                spans = ranges;
1190            } else {
1191                spans.push((syntect::highlighting::Style::default(), line_content));
1192            }
1193        } else {
1194            spans.push((syntect::highlighting::Style::default(), line_content));
1195        }
1196
1197        // 1. Build the full colored line in memory first
1198        self.scratch_buffer.clear();
1199        for (style, text) in spans {
1200            let _ = write!(
1201                self.scratch_buffer,
1202                "\x1b[38;2;{};{};{}m{}",
1203                style.foreground.r, style.foreground.g, style.foreground.b, text
1204            );
1205        }
1206
1207        // 2. Determine if we need to wrap
1208        let content_width = self.visible_width(line_content);
1209
1210        if content_width <= avail_width {
1211            // Fits in one line: Print directly
1212            let pad = avail_width.saturating_sub(content_width);
1213            queue!(
1214                w,
1215                Print(&prefix),
1216                SetBackgroundColor(COLOR_CODE_BG),
1217                Print(&self.scratch_buffer),
1218                Print(" ".repeat(pad)),
1219                ResetColor
1220            )?;
1221        } else {
1222            // Needs wrapping
1223            let wrapped_lines = self.wrap_ansi(&self.scratch_buffer, avail_width);
1224
1225            if wrapped_lines.is_empty() {
1226                queue!(
1227                    w,
1228                    Print(&prefix),
1229                    SetBackgroundColor(COLOR_CODE_BG),
1230                    Print(" ".repeat(avail_width)),
1231                    ResetColor
1232                )?;
1233            } else {
1234                for (i, line) in wrapped_lines.iter().enumerate() {
1235                    if i > 0 {
1236                        queue!(w, Print("\n"))?;
1237                    }
1238                    let vis_len = self.visible_width(line);
1239                    let pad = avail_width.saturating_sub(vis_len);
1240
1241                    queue!(
1242                        w,
1243                        Print(&prefix),
1244                        SetBackgroundColor(COLOR_CODE_BG),
1245                        Print(line),
1246                        Print(" ".repeat(pad)),
1247                        ResetColor
1248                    )?;
1249                }
1250            }
1251        }
1252        self.pending_newline = true;
1253        Ok(())
1254    }
1255
1256    fn render_stream_table_row<W: Write>(&mut self, w: &mut W, row_str: &str) -> io::Result<()> {
1257        self.flush_pending_inline(w)?;
1258        self.commit_newline(w)?;
1259        let term_width = self.get_width();
1260
1261        let trimmed_row = row_str.trim().trim_matches('|');
1262        let segments = parse_segments(trimmed_row, None);
1263        let cells = split_table_row(trimmed_row, &segments);
1264
1265        if cells.is_empty() {
1266            return Ok(());
1267        }
1268
1269        let prefix_width = self.margin + (self.blockquote_depth * 2);
1270        let cell_overhead = (cells.len() * 3).saturating_sub(1);
1271        let avail = term_width.saturating_sub(prefix_width + self.margin + cell_overhead);
1272        if avail == 0 {
1273            return Ok(());
1274        }
1275        let base_w = avail / cells.len();
1276        let rem = avail % cells.len();
1277
1278        let bg = if !self.table_header_printed {
1279            Color::Rgb {
1280                r: 60,
1281                g: 60,
1282                b: 80,
1283            }
1284        } else {
1285            COLOR_CODE_BG
1286        };
1287        let mut wrapped_cells = Vec::new();
1288        let mut max_h = 1;
1289
1290        for (i, cell) in cells.iter().enumerate() {
1291            let width = std::cmp::max(
1292                1,
1293                if i == cells.len() - 1 {
1294                    base_w + rem
1295                } else {
1296                    base_w
1297                },
1298            );
1299            self.scratch_buffer.clear();
1300            if !self.table_header_printed {
1301                self.scratch_buffer.push_str("\x1b[1;33m");
1302            }
1303            self.render_inline(
1304                cell.trim(),
1305                Some(bg),
1306                if !self.table_header_printed {
1307                    Some("\x1b[1;33m")
1308                } else {
1309                    None
1310                },
1311            );
1312            if !self.table_header_printed {
1313                self.scratch_buffer.push_str("\x1b[0m");
1314            }
1315
1316            let lines = self.wrap_ansi(&self.scratch_buffer, width);
1317            if lines.len() > max_h {
1318                max_h = lines.len();
1319            }
1320            wrapped_cells.push((lines, width));
1321        }
1322
1323        let prefix = self.build_block_prefix();
1324
1325        for i in 0..max_h {
1326            if i > 0 {
1327                queue!(w, Print("\n"))?;
1328            }
1329            queue!(w, Print(&prefix))?;
1330            for (col, (lines, width)) in wrapped_cells.iter().enumerate() {
1331                let text = lines.get(i).map(|s| s.as_str()).unwrap_or("");
1332                let pad = width.saturating_sub(self.visible_width(text));
1333                queue!(
1334                    w,
1335                    SetBackgroundColor(bg),
1336                    Print(" "),
1337                    Print(text),
1338                    SetBackgroundColor(bg),
1339                    Print(" ".repeat(pad + 1)),
1340                    ResetColor
1341                )?;
1342                if col < cells.len() - 1 {
1343                    queue!(
1344                        w,
1345                        SetBackgroundColor(bg),
1346                        SetForegroundColor(Color::White),
1347                        Print("│"),
1348                        ResetColor
1349                    )?;
1350                }
1351            }
1352        }
1353        self.pending_newline = true;
1354        Ok(())
1355    }
1356
1357    pub fn render_inline(&mut self, text: &str, def_bg: Option<Color>, restore_fg: Option<&str>) {
1358        let mut parts = self.build_inline_parts(text, def_bg, restore_fg);
1359        self.resolve_delimiters(&mut parts);
1360
1361        for part in parts {
1362            for s in &part.pre_style {
1363                self.scratch_buffer.push_str(s);
1364            }
1365            self.scratch_buffer.push_str(&part.content());
1366            for s in &part.post_style {
1367                self.scratch_buffer.push_str(s);
1368            }
1369        }
1370    }
1371
1372    fn build_inline_parts(
1373        &mut self,
1374        text: &str,
1375        def_bg: Option<Color>,
1376        restore_fg: Option<&str>,
1377    ) -> Vec<InlinePart> {
1378        let active_ticks = self.inline_code.ticks;
1379        let segments = parse_segments(text, active_ticks);
1380        let mut parts: Vec<InlinePart> = Vec::new();
1381
1382        for seg in &segments {
1383            match seg {
1384                ParsedSegment::CodeSpan {
1385                    range,
1386                    delimiter_len,
1387                } => {
1388                    let n = *delimiter_len;
1389                    let content_range = range.start + n..range.end - n;
1390                    let raw_content = &text[content_range];
1391                    let normalized = InlineCodeState::normalize_content_static(raw_content);
1392                    let formatted =
1393                        self.format_inline_code_content(&normalized, def_bg, restore_fg);
1394                    parts.push(InlinePart::text(formatted));
1395                }
1396                ParsedSegment::CodeSpanOpener {
1397                    range: _,
1398                    delimiter_len,
1399                } => {
1400                    self.inline_code.open(*delimiter_len);
1401                }
1402                ParsedSegment::CodeSpanContent(range) => {
1403                    self.inline_code.push_content(&text[range.clone()]);
1404                }
1405                ParsedSegment::CodeSpanCloser {
1406                    range: _,
1407                    delimiter_len: _,
1408                } => {
1409                    let content = self.inline_code.close();
1410                    let formatted = self.format_inline_code_content(&content, def_bg, restore_fg);
1411                    parts.push(InlinePart::text(formatted));
1412                }
1413                ParsedSegment::Escape(r) => {
1414                    parts.push(InlinePart::text(text[r.start + 1..r.end].to_string()));
1415                }
1416                ParsedSegment::Math(r) => {
1417                    let tok = &text[r.clone()];
1418                    if tok.len() > 1 && tok.starts_with('$') && tok.ends_with('$') {
1419                        parts.push(InlinePart::text(unicodeit::replace(&tok[1..tok.len() - 1])));
1420                    } else {
1421                        parts.push(InlinePart::text(tok.to_string()));
1422                    }
1423                }
1424                ParsedSegment::Autolink(r) => {
1425                    let url = &text[r.start + 1..r.end - 1];
1426                    parts.push(InlinePart::text(format!(
1427                        "\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\",
1428                        url, url
1429                    )));
1430                }
1431                ParsedSegment::Link(r) => {
1432                    if let Some(caps) = RE_LINK.captures(&text[r.clone()]) {
1433                        let link_text = caps.get(1).map_or("", |m| m.as_str());
1434                        let url = caps.get(2).map_or("", |m| m.as_str());
1435                        parts.push(InlinePart::text(format!(
1436                            "\x1b]8;;{}\x1b\\\x1b[33;4m{}\x1b[24;39m\x1b]8;;\x1b\\",
1437                            url, link_text
1438                        )));
1439                    }
1440                }
1441                ParsedSegment::Ansi(r) => {
1442                    parts.push(InlinePart::text(text[r.clone()].to_string()));
1443                }
1444                ParsedSegment::Delim(r) => {
1445                    let tok = &text[r.clone()];
1446                    let c = tok.chars().next().unwrap();
1447
1448                    let prev_char = if r.start > 0 {
1449                        text[..r.start].chars().last().unwrap_or(' ')
1450                    } else {
1451                        ' '
1452                    };
1453                    let next_char = text[r.end..].chars().next().unwrap_or(' ');
1454
1455                    let is_ws_next = next_char.is_whitespace();
1456                    let is_ws_prev = prev_char.is_whitespace();
1457                    let is_punct_next = !next_char.is_alphanumeric() && !is_ws_next;
1458                    let is_punct_prev = !prev_char.is_alphanumeric() && !is_ws_prev;
1459                    let left_flanking =
1460                        !is_ws_next && (!is_punct_next || (is_ws_prev || is_punct_prev));
1461                    let right_flanking =
1462                        !is_ws_prev && (!is_punct_prev || (is_ws_next || is_punct_next));
1463
1464                    let (can_open, can_close) = if c == '_' {
1465                        (
1466                            left_flanking && (!right_flanking || is_punct_prev),
1467                            right_flanking && (!left_flanking || is_punct_next),
1468                        )
1469                    } else {
1470                        (left_flanking, right_flanking)
1471                    };
1472
1473                    parts.push(InlinePart::delimiter(c, tok.len(), can_open, can_close));
1474                }
1475                ParsedSegment::Text(r) => {
1476                    parts.push(InlinePart::text(text[r.clone()].to_string()));
1477                }
1478            }
1479        }
1480
1481        parts
1482    }
1483
1484    fn resolve_delimiters(&self, parts: &mut [InlinePart]) {
1485        let mut stack: Vec<usize> = Vec::new();
1486
1487        for i in 0..parts.len() {
1488            if !parts[i].is_delim() {
1489                continue;
1490            }
1491
1492            if parts[i].can_close() {
1493                let mut stack_idx = stack.len();
1494                while stack_idx > 0 {
1495                    let open_pos = stack_idx - 1;
1496                    let open_idx = stack[open_pos];
1497
1498                    if parts[open_idx].delim_char() == parts[i].delim_char()
1499                        && parts[open_idx].can_open()
1500                    {
1501                        // Rule 9/10: Multiple of 3 Rule
1502                        if (parts[open_idx].can_open() && parts[open_idx].can_close())
1503                            || (parts[i].can_open() && parts[i].can_close())
1504                        {
1505                            let sum = parts[open_idx].delim_len() + parts[i].delim_len();
1506                            if sum.is_multiple_of(3)
1507                                && (!parts[open_idx].delim_len().is_multiple_of(3)
1508                                    || !parts[i].delim_len().is_multiple_of(3))
1509                            {
1510                                stack_idx -= 1;
1511                                continue;
1512                            }
1513                        }
1514
1515                        // Empty emphasis check
1516                        if open_idx + 1 == i {
1517                            stack_idx -= 1;
1518                            continue;
1519                        }
1520
1521                        // Determine consumption length
1522                        let open_len = parts[open_idx].delim_len();
1523                        let close_len = parts[i].delim_len();
1524                        let use_len = if close_len == 3 && open_len == 3 {
1525                            1
1526                        } else if close_len >= 2 && open_len >= 2 {
1527                            2
1528                        } else {
1529                            1
1530                        };
1531
1532                        let (style_on, style_off) = match (parts[open_idx].delim_char(), use_len) {
1533                            ('~', _) => ("\x1b[9m", "\x1b[29m"),
1534                            ('_', 1) => ("\x1b[4m", "\x1b[24m"),
1535                            (_, 1) => ("\x1b[3m", "\x1b[23m"),
1536                            (_, 2) => ("\x1b[1m", "\x1b[22m"),
1537                            _ => ("", ""),
1538                        };
1539
1540                        // Apply styles
1541                        if use_len == 1 {
1542                            parts[open_idx].pre_style.push(style_on.to_string());
1543                            parts[i].post_style.push(style_off.to_string());
1544                        } else {
1545                            parts[open_idx].post_style.push(style_on.to_string());
1546                            parts[i].pre_style.push(style_off.to_string());
1547                        }
1548
1549                        // Consume tokens
1550                        parts[open_idx].consume(use_len);
1551                        parts[i].consume(use_len);
1552
1553                        // Stack Management
1554                        if parts[open_idx].delim_len() == 0 {
1555                            stack.remove(open_pos);
1556                            stack_idx -= 1;
1557                        }
1558
1559                        if parts[i].delim_len() == 0 {
1560                            break;
1561                        }
1562                    } else {
1563                        stack_idx -= 1;
1564                    }
1565                }
1566            }
1567
1568            if parts[i].delim_len() > 0 && parts[i].can_open() {
1569                stack.push(i);
1570            }
1571        }
1572    }
1573
1574    fn build_block_prefix(&self) -> String {
1575        let mut prefix = " ".repeat(self.margin);
1576        if self.blockquote_depth > 0 {
1577            prefix.push_str(STYLE_BLOCKQUOTE);
1578            for _ in 0..self.blockquote_depth {
1579                prefix.push_str("│ ");
1580            }
1581            prefix.push_str(STYLE_RESET);
1582        }
1583        prefix
1584    }
1585
1586    fn format_inline_code_content(
1587        &self,
1588        content: &str,
1589        def_bg: Option<Color>,
1590        restore_fg: Option<&str>,
1591    ) -> String {
1592        let mut out = String::new();
1593        let _ = write!(out, "{}{}", STYLE_INLINE_CODE, content);
1594        if let Some(Color::Rgb { r, g, b }) = def_bg {
1595            let _ = write!(out, "\x1b[48;2;{};{};{}m", r, g, b);
1596        } else {
1597            out.push_str(STYLE_RESET_BG);
1598        }
1599        out.push_str(restore_fg.unwrap_or(STYLE_RESET_FG));
1600        out
1601    }
1602
1603    fn expand_tabs(&self, line: &str) -> String {
1604        let mut expanded = String::with_capacity(line.len());
1605        let mut col = 0;
1606        for c in line.chars() {
1607            if c == '\t' {
1608                let n = 4 - (col % 4);
1609                expanded.push_str(&" ".repeat(n));
1610                col += n;
1611            } else {
1612                expanded.push(c);
1613                col += UnicodeWidthChar::width(c).unwrap_or(0);
1614            }
1615        }
1616        expanded
1617    }
1618
1619    pub fn classify_line(&self, expanded: &str) -> ClassifiedLine {
1620        let trimmed = expanded.trim_end();
1621
1622        // 1. Continuation contexts (checked before blockquote stripping)
1623
1624        // Active code fence: check for close or treat as content
1625        if let Some((f_char, min_len, _indent)) = self.active_fence {
1626            if let Some(caps) = RE_CODE_FENCE.captures(trimmed) {
1627                let fence = &caps[2];
1628                if fence.starts_with(f_char) && fence.len() >= min_len && caps[3].trim().is_empty()
1629                {
1630                    return ClassifiedLine {
1631                        blockquote_depth: 0,
1632                        content: expanded.to_string(),
1633                        kind: BlockKind::FenceClose,
1634                    };
1635                }
1636            }
1637            return ClassifiedLine {
1638                blockquote_depth: 0,
1639                content: expanded.to_string(),
1640                kind: BlockKind::FenceContent,
1641            };
1642        }
1643
1644        // Active math block
1645        if self.in_math_block {
1646            if RE_MATH_BLOCK.is_match(trimmed) {
1647                return ClassifiedLine {
1648                    blockquote_depth: 0,
1649                    content: expanded.to_string(),
1650                    kind: BlockKind::MathClose,
1651                };
1652            }
1653            return ClassifiedLine {
1654                blockquote_depth: 0,
1655                content: expanded.to_string(),
1656                kind: BlockKind::MathContent,
1657            };
1658        }
1659
1660        // Table separator (only when already in a table)
1661        if self.in_table && RE_TABLE_SEP.is_match(trimmed) {
1662            return ClassifiedLine {
1663                blockquote_depth: 0,
1664                content: expanded.to_string(),
1665                kind: BlockKind::TableSeparator,
1666            };
1667        }
1668
1669        // Table row (before blockquote stripping, matching current precedence)
1670        if RE_TABLE_ROW.is_match(trimmed) {
1671            return ClassifiedLine {
1672                blockquote_depth: 0,
1673                content: expanded.to_string(),
1674                kind: BlockKind::TableRow,
1675            };
1676        }
1677
1678        // 2. Strip blockquotes and count depth
1679        let mut content = expanded.to_string();
1680        let mut blockquote_depth = 0;
1681        loop {
1682            let trimmed_content = content.clone();
1683            if let Some(caps) = RE_BLOCKQUOTE.captures(&trimmed_content) {
1684                blockquote_depth += 1;
1685                content = caps.get(2).map_or("", |m| m.as_str()).to_string();
1686            } else {
1687                break;
1688            }
1689        }
1690
1691        let clean = content.trim_end();
1692
1693        // 3. Post-blockquote classification
1694
1695        // Code fence open
1696        if let Some(caps) = RE_CODE_FENCE.captures(clean) {
1697            let fence = &caps[2];
1698            let indent_len = caps[1].len();
1699            let info = caps[3].trim();
1700            if let Some(f_char) = fence.chars().next()
1701                && (f_char != '`' || !info.contains('`'))
1702            {
1703                let lang = info.split_whitespace().next().unwrap_or("bash").to_string();
1704                return ClassifiedLine {
1705                    blockquote_depth,
1706                    content: content.clone(),
1707                    kind: BlockKind::FenceOpen {
1708                        fence_char: f_char,
1709                        fence_len: fence.len(),
1710                        indent: indent_len,
1711                        lang,
1712                    },
1713                };
1714            }
1715        }
1716
1717        // Math block open
1718        if RE_MATH_BLOCK.is_match(clean) {
1719            return ClassifiedLine {
1720                blockquote_depth,
1721                content: content.clone(),
1722                kind: BlockKind::MathOpen,
1723            };
1724        }
1725
1726        // Header
1727        if let Some(caps) = RE_HEADER.captures(clean) {
1728            let level = caps.get(1).map_or(0, |m| m.len());
1729            let raw_text = caps.get(2).map_or("", |m| m.as_str());
1730            let text = Self::clean_atx_header_text_static(raw_text).to_string();
1731            return ClassifiedLine {
1732                blockquote_depth,
1733                content: content.clone(),
1734                kind: BlockKind::Header { level, text },
1735            };
1736        }
1737
1738        // Empty ATX heading: `## ` or `###` with only hashes and optional trailing whitespace
1739        {
1740            let stripped = clean.trim_start();
1741            let hash_count = stripped.chars().take_while(|&c| c == '#').count();
1742            if (1..=6).contains(&hash_count) {
1743                let after_hashes = &stripped[hash_count..];
1744                if after_hashes.is_empty() || after_hashes.chars().all(|c| c.is_whitespace()) {
1745                    return ClassifiedLine {
1746                        blockquote_depth,
1747                        content: content.clone(),
1748                        kind: BlockKind::Header {
1749                            level: hash_count,
1750                            text: "".to_string(),
1751                        },
1752                    };
1753                }
1754            }
1755        }
1756
1757        // Thematic break (must be checked before list to handle `* * *`)
1758        if RE_HR.is_match(clean) {
1759            let hr_chars: Vec<char> = clean.chars().filter(|c| !c.is_whitespace()).collect();
1760            if !hr_chars.is_empty() && hr_chars.iter().all(|&c| c == hr_chars[0]) {
1761                return ClassifiedLine {
1762                    blockquote_depth,
1763                    content: content.clone(),
1764                    kind: BlockKind::ThematicBreak,
1765                };
1766            }
1767        }
1768
1769        // List item
1770        if let Some(caps) = RE_LIST.captures(clean) {
1771            let indent = caps.get(1).map_or(0, |m| m.len());
1772            let marker = caps.get(2).map_or("", |m| m.as_str()).to_string();
1773            let separator = caps.get(3).map_or(" ", |m| m.as_str()).to_string();
1774            let content_text = caps.get(4).map_or("", |m| m.as_str()).to_string();
1775            let is_ordered = marker.chars().any(|c| c.is_numeric());
1776
1777            // CommonMark §4.1 ex 43: thematic break inside list item content
1778            // takes precedence over the list item
1779            if !content_text.is_empty() {
1780                let ct = content_text.trim_end();
1781                if RE_HR.is_match(ct) {
1782                    let hr_chars: Vec<char> = ct.chars().filter(|c| !c.is_whitespace()).collect();
1783                    if !hr_chars.is_empty() && hr_chars.iter().all(|&c| c == hr_chars[0]) {
1784                        return ClassifiedLine {
1785                            blockquote_depth,
1786                            content: content.clone(),
1787                            kind: BlockKind::ThematicBreak,
1788                        };
1789                    }
1790                }
1791            }
1792
1793            return ClassifiedLine {
1794                blockquote_depth,
1795                content: content.clone(),
1796                kind: BlockKind::ListItem {
1797                    indent,
1798                    marker,
1799                    separator,
1800                    content: content_text,
1801                    is_ordered,
1802                },
1803            };
1804        }
1805
1806        // Blank line
1807        if clean.is_empty() {
1808            return ClassifiedLine {
1809                blockquote_depth,
1810                content,
1811                kind: BlockKind::BlankLine,
1812            };
1813        }
1814
1815        // Paragraph (fallback)
1816        ClassifiedLine {
1817            blockquote_depth,
1818            content,
1819            kind: BlockKind::Paragraph,
1820        }
1821    }
1822
1823    fn clean_atx_header_text_static(text: &str) -> &str {
1824        let trimmed = text.trim_end();
1825        let mut end = trimmed.len();
1826        let bytes = trimmed.as_bytes();
1827        while end > 0 && bytes[end - 1] == b'#' {
1828            end -= 1;
1829        }
1830        if end == 0 {
1831            // All hashes (e.g., "###"): empty heading
1832            ""
1833        } else if end < trimmed.len() && bytes[end - 1] == b' ' {
1834            // Closing hashes preceded by space: strip both space and hashes
1835            trimmed[..end - 1].trim_end()
1836        } else {
1837            // No valid closing sequence (e.g., "foo#"): return as-is
1838            trimmed
1839        }
1840    }
1841
1842    fn start_highlighter(&mut self, lang: &str) {
1843        let ss = &*SYNTAX_SET;
1844        let syntax = ss
1845            .find_syntax_by_token(lang)
1846            .unwrap_or_else(|| ss.find_syntax_plain_text());
1847        self.highlighter = Some(HighlightLines::new(syntax, &THEME));
1848    }
1849}