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    while pos < text.len() {
441        let rest = &text[pos..];
442        if let Some(caps) = RE_OPAQUE.captures(rest) {
443            let m = caps.get(0).unwrap();
444            if m.start() > 0 {
445                segments.push(ParsedSegment::Text(pos..pos + m.start()));
446            }
447
448            let start = pos + m.start();
449            let end = pos + m.end();
450
451            if let Some(code) = caps.name("code") {
452                let ticks = code.as_str();
453                let n = ticks.len();
454                let search_start = m.end();
455                if let Some(close_idx) = find_backtick_closer(&rest[search_start..], n) {
456                    let span_end = pos + search_start + close_idx + n;
457                    segments.push(ParsedSegment::CodeSpan {
458                        range: start..span_end,
459                        delimiter_len: n,
460                    });
461                    pos = span_end;
462                } else {
463                    segments.push(ParsedSegment::CodeSpanOpener {
464                        range: start..end,
465                        delimiter_len: n,
466                    });
467                    if end < text.len() {
468                        segments.push(ParsedSegment::CodeSpanContent(end..text.len()));
469                    }
470                    return segments;
471                }
472            } else {
473                if caps.name("link").is_some() {
474                    segments.push(ParsedSegment::Link(start..end));
475                } else if caps.name("autolink").is_some() {
476                    segments.push(ParsedSegment::Autolink(start..end));
477                } else if caps.name("math").is_some() {
478                    segments.push(ParsedSegment::Math(start..end));
479                } else if caps.name("escape").is_some() {
480                    segments.push(ParsedSegment::Escape(start..end));
481                } else if caps.name("ansi").is_some() {
482                    segments.push(ParsedSegment::Ansi(start..end));
483                } else if caps.name("delim").is_some() {
484                    segments.push(ParsedSegment::Delim(start..end));
485                }
486                pos = end;
487            }
488        } else {
489            segments.push(ParsedSegment::Text(pos..text.len()));
490            break;
491        }
492    }
493
494    segments
495}
496
497fn split_table_row<'a>(text: &'a str, segments: &[ParsedSegment]) -> Vec<&'a str> {
498    let mut cells = Vec::new();
499    let mut start = 0;
500
501    for seg in segments {
502        if let ParsedSegment::Text(range) = seg {
503            for (i, c) in text[range.clone()].char_indices() {
504                if c == '|' {
505                    cells.push(&text[start..range.start + i]);
506                    start = range.start + i + 1;
507                }
508            }
509        }
510    }
511    cells.push(&text[start..]);
512    cells
513}
514
515pub struct MarkdownStreamer {
516    // Code State
517    active_fence: Option<(char, usize, usize)>, // char, min_len, indent
518    code_lang: String,
519
520    // Inline Code State
521    inline_code: InlineCodeState,
522
523    // Math State
524    in_math_block: bool,
525    math_buffer: String,
526
527    // Table State
528    in_table: bool,
529    table_header_printed: bool,
530
531    // Parsing State
532    highlighter: Option<HighlightLines<'static>>,
533    line_buffer: String,
534
535    // Layout State
536    margin: usize,
537    blockquote_depth: usize,
538    list_context: ListContext,
539    pending_newline: bool,
540
541    // Configuration
542    manual_width: Option<usize>,
543
544    // Reusable buffer
545    scratch_buffer: String,
546}
547
548impl Default for MarkdownStreamer {
549    fn default() -> Self {
550        Self::new()
551    }
552}
553
554impl MarkdownStreamer {
555    pub fn new() -> Self {
556        Self {
557            active_fence: None,
558            code_lang: "bash".to_string(),
559            inline_code: InlineCodeState::new(),
560            in_math_block: false,
561            math_buffer: String::new(),
562            in_table: false,
563            table_header_printed: false,
564            highlighter: None,
565            line_buffer: String::new(),
566            margin: 2,
567            blockquote_depth: 0,
568            list_context: ListContext::new(),
569            pending_newline: false,
570            manual_width: None,
571            scratch_buffer: String::with_capacity(1024),
572        }
573    }
574
575    /// Set a fixed width for rendering. If not set, terminal size is queried.
576    pub fn set_width(&mut self, width: usize) {
577        self.manual_width = Some(width);
578    }
579
580    /// Set the margin (default 2)
581    pub fn set_margin(&mut self, margin: usize) {
582        self.margin = margin;
583    }
584
585    fn get_width(&self) -> usize {
586        self.manual_width
587            .unwrap_or_else(crate::console::get_terminal_width)
588    }
589
590    fn visible_width(&self, text: &str) -> usize {
591        UnicodeWidthStr::width(RE_INVISIBLE.replace_all(text, "").as_ref())
592    }
593
594    /// Main entry point: Process a chunk of text and write to the provided writer.
595    pub fn print_chunk<W: Write>(&mut self, writer: &mut W, text: &str) -> io::Result<()> {
596        self.line_buffer.push_str(text);
597        while let Some(pos) = self.line_buffer.find('\n') {
598            let line = self.line_buffer[..pos + 1].to_string();
599            self.line_buffer.drain(..pos + 1);
600            self.process_line(writer, &line)?;
601        }
602        Ok(())
603    }
604
605    /// Flush remaining buffer (useful at end of stream).
606    pub fn flush<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
607        if !self.line_buffer.is_empty() {
608            let line = std::mem::take(&mut self.line_buffer);
609            self.process_line(writer, &line)?;
610        }
611
612        self.flush_pending_inline(writer)?;
613        self.commit_newline(writer)?;
614        writer.flush()
615    }
616
617    fn commit_newline<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
618        if self.pending_newline {
619            queue!(writer, Print("\n"))?;
620            self.pending_newline = false;
621        }
622        Ok(())
623    }
624
625    fn flush_pending_inline<W: Write>(&mut self, writer: &mut W) -> io::Result<()> {
626        if let Some((ticks, buffer)) = self.inline_code.flush_incomplete() {
627            let prefix = "`".repeat(ticks);
628            let formatted =
629                self.format_inline_code_content(&format!("{}{}", prefix, buffer), None, None);
630            queue!(writer, Print(formatted))?;
631            self.inline_code.reset();
632        }
633        Ok(())
634    }
635
636    // --- Pipeline Controller ---
637    fn process_line<W: Write>(&mut self, w: &mut W, raw_line: &str) -> io::Result<()> {
638        let expanded = self.expand_tabs(raw_line);
639        let classified = self.classify_line(&expanded);
640
641        self.blockquote_depth = classified.blockquote_depth;
642        self.update_block_state(&classified);
643
644        match classified.kind {
645            BlockKind::FenceOpen { lang, .. } => {
646                self.render_fence_open(w, &lang)?;
647            }
648            BlockKind::FenceClose => {
649                self.render_fence_close(w)?;
650            }
651            BlockKind::FenceContent => {
652                self.render_code_line(w, &expanded)?;
653            }
654            BlockKind::MathOpen => {
655                self.render_math_open(w)?;
656            }
657            BlockKind::MathClose => {
658                self.render_math_close(w)?;
659            }
660            BlockKind::MathContent => {
661                let trimmed = expanded.trim_end();
662                self.render_math_content(trimmed);
663            }
664            BlockKind::TableSeparator => {
665                // Separator line is consumed (sets table_header_printed in update_block_state)
666            }
667            BlockKind::TableRow => {
668                let trimmed = expanded.trim_end();
669                self.render_stream_table_row(w, trimmed)?;
670            }
671            BlockKind::Header { level, text } => {
672                let layout = self.compute_block_layout();
673                self.render_header(w, level, &text, &layout)?;
674            }
675            BlockKind::ThematicBreak => {
676                let layout = self.compute_block_layout();
677                self.render_thematic_break(w, &layout)?;
678            }
679            BlockKind::ListItem {
680                indent,
681                marker,
682                separator,
683                content,
684                is_ordered,
685            } => {
686                let layout = self.compute_block_layout();
687                self.render_list_item(
688                    w, indent, &marker, &separator, &content, is_ordered, &layout,
689                )?;
690            }
691            BlockKind::BlankLine | BlockKind::Paragraph => {
692                let layout = self.compute_block_layout();
693                self.render_standard_text(w, &classified.content, &layout)?;
694            }
695        }
696        Ok(())
697    }
698
699    // --- Pure Renderers ---
700
701    fn render_fence_open<W: Write>(&mut self, w: &mut W, lang: &str) -> io::Result<()> {
702        self.flush_pending_inline(w)?;
703        self.commit_newline(w)?;
704        self.start_highlighter(lang);
705        Ok(())
706    }
707
708    fn render_fence_close<W: Write>(&mut self, w: &mut W) -> io::Result<()> {
709        self.commit_newline(w)?;
710        queue!(w, ResetColor)?;
711        self.pending_newline = true;
712        Ok(())
713    }
714
715    fn render_math_open<W: Write>(&mut self, w: &mut W) -> io::Result<()> {
716        self.flush_pending_inline(w)?;
717        self.commit_newline(w)?;
718        Ok(())
719    }
720
721    fn render_math_close<W: Write>(&mut self, w: &mut W) -> io::Result<()> {
722        let converted = unicodeit::replace(&self.math_buffer);
723        let p_width = self.margin + (self.blockquote_depth * 2);
724        let avail = self.get_width().saturating_sub(p_width + self.margin);
725        let padding = avail.saturating_sub(self.visible_width(&converted)) / 2;
726
727        self.commit_newline(w)?;
728        queue!(
729            w,
730            Print(" ".repeat(self.margin + padding)),
731            Print(STYLE_MATH),
732            Print(converted),
733            Print(STYLE_RESET)
734        )?;
735        self.pending_newline = true;
736        self.math_buffer.clear();
737        Ok(())
738    }
739
740    fn render_math_content(&mut self, trimmed: &str) {
741        self.math_buffer.push_str(trimmed);
742        self.math_buffer.push(' ');
743    }
744
745    fn compute_block_layout(&self) -> BlockLayout {
746        let prefix = self.build_block_prefix();
747        let term_width = self.get_width();
748        let prefix_width = self.margin + (self.blockquote_depth * 2);
749        let avail_width = term_width.saturating_sub(prefix_width + self.margin);
750        BlockLayout {
751            prefix,
752            avail_width,
753        }
754    }
755
756    fn render_header<W: Write>(
757        &mut self,
758        w: &mut W,
759        level: usize,
760        text: &str,
761        layout: &BlockLayout,
762    ) -> io::Result<()> {
763        self.flush_pending_inline(w)?;
764        self.commit_newline(w)?;
765
766        let prefix = &layout.prefix;
767        let avail = layout.avail_width;
768
769        queue!(w, Print(prefix))?;
770        if level <= 2 {
771            queue!(w, Print("\n"))?;
772        }
773
774        self.scratch_buffer.clear();
775        let style = match level {
776            1 => STYLE_H1,
777            2 => STYLE_H2,
778            3 => STYLE_H3,
779            _ => STYLE_H_DEFAULT,
780        };
781        self.render_inline(text, None, Some(style));
782
783        if level <= 2 {
784            let lines = self.wrap_ansi(&self.scratch_buffer, avail);
785            for (i, line) in lines.iter().enumerate() {
786                let pad = avail.saturating_sub(self.visible_width(line)) / 2;
787                if i > 0 {
788                    queue!(w, Print("\n"), Print(prefix))?;
789                }
790                queue!(
791                    w,
792                    Print(" ".repeat(pad)),
793                    Print(format!("{}{}{}", style, line, STYLE_RESET)),
794                    ResetColor
795                )?;
796            }
797            if level == 1 {
798                queue!(w, Print("\n"), Print(prefix), Print("─".repeat(avail)))?;
799            }
800            self.pending_newline = true;
801        } else {
802            queue!(
803                w,
804                Print(style),
805                Print(&self.scratch_buffer),
806                Print(STYLE_RESET)
807            )?;
808            self.pending_newline = true;
809        }
810        Ok(())
811    }
812
813    #[allow(clippy::too_many_arguments)]
814    fn render_list_item<W: Write>(
815        &mut self,
816        w: &mut W,
817        indent: usize,
818        marker: &str,
819        separator: &str,
820        text: &str,
821        is_ordered: bool,
822        layout: &BlockLayout,
823    ) -> io::Result<()> {
824        let prefix = &layout.prefix;
825        let avail = layout.avail_width;
826
827        self.flush_pending_inline(w)?;
828        self.commit_newline(w)?;
829
830        let disp_bullet = if is_ordered { marker } else { "•" };
831        let marker_width = self.visible_width(disp_bullet) + separator.len();
832
833        let last_indent = self.list_context.last_indent().unwrap_or(0);
834        if self.list_context.is_empty() || indent > last_indent {
835            self.list_context.push(indent, marker_width);
836        } else if indent < last_indent {
837            self.list_context.pop_to_indent(indent);
838            if self.list_context.last_indent().is_some_and(|d| d != indent) {
839                self.list_context.push(indent, marker_width);
840            }
841        } else {
842            self.list_context.update_last_marker_width(marker_width);
843        }
844
845        let full_stack_width = self.list_context.structural_width();
846        let parent_width = self.list_context.parent_width();
847
848        let hang_indent = " ".repeat(full_stack_width);
849        let content_width = avail.saturating_sub(full_stack_width);
850
851        queue!(
852            w,
853            Print(prefix),
854            Print(" ".repeat(parent_width)),
855            Print(STYLE_LIST_BULLET),
856            Print(disp_bullet),
857            Print(STYLE_RESET),
858            Print(separator)
859        )?;
860
861        if let Some(fcaps) = RE_CODE_FENCE.captures(text) {
862            queue!(w, Print("\n"))?;
863
864            let fence_chars = &fcaps[2];
865            let info = fcaps[3].trim();
866
867            if let Some(f_char) = fence_chars.chars().next() {
868                self.active_fence = Some((f_char, fence_chars.len(), 0));
869
870                let lang = info.split_whitespace().next().unwrap_or("bash");
871                self.code_lang = lang.to_string();
872                self.start_highlighter(&self.code_lang.clone());
873            }
874            return Ok(());
875        }
876
877        self.scratch_buffer.clear();
878        self.render_inline(text, None, None);
879        let lines = self.wrap_ansi(&self.scratch_buffer, content_width);
880
881        if lines.is_empty() {
882            self.pending_newline = true;
883        } else {
884            for (i, line) in lines.iter().enumerate() {
885                if i > 0 {
886                    queue!(w, Print("\n"), Print(prefix), Print(&hang_indent))?;
887                }
888                queue!(w, Print(line), ResetColor)?;
889            }
890            self.pending_newline = true;
891        }
892        Ok(())
893    }
894
895    fn render_thematic_break<W: Write>(
896        &mut self,
897        w: &mut W,
898        layout: &BlockLayout,
899    ) -> io::Result<()> {
900        let prefix = &layout.prefix;
901        let avail = layout.avail_width;
902
903        self.flush_pending_inline(w)?;
904        self.commit_newline(w)?;
905        queue!(
906            w,
907            Print(prefix),
908            SetForegroundColor(Color::DarkGrey),
909            Print("─".repeat(avail)),
910            ResetColor
911        )?;
912        self.pending_newline = true;
913        Ok(())
914    }
915
916    fn render_standard_text<W: Write>(
917        &mut self,
918        w: &mut W,
919        content: &str,
920        layout: &BlockLayout,
921    ) -> io::Result<()> {
922        let prefix = &layout.prefix;
923        let avail = layout.avail_width;
924
925        self.commit_newline(w)?;
926        let mut line_content = content.trim_end_matches(['\n', '\r']);
927        if line_content.trim().is_empty() {
928            self.exit_block_context();
929            if self.blockquote_depth > 0 {
930                queue!(w, Print(prefix))?;
931            }
932            self.pending_newline = true;
933            return Ok(());
934        }
935
936        if !line_content.is_empty() || self.inline_code.is_active() {
937            let mut eff_prefix = self.build_block_prefix();
938            if !self.list_context.is_empty() {
939                let current_indent = line_content.chars().take_while(|c| *c == ' ').count();
940                if current_indent == 0 {
941                    self.list_context.clear();
942                } else {
943                    self.list_context.pop_to_indent(current_indent);
944                }
945
946                if !self.list_context.is_empty() {
947                    let structural_indent = self.list_context.structural_width();
948                    eff_prefix.push_str(&" ".repeat(structural_indent));
949
950                    // To avoid double-indenting, we skip the source indentation that matches
951                    // the structural indentation we just applied via eff_prefix.
952                    let skip = current_indent.min(structural_indent);
953                    line_content = &line_content[skip..];
954                }
955            }
956
957            self.scratch_buffer.clear();
958            if self.inline_code.is_active() {
959                self.inline_code.append_space();
960            }
961            self.render_inline(line_content, None, None);
962
963            let lines = self.wrap_ansi(&self.scratch_buffer, avail);
964            let has_visible_content = self.visible_width(&self.scratch_buffer) > 0;
965
966            for (i, line) in lines.iter().enumerate() {
967                if i > 0 {
968                    queue!(w, Print("\n"))?;
969                }
970                queue!(
971                    w,
972                    ResetColor,
973                    SetAttribute(Attribute::Reset),
974                    Print(&eff_prefix),
975                    Print(line),
976                    ResetColor
977                )?;
978            }
979            if !lines.is_empty() && has_visible_content {
980                self.pending_newline = true;
981            }
982        }
983        Ok(())
984    }
985
986    fn update_block_state(&mut self, classified: &ClassifiedLine) {
987        match &classified.kind {
988            BlockKind::FenceOpen {
989                fence_char,
990                fence_len,
991                indent,
992                lang,
993            } => {
994                self.list_context.pop_to_indent(*indent);
995                self.active_fence = Some((*fence_char, *fence_len, *indent));
996                self.code_lang = lang.clone();
997            }
998            BlockKind::FenceClose => {
999                self.active_fence = None;
1000            }
1001            BlockKind::FenceContent => {}
1002            BlockKind::MathOpen => {
1003                self.exit_block_context();
1004                self.in_math_block = true;
1005            }
1006            BlockKind::MathClose => {
1007                self.in_math_block = false;
1008            }
1009            BlockKind::MathContent => {}
1010            BlockKind::TableSeparator => {
1011                self.table_header_printed = true;
1012            }
1013            BlockKind::TableRow => {
1014                if !self.in_table {
1015                    self.exit_block_context();
1016                    self.in_table = true;
1017                }
1018            }
1019            BlockKind::Header { .. } => {
1020                self.exit_block_context();
1021            }
1022            BlockKind::ThematicBreak => {
1023                self.exit_block_context();
1024            }
1025            BlockKind::ListItem { .. } => {
1026                self.in_table = false;
1027                self.table_header_printed = false;
1028            }
1029            BlockKind::BlankLine | BlockKind::Paragraph => {
1030                self.in_table = false;
1031                self.table_header_printed = false;
1032            }
1033        }
1034    }
1035
1036    fn exit_block_context(&mut self) {
1037        self.list_context.clear();
1038        self.in_table = false;
1039        self.table_header_printed = false;
1040    }
1041
1042    fn wrap_ansi(&self, text: &str, width: usize) -> Vec<String> {
1043        let mut lines = Vec::new();
1044        let mut current_line = String::new();
1045        let mut current_len = 0;
1046        let mut active_codes: Vec<String> = Vec::new();
1047
1048        for caps in RE_SPLIT_ANSI.captures_iter(text) {
1049            let token = caps.get(1).unwrap().as_str();
1050            if token.starts_with("\x1b") {
1051                current_line.push_str(token);
1052                self.update_ansi_state(&mut active_codes, token);
1053            } else {
1054                let mut token_str = token;
1055                let mut token_len = UnicodeWidthStr::width(token_str);
1056
1057                while current_len + token_len > width && width > 0 {
1058                    if current_len == 0 {
1059                        // Force split long word
1060                        let mut split_idx = 0;
1061                        let mut split_len = 0;
1062                        for (idx, c) in token_str.char_indices() {
1063                            let c_w = c.width().unwrap_or(0);
1064                            if split_len + c_w > width {
1065                                break;
1066                            }
1067                            split_idx = idx + c.len_utf8();
1068                            split_len += c_w;
1069                        }
1070                        if split_idx == 0 {
1071                            split_idx = token_str.chars().next().map_or(0, |c| c.len_utf8());
1072                        }
1073                        if split_idx == 0 {
1074                            break;
1075                        }
1076
1077                        current_line.push_str(&token_str[..split_idx]);
1078                        lines.push(current_line);
1079                        current_line = active_codes.join("");
1080                        token_str = &token_str[split_idx..];
1081                        token_len = UnicodeWidthStr::width(token_str);
1082                        current_len = 0;
1083                    } else if !token_str.trim().is_empty() {
1084                        lines.push(current_line);
1085                        current_line = active_codes.join("");
1086                        current_len = 0;
1087                    } else {
1088                        token_str = "";
1089                        token_len = 0;
1090                    }
1091                }
1092                if !token_str.is_empty() {
1093                    current_line.push_str(token_str);
1094                    current_len += token_len;
1095                }
1096            }
1097        }
1098        if !current_line.is_empty() {
1099            lines.push(current_line);
1100        }
1101        lines
1102    }
1103
1104    fn update_ansi_state(&self, state: &mut Vec<String>, code: &str) {
1105        if RE_OSC8.is_match(code) {
1106            return;
1107        }
1108        if let Some(caps) = RE_ANSI_PARTS.captures(code) {
1109            let content = caps.get(1).map_or("", |m| m.as_str());
1110            if content == "0" || content.is_empty() {
1111                state.clear();
1112                return;
1113            }
1114
1115            let num: i32 = content
1116                .split(';')
1117                .next()
1118                .unwrap_or("0")
1119                .parse()
1120                .unwrap_or(0);
1121            let category = match num {
1122                1 | 22 => "bold",
1123                3 | 23 => "italic",
1124                4 | 24 => "underline",
1125                30..=39 | 90..=97 => "fg",
1126                40..=49 | 100..=107 => "bg",
1127                _ => "other",
1128            };
1129            if category != "other" {
1130                state.retain(|exist| {
1131                    let e_num: i32 = RE_ANSI_PARTS
1132                        .captures(exist)
1133                        .and_then(|c| c.get(1))
1134                        .map_or("0", |m| m.as_str())
1135                        .split(';')
1136                        .next()
1137                        .unwrap_or("0")
1138                        .parse()
1139                        .unwrap_or(0);
1140                    let e_cat = match e_num {
1141                        1 | 22 => "bold",
1142                        3 | 23 => "italic",
1143                        4 | 24 => "underline",
1144                        30..=39 | 90..=97 => "fg",
1145                        40..=49 | 100..=107 => "bg",
1146                        _ => "other",
1147                    };
1148                    e_cat != category
1149                });
1150            }
1151            state.push(code.to_string());
1152        }
1153    }
1154
1155    fn render_code_line<W: Write>(&mut self, w: &mut W, line: &str) -> io::Result<()> {
1156        self.commit_newline(w)?;
1157        let raw_line = line.trim_end_matches(&['\r', '\n'][..]);
1158
1159        let fence_indent = self.active_fence.map(|(_, _, i)| i).unwrap_or(0);
1160
1161        // Strip the fence's indentation from the content line (Spec §4.5)
1162        let skip = raw_line
1163            .chars()
1164            .take(fence_indent)
1165            .take_while(|&c| c == ' ')
1166            .count();
1167        let line_content = &raw_line[skip..];
1168
1169        let mut prefix = " ".repeat(self.margin);
1170        if !self.list_context.is_empty() {
1171            let indent_width = self.list_context.structural_width();
1172            prefix.push_str(&" ".repeat(indent_width));
1173        }
1174
1175        let avail_width = self.get_width().saturating_sub(prefix.len() + self.margin);
1176
1177        let mut spans = Vec::new();
1178        if let Some(h) = &mut self.highlighter {
1179            if let Ok(ranges) = h.highlight_line(line_content, &SYNTAX_SET) {
1180                spans = ranges;
1181            } else {
1182                spans.push((syntect::highlighting::Style::default(), line_content));
1183            }
1184        } else {
1185            spans.push((syntect::highlighting::Style::default(), line_content));
1186        }
1187
1188        // 1. Build the full colored line in memory first
1189        self.scratch_buffer.clear();
1190        for (style, text) in spans {
1191            let _ = write!(
1192                self.scratch_buffer,
1193                "\x1b[38;2;{};{};{}m{}",
1194                style.foreground.r, style.foreground.g, style.foreground.b, text
1195            );
1196        }
1197
1198        // 2. Determine if we need to wrap
1199        let content_width = self.visible_width(line_content);
1200
1201        if content_width <= avail_width {
1202            // Fits in one line: Print directly
1203            let pad = avail_width.saturating_sub(content_width);
1204            queue!(
1205                w,
1206                Print(&prefix),
1207                SetBackgroundColor(COLOR_CODE_BG),
1208                Print(&self.scratch_buffer),
1209                Print(" ".repeat(pad)),
1210                ResetColor
1211            )?;
1212        } else {
1213            // Needs wrapping
1214            let wrapped_lines = self.wrap_ansi(&self.scratch_buffer, avail_width);
1215
1216            if wrapped_lines.is_empty() {
1217                queue!(
1218                    w,
1219                    Print(&prefix),
1220                    SetBackgroundColor(COLOR_CODE_BG),
1221                    Print(" ".repeat(avail_width)),
1222                    ResetColor
1223                )?;
1224            } else {
1225                for (i, line) in wrapped_lines.iter().enumerate() {
1226                    if i > 0 {
1227                        queue!(w, Print("\n"))?;
1228                    }
1229                    let vis_len = self.visible_width(line);
1230                    let pad = avail_width.saturating_sub(vis_len);
1231
1232                    queue!(
1233                        w,
1234                        Print(&prefix),
1235                        SetBackgroundColor(COLOR_CODE_BG),
1236                        Print(line),
1237                        Print(" ".repeat(pad)),
1238                        ResetColor
1239                    )?;
1240                }
1241            }
1242        }
1243        self.pending_newline = true;
1244        Ok(())
1245    }
1246
1247    fn render_stream_table_row<W: Write>(&mut self, w: &mut W, row_str: &str) -> io::Result<()> {
1248        self.flush_pending_inline(w)?;
1249        self.commit_newline(w)?;
1250        let term_width = self.get_width();
1251
1252        let trimmed_row = row_str.trim().trim_matches('|');
1253        let segments = parse_segments(trimmed_row, None);
1254        let cells = split_table_row(trimmed_row, &segments);
1255
1256        if cells.is_empty() {
1257            return Ok(());
1258        }
1259
1260        let prefix_width = self.margin + (self.blockquote_depth * 2);
1261        let cell_overhead = (cells.len() * 3).saturating_sub(1);
1262        let avail = term_width.saturating_sub(prefix_width + self.margin + cell_overhead);
1263        if avail == 0 {
1264            return Ok(());
1265        }
1266        let base_w = avail / cells.len();
1267        let rem = avail % cells.len();
1268
1269        let bg = if !self.table_header_printed {
1270            Color::Rgb {
1271                r: 60,
1272                g: 60,
1273                b: 80,
1274            }
1275        } else {
1276            COLOR_CODE_BG
1277        };
1278        let mut wrapped_cells = Vec::new();
1279        let mut max_h = 1;
1280
1281        for (i, cell) in cells.iter().enumerate() {
1282            let width = std::cmp::max(
1283                1,
1284                if i == cells.len() - 1 {
1285                    base_w + rem
1286                } else {
1287                    base_w
1288                },
1289            );
1290            self.scratch_buffer.clear();
1291            if !self.table_header_printed {
1292                self.scratch_buffer.push_str("\x1b[1;33m");
1293            }
1294            self.render_inline(
1295                cell.trim(),
1296                Some(bg),
1297                if !self.table_header_printed {
1298                    Some("\x1b[1;33m")
1299                } else {
1300                    None
1301                },
1302            );
1303            if !self.table_header_printed {
1304                self.scratch_buffer.push_str("\x1b[0m");
1305            }
1306
1307            let lines = self.wrap_ansi(&self.scratch_buffer, width);
1308            if lines.len() > max_h {
1309                max_h = lines.len();
1310            }
1311            wrapped_cells.push((lines, width));
1312        }
1313
1314        let prefix = self.build_block_prefix();
1315
1316        for i in 0..max_h {
1317            if i > 0 {
1318                queue!(w, Print("\n"))?;
1319            }
1320            queue!(w, Print(&prefix))?;
1321            for (col, (lines, width)) in wrapped_cells.iter().enumerate() {
1322                let text = lines.get(i).map(|s| s.as_str()).unwrap_or("");
1323                let pad = width.saturating_sub(self.visible_width(text));
1324                queue!(
1325                    w,
1326                    SetBackgroundColor(bg),
1327                    Print(" "),
1328                    Print(text),
1329                    SetBackgroundColor(bg),
1330                    Print(" ".repeat(pad + 1)),
1331                    ResetColor
1332                )?;
1333                if col < cells.len() - 1 {
1334                    queue!(
1335                        w,
1336                        SetBackgroundColor(bg),
1337                        SetForegroundColor(Color::White),
1338                        Print("│"),
1339                        ResetColor
1340                    )?;
1341                }
1342            }
1343        }
1344        self.pending_newline = true;
1345        Ok(())
1346    }
1347
1348    pub fn render_inline(&mut self, text: &str, def_bg: Option<Color>, restore_fg: Option<&str>) {
1349        let mut parts = self.build_inline_parts(text, def_bg, restore_fg);
1350        self.resolve_delimiters(&mut parts);
1351
1352        for part in parts {
1353            for s in &part.pre_style {
1354                self.scratch_buffer.push_str(s);
1355            }
1356            self.scratch_buffer.push_str(&part.content());
1357            for s in &part.post_style {
1358                self.scratch_buffer.push_str(s);
1359            }
1360        }
1361    }
1362
1363    fn build_inline_parts(
1364        &mut self,
1365        text: &str,
1366        def_bg: Option<Color>,
1367        restore_fg: Option<&str>,
1368    ) -> Vec<InlinePart> {
1369        let active_ticks = self.inline_code.ticks;
1370        let segments = parse_segments(text, active_ticks);
1371        let mut parts: Vec<InlinePart> = Vec::new();
1372
1373        for seg in &segments {
1374            match seg {
1375                ParsedSegment::CodeSpan {
1376                    range,
1377                    delimiter_len,
1378                } => {
1379                    let n = *delimiter_len;
1380                    let content_range = range.start + n..range.end - n;
1381                    let raw_content = &text[content_range];
1382                    let normalized = InlineCodeState::normalize_content_static(raw_content);
1383                    let formatted =
1384                        self.format_inline_code_content(&normalized, def_bg, restore_fg);
1385                    parts.push(InlinePart::text(formatted));
1386                }
1387                ParsedSegment::CodeSpanOpener {
1388                    range: _,
1389                    delimiter_len,
1390                } => {
1391                    self.inline_code.open(*delimiter_len);
1392                }
1393                ParsedSegment::CodeSpanContent(range) => {
1394                    self.inline_code.push_content(&text[range.clone()]);
1395                }
1396                ParsedSegment::CodeSpanCloser {
1397                    range: _,
1398                    delimiter_len: _,
1399                } => {
1400                    let content = self.inline_code.close();
1401                    let formatted = self.format_inline_code_content(&content, def_bg, restore_fg);
1402                    parts.push(InlinePart::text(formatted));
1403                }
1404                ParsedSegment::Escape(r) => {
1405                    parts.push(InlinePart::text(text[r.start + 1..r.end].to_string()));
1406                }
1407                ParsedSegment::Math(r) => {
1408                    let tok = &text[r.clone()];
1409                    if tok.len() > 1 && tok.starts_with('$') && tok.ends_with('$') {
1410                        parts.push(InlinePart::text(unicodeit::replace(&tok[1..tok.len() - 1])));
1411                    } else {
1412                        parts.push(InlinePart::text(tok.to_string()));
1413                    }
1414                }
1415                ParsedSegment::Autolink(r) => {
1416                    let url = &text[r.start + 1..r.end - 1];
1417                    parts.push(InlinePart::text(format!(
1418                        "\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\",
1419                        url, url
1420                    )));
1421                }
1422                ParsedSegment::Link(r) => {
1423                    if let Some(caps) = RE_LINK.captures(&text[r.clone()]) {
1424                        let link_text = caps.get(1).map_or("", |m| m.as_str());
1425                        let url = caps.get(2).map_or("", |m| m.as_str());
1426                        parts.push(InlinePart::text(format!(
1427                            "\x1b]8;;{}\x1b\\\x1b[33;4m{}\x1b[24;39m\x1b]8;;\x1b\\",
1428                            url, link_text
1429                        )));
1430                    }
1431                }
1432                ParsedSegment::Ansi(r) => {
1433                    parts.push(InlinePart::text(text[r.clone()].to_string()));
1434                }
1435                ParsedSegment::Delim(r) => {
1436                    let tok = &text[r.clone()];
1437                    let c = tok.chars().next().unwrap();
1438
1439                    let prev_char = if r.start > 0 {
1440                        text[..r.start].chars().last().unwrap_or(' ')
1441                    } else {
1442                        ' '
1443                    };
1444                    let next_char = text[r.end..].chars().next().unwrap_or(' ');
1445
1446                    let is_ws_next = next_char.is_whitespace();
1447                    let is_ws_prev = prev_char.is_whitespace();
1448                    let is_punct_next = !next_char.is_alphanumeric() && !is_ws_next;
1449                    let is_punct_prev = !prev_char.is_alphanumeric() && !is_ws_prev;
1450                    let left_flanking =
1451                        !is_ws_next && (!is_punct_next || (is_ws_prev || is_punct_prev));
1452                    let right_flanking =
1453                        !is_ws_prev && (!is_punct_prev || (is_ws_next || is_punct_next));
1454
1455                    let (can_open, can_close) = if c == '_' {
1456                        (
1457                            left_flanking && (!right_flanking || is_punct_prev),
1458                            right_flanking && (!left_flanking || is_punct_next),
1459                        )
1460                    } else {
1461                        (left_flanking, right_flanking)
1462                    };
1463
1464                    parts.push(InlinePart::delimiter(c, tok.len(), can_open, can_close));
1465                }
1466                ParsedSegment::Text(r) => {
1467                    parts.push(InlinePart::text(text[r.clone()].to_string()));
1468                }
1469            }
1470        }
1471
1472        parts
1473    }
1474
1475    fn resolve_delimiters(&self, parts: &mut [InlinePart]) {
1476        let mut stack: Vec<usize> = Vec::new();
1477
1478        for i in 0..parts.len() {
1479            if !parts[i].is_delim() {
1480                continue;
1481            }
1482
1483            if parts[i].can_close() {
1484                let mut stack_idx = stack.len();
1485                while stack_idx > 0 {
1486                    let open_pos = stack_idx - 1;
1487                    let open_idx = stack[open_pos];
1488
1489                    if parts[open_idx].delim_char() == parts[i].delim_char()
1490                        && parts[open_idx].can_open()
1491                    {
1492                        // Rule 9/10: Multiple of 3 Rule
1493                        if (parts[open_idx].can_open() && parts[open_idx].can_close())
1494                            || (parts[i].can_open() && parts[i].can_close())
1495                        {
1496                            let sum = parts[open_idx].delim_len() + parts[i].delim_len();
1497                            if sum.is_multiple_of(3)
1498                                && (!parts[open_idx].delim_len().is_multiple_of(3)
1499                                    || !parts[i].delim_len().is_multiple_of(3))
1500                            {
1501                                stack_idx -= 1;
1502                                continue;
1503                            }
1504                        }
1505
1506                        // Empty emphasis check
1507                        if open_idx + 1 == i {
1508                            stack_idx -= 1;
1509                            continue;
1510                        }
1511
1512                        // Determine consumption length
1513                        let open_len = parts[open_idx].delim_len();
1514                        let close_len = parts[i].delim_len();
1515                        let use_len = if close_len == 3 && open_len == 3 {
1516                            1
1517                        } else if close_len >= 2 && open_len >= 2 {
1518                            2
1519                        } else {
1520                            1
1521                        };
1522
1523                        let (style_on, style_off) = match (parts[open_idx].delim_char(), use_len) {
1524                            ('~', _) => ("\x1b[9m", "\x1b[29m"),
1525                            ('_', 1) => ("\x1b[4m", "\x1b[24m"),
1526                            (_, 1) => ("\x1b[3m", "\x1b[23m"),
1527                            (_, 2) => ("\x1b[1m", "\x1b[22m"),
1528                            _ => ("", ""),
1529                        };
1530
1531                        // Apply styles
1532                        if use_len == 1 {
1533                            parts[open_idx].pre_style.push(style_on.to_string());
1534                            parts[i].post_style.push(style_off.to_string());
1535                        } else {
1536                            parts[open_idx].post_style.push(style_on.to_string());
1537                            parts[i].pre_style.push(style_off.to_string());
1538                        }
1539
1540                        // Consume tokens
1541                        parts[open_idx].consume(use_len);
1542                        parts[i].consume(use_len);
1543
1544                        // Stack Management
1545                        if parts[open_idx].delim_len() == 0 {
1546                            stack.remove(open_pos);
1547                            stack_idx -= 1;
1548                        }
1549
1550                        if parts[i].delim_len() == 0 {
1551                            break;
1552                        }
1553                    } else {
1554                        stack_idx -= 1;
1555                    }
1556                }
1557            }
1558
1559            if parts[i].delim_len() > 0 && parts[i].can_open() {
1560                stack.push(i);
1561            }
1562        }
1563    }
1564
1565    fn build_block_prefix(&self) -> String {
1566        let mut prefix = " ".repeat(self.margin);
1567        if self.blockquote_depth > 0 {
1568            prefix.push_str(STYLE_BLOCKQUOTE);
1569            for _ in 0..self.blockquote_depth {
1570                prefix.push_str("│ ");
1571            }
1572            prefix.push_str(STYLE_RESET);
1573        }
1574        prefix
1575    }
1576
1577    fn format_inline_code_content(
1578        &self,
1579        content: &str,
1580        def_bg: Option<Color>,
1581        restore_fg: Option<&str>,
1582    ) -> String {
1583        let mut out = String::new();
1584        let _ = write!(out, "{}{}", STYLE_INLINE_CODE, content);
1585        if let Some(Color::Rgb { r, g, b }) = def_bg {
1586            let _ = write!(out, "\x1b[48;2;{};{};{}m", r, g, b);
1587        } else {
1588            out.push_str(STYLE_RESET_BG);
1589        }
1590        out.push_str(restore_fg.unwrap_or(STYLE_RESET_FG));
1591        out
1592    }
1593
1594    fn expand_tabs(&self, line: &str) -> String {
1595        let mut expanded = String::with_capacity(line.len());
1596        let mut col = 0;
1597        for c in line.chars() {
1598            if c == '\t' {
1599                let n = 4 - (col % 4);
1600                expanded.push_str(&" ".repeat(n));
1601                col += n;
1602            } else {
1603                expanded.push(c);
1604                col += UnicodeWidthChar::width(c).unwrap_or(0);
1605            }
1606        }
1607        expanded
1608    }
1609
1610    pub fn classify_line(&self, expanded: &str) -> ClassifiedLine {
1611        let trimmed = expanded.trim_end();
1612
1613        // 1. Continuation contexts (checked before blockquote stripping)
1614
1615        // Active code fence: check for close or treat as content
1616        if let Some((f_char, min_len, _indent)) = self.active_fence {
1617            if let Some(caps) = RE_CODE_FENCE.captures(trimmed) {
1618                let fence = &caps[2];
1619                if fence.starts_with(f_char) && fence.len() >= min_len && caps[3].trim().is_empty()
1620                {
1621                    return ClassifiedLine {
1622                        blockquote_depth: 0,
1623                        content: expanded.to_string(),
1624                        kind: BlockKind::FenceClose,
1625                    };
1626                }
1627            }
1628            return ClassifiedLine {
1629                blockquote_depth: 0,
1630                content: expanded.to_string(),
1631                kind: BlockKind::FenceContent,
1632            };
1633        }
1634
1635        // Active math block
1636        if self.in_math_block {
1637            if RE_MATH_BLOCK.is_match(trimmed) {
1638                return ClassifiedLine {
1639                    blockquote_depth: 0,
1640                    content: expanded.to_string(),
1641                    kind: BlockKind::MathClose,
1642                };
1643            }
1644            return ClassifiedLine {
1645                blockquote_depth: 0,
1646                content: expanded.to_string(),
1647                kind: BlockKind::MathContent,
1648            };
1649        }
1650
1651        // Table separator (only when already in a table)
1652        if self.in_table && RE_TABLE_SEP.is_match(trimmed) {
1653            return ClassifiedLine {
1654                blockquote_depth: 0,
1655                content: expanded.to_string(),
1656                kind: BlockKind::TableSeparator,
1657            };
1658        }
1659
1660        // Table row (before blockquote stripping, matching current precedence)
1661        if RE_TABLE_ROW.is_match(trimmed) {
1662            return ClassifiedLine {
1663                blockquote_depth: 0,
1664                content: expanded.to_string(),
1665                kind: BlockKind::TableRow,
1666            };
1667        }
1668
1669        // 2. Strip blockquotes and count depth
1670        let mut content = expanded.to_string();
1671        let mut blockquote_depth = 0;
1672        loop {
1673            let trimmed_content = content.clone();
1674            if let Some(caps) = RE_BLOCKQUOTE.captures(&trimmed_content) {
1675                blockquote_depth += 1;
1676                content = caps.get(2).map_or("", |m| m.as_str()).to_string();
1677            } else {
1678                break;
1679            }
1680        }
1681
1682        let clean = content.trim_end();
1683
1684        // 3. Post-blockquote classification
1685
1686        // Code fence open
1687        if let Some(caps) = RE_CODE_FENCE.captures(clean) {
1688            let fence = &caps[2];
1689            let indent_len = caps[1].len();
1690            let info = caps[3].trim();
1691            if let Some(f_char) = fence.chars().next()
1692                && (f_char != '`' || !info.contains('`'))
1693            {
1694                let lang = info.split_whitespace().next().unwrap_or("bash").to_string();
1695                return ClassifiedLine {
1696                    blockquote_depth,
1697                    content: content.clone(),
1698                    kind: BlockKind::FenceOpen {
1699                        fence_char: f_char,
1700                        fence_len: fence.len(),
1701                        indent: indent_len,
1702                        lang,
1703                    },
1704                };
1705            }
1706        }
1707
1708        // Math block open
1709        if RE_MATH_BLOCK.is_match(clean) {
1710            return ClassifiedLine {
1711                blockquote_depth,
1712                content: content.clone(),
1713                kind: BlockKind::MathOpen,
1714            };
1715        }
1716
1717        // Header
1718        if let Some(caps) = RE_HEADER.captures(clean) {
1719            let level = caps.get(1).map_or(0, |m| m.len());
1720            let raw_text = caps.get(2).map_or("", |m| m.as_str());
1721            let text = Self::clean_atx_header_text_static(raw_text).to_string();
1722            return ClassifiedLine {
1723                blockquote_depth,
1724                content: content.clone(),
1725                kind: BlockKind::Header { level, text },
1726            };
1727        }
1728
1729        // Empty ATX heading: `## ` or `###` with only hashes and optional trailing whitespace
1730        {
1731            let stripped = clean.trim_start();
1732            let hash_count = stripped.chars().take_while(|&c| c == '#').count();
1733            if (1..=6).contains(&hash_count) {
1734                let after_hashes = &stripped[hash_count..];
1735                if after_hashes.is_empty() || after_hashes.chars().all(|c| c.is_whitespace()) {
1736                    return ClassifiedLine {
1737                        blockquote_depth,
1738                        content: content.clone(),
1739                        kind: BlockKind::Header {
1740                            level: hash_count,
1741                            text: "".to_string(),
1742                        },
1743                    };
1744                }
1745            }
1746        }
1747
1748        // Thematic break (must be checked before list to handle `* * *`)
1749        if RE_HR.is_match(clean) {
1750            let hr_chars: Vec<char> = clean.chars().filter(|c| !c.is_whitespace()).collect();
1751            if !hr_chars.is_empty() && hr_chars.iter().all(|&c| c == hr_chars[0]) {
1752                return ClassifiedLine {
1753                    blockquote_depth,
1754                    content: content.clone(),
1755                    kind: BlockKind::ThematicBreak,
1756                };
1757            }
1758        }
1759
1760        // List item
1761        if let Some(caps) = RE_LIST.captures(clean) {
1762            let indent = caps.get(1).map_or(0, |m| m.len());
1763            let marker = caps.get(2).map_or("", |m| m.as_str()).to_string();
1764            let separator = caps.get(3).map_or(" ", |m| m.as_str()).to_string();
1765            let content_text = caps.get(4).map_or("", |m| m.as_str()).to_string();
1766            let is_ordered = marker.chars().any(|c| c.is_numeric());
1767
1768            // CommonMark §4.1 ex 43: thematic break inside list item content
1769            // takes precedence over the list item
1770            if !content_text.is_empty() {
1771                let ct = content_text.trim_end();
1772                if RE_HR.is_match(ct) {
1773                    let hr_chars: Vec<char> = ct.chars().filter(|c| !c.is_whitespace()).collect();
1774                    if !hr_chars.is_empty() && hr_chars.iter().all(|&c| c == hr_chars[0]) {
1775                        return ClassifiedLine {
1776                            blockquote_depth,
1777                            content: content.clone(),
1778                            kind: BlockKind::ThematicBreak,
1779                        };
1780                    }
1781                }
1782            }
1783
1784            return ClassifiedLine {
1785                blockquote_depth,
1786                content: content.clone(),
1787                kind: BlockKind::ListItem {
1788                    indent,
1789                    marker,
1790                    separator,
1791                    content: content_text,
1792                    is_ordered,
1793                },
1794            };
1795        }
1796
1797        // Blank line
1798        if clean.is_empty() {
1799            return ClassifiedLine {
1800                blockquote_depth,
1801                content,
1802                kind: BlockKind::BlankLine,
1803            };
1804        }
1805
1806        // Paragraph (fallback)
1807        ClassifiedLine {
1808            blockquote_depth,
1809            content,
1810            kind: BlockKind::Paragraph,
1811        }
1812    }
1813
1814    fn clean_atx_header_text_static(text: &str) -> &str {
1815        let trimmed = text.trim_end();
1816        let mut end = trimmed.len();
1817        let bytes = trimmed.as_bytes();
1818        while end > 0 && bytes[end - 1] == b'#' {
1819            end -= 1;
1820        }
1821        if end == 0 {
1822            // All hashes (e.g., "###"): empty heading
1823            ""
1824        } else if end < trimmed.len() && bytes[end - 1] == b' ' {
1825            // Closing hashes preceded by space: strip both space and hashes
1826            trimmed[..end - 1].trim_end()
1827        } else {
1828            // No valid closing sequence (e.g., "foo#"): return as-is
1829            trimmed
1830        }
1831    }
1832
1833    fn start_highlighter(&mut self, lang: &str) {
1834        let ss = &*SYNTAX_SET;
1835        let syntax = ss
1836            .find_syntax_by_token(lang)
1837            .unwrap_or_else(|| ss.find_syntax_plain_text());
1838        self.highlighter = Some(HighlightLines::new(syntax, &THEME));
1839    }
1840}