Skip to main content

cookie_agent_tui/
markdown.rs

1//! Incremental Markdown parsing and direct ratatui span rendering.
2
3use std::{
4    any::TypeId,
5    collections::{BTreeMap, BTreeSet, HashMap},
6    hash::BuildHasher,
7    mem::{size_of, size_of_val},
8    sync::{Arc, Mutex},
9};
10
11#[cfg(test)]
12use std::sync::atomic::{AtomicUsize, Ordering};
13
14use pulldown_cmark::{
15    BrokenLink, CodeBlockKind, Event, HeadingLevel, LinkType, Options, Parser, Tag, TagEnd,
16};
17use ratatui::{
18    style::{Color, Modifier, Style},
19    text::{Line, Span},
20};
21use syntect::{
22    easy::HighlightLines,
23    highlighting::{FontStyle, HighlightState, Style as SyntectStyle, ThemeSet},
24    parsing::{ParseState, SyntaxSet},
25    util::LinesWithEndings,
26};
27
28use crate::theme::{Theme, ThemeKey, ThemeKind};
29
30const HIGHLIGHT_CACHE_BUDGET_BYTES: usize = 8 * 1024 * 1024;
31const HIGHLIGHT_CACHE_ENTRY_OVERHEAD_BYTES: usize = 256;
32const HIGHLIGHT_CACHE_ALLOCATION_OVERHEAD_BYTES: usize = 16;
33
34#[derive(Clone, Debug, Default)]
35pub struct MarkdownDocument {
36    source: String,
37    stable_prefix_len: usize,
38    stable_blocks: Vec<MarkdownBlock>,
39    tail_blocks: Vec<MarkdownBlock>,
40    stable_reference_definitions: BTreeMap<String, ReferenceDefinition>,
41    reference_definitions: BTreeMap<String, ReferenceDefinition>,
42    stable_reference_dependencies: BTreeSet<String>,
43    parse_passes: u64,
44    parsed_bytes: u64,
45    reference_reparses: u64,
46}
47
48#[derive(Debug)]
49struct HighlightCache {
50    entries: HashMap<HighlightCacheKey, HighlightCacheEntry>,
51    budget_bytes: usize,
52    used_bytes: usize,
53    generation: u64,
54    theme: Option<ThemeKey>,
55}
56
57impl Default for HighlightCache {
58    fn default() -> Self {
59        Self {
60            entries: HashMap::new(),
61            budget_bytes: HIGHLIGHT_CACHE_BUDGET_BYTES,
62            used_bytes: 0,
63            generation: 0,
64            theme: None,
65        }
66    }
67}
68
69#[derive(Clone, Debug, Eq, Hash, PartialEq)]
70struct HighlightCacheKey {
71    content_hash: u64,
72    code_len: usize,
73    language: String,
74    theme: ThemeKey,
75    syntect_theme: &'static str,
76}
77
78#[derive(Debug)]
79struct HighlightCacheEntry {
80    source: Arc<str>,
81    lines: Arc<[Line<'static>]>,
82    bytes: usize,
83    last_used: u64,
84}
85
86impl HighlightCache {
87    fn set_theme(&mut self, theme: ThemeKey) {
88        if self.theme.is_some_and(|current| current != theme) {
89            self.entries = HashMap::new();
90            self.used_bytes = 0;
91        }
92        self.theme = Some(theme);
93    }
94
95    fn get(&mut self, key: &HighlightCacheKey, source: &str) -> Option<Arc<[Line<'static>]>> {
96        self.generation = self.generation.wrapping_add(1);
97        let generation = self.generation;
98        let entry = self.entries.get_mut(key)?;
99        // The hash narrows the lookup, while this exact comparison prevents a
100        // same-length collision from reusing spans for different source text.
101        if entry.source.as_ref() != source {
102            return None;
103        }
104        entry.last_used = generation;
105        Some(entry.lines.clone())
106    }
107
108    fn insert(&mut self, key: HighlightCacheKey, source: &str, lines: Arc<[Line<'static>]>) {
109        debug_assert!(self.used_bytes <= self.budget_bytes);
110        self.generation = self.generation.wrapping_add(1);
111        let bytes = highlight_entry_bytes(&key, source, &lines);
112        // A single entry cannot displace enough data to fit, so return it to
113        // the renderer without caching it.
114        if bytes > self.budget_bytes {
115            return;
116        }
117        if let Some(replaced) = self.entries.remove(&key) {
118            self.used_bytes = self.used_bytes.saturating_sub(replaced.bytes);
119        }
120        let available = self.budget_bytes - bytes;
121        while self.used_bytes > available {
122            let Some(oldest) = self
123                .entries
124                .iter()
125                .min_by_key(|(_, entry)| entry.last_used)
126                .map(|(key, _)| key.clone())
127            else {
128                break;
129            };
130            if let Some(evicted) = self.entries.remove(&oldest) {
131                self.used_bytes = self.used_bytes.saturating_sub(evicted.bytes);
132            }
133            debug_assert!(self.used_bytes <= self.budget_bytes);
134        }
135        debug_assert!(self.used_bytes <= available);
136        let entry = HighlightCacheEntry {
137            source: Arc::from(source),
138            lines,
139            bytes,
140            last_used: self.generation,
141        };
142        self.used_bytes += bytes;
143        self.entries.insert(key, entry);
144        debug_assert!(self.used_bytes <= self.budget_bytes);
145    }
146}
147
148fn highlight_entry_bytes(key: &HighlightCacheKey, source: &str, lines: &[Line<'static>]) -> usize {
149    size_of::<HighlightCacheKey>()
150        + size_of::<HighlightCacheEntry>()
151        + HIGHLIGHT_CACHE_ENTRY_OVERHEAD_BYTES
152        + 3 * HIGHLIGHT_CACHE_ALLOCATION_OVERHEAD_BYTES
153        + key.language.capacity()
154        + source.len()
155        + 2 * size_of::<usize>()
156        + size_of_val(lines)
157        + lines
158            .iter()
159            .map(|line| {
160                size_of::<Span<'static>>() * line.spans.capacity()
161                    + usize::from(!line.spans.is_empty())
162                        * HIGHLIGHT_CACHE_ALLOCATION_OVERHEAD_BYTES
163                    + line
164                        .spans
165                        .iter()
166                        .map(|span| match &span.content {
167                            std::borrow::Cow::Borrowed(content) => content.len(),
168                            std::borrow::Cow::Owned(content) => {
169                                content.capacity() + HIGHLIGHT_CACHE_ALLOCATION_OVERHEAD_BYTES
170                            }
171                        })
172                        .sum::<usize>()
173            })
174            .sum::<usize>()
175}
176
177#[derive(Clone, Debug)]
178pub struct MarkdownBlock {
179    kind: MarkdownBlockKind,
180    events: Vec<Event<'static>>,
181    source: Arc<str>,
182    content_hash: u64,
183    content_len: usize,
184    rendered: Arc<Mutex<Option<RenderedBlockCacheEntry>>>,
185}
186
187#[derive(Clone, Copy, Debug, Eq, PartialEq)]
188struct RenderedBlockCacheKey {
189    content_hash: u64,
190    code_len: usize,
191    width: u16,
192    theme: ThemeKey,
193    quote_depth: usize,
194    highlighter: u64,
195}
196
197#[derive(Clone, Debug)]
198struct RenderedBlockCacheEntry {
199    key: RenderedBlockCacheKey,
200    source: Arc<str>,
201    lines: Arc<[MarkdownLine]>,
202}
203
204impl MarkdownBlock {
205    pub const fn kind(&self) -> MarkdownBlockKind {
206        self.kind
207    }
208}
209
210#[derive(Clone, Copy, Debug, Eq, PartialEq)]
211pub enum MarkdownBlockKind {
212    Paragraph,
213    Heading,
214    Quote,
215    Code,
216    List,
217    Table,
218    Html,
219    ThematicBreak,
220    Other,
221}
222
223#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
224pub(crate) enum MarkdownLineKind {
225    #[default]
226    Prose,
227    ListItem {
228        continuation_indent: usize,
229    },
230    Code,
231    Table,
232}
233
234#[derive(Clone, Debug, Default, Eq, PartialEq)]
235pub(crate) struct MarkdownLine {
236    pub(crate) line: Line<'static>,
237    pub(crate) kind: MarkdownLineKind,
238}
239
240impl MarkdownDocument {
241    pub fn new(source: String) -> Self {
242        let mut document = Self {
243            source,
244            ..Self::default()
245        };
246        document.reparse_open_tail();
247        document
248    }
249
250    pub fn append(&mut self, delta: &str) {
251        self.source.push_str(delta);
252        self.reparse_open_tail();
253    }
254
255    pub fn as_str(&self) -> &str {
256        &self.source
257    }
258
259    pub fn stable_prefix_len(&self) -> usize {
260        self.stable_prefix_len
261    }
262
263    pub fn parse_passes(&self) -> u64 {
264        self.parse_passes
265    }
266
267    pub fn parsed_bytes(&self) -> u64 {
268        self.parsed_bytes
269    }
270
271    pub fn reference_reparses(&self) -> u64 {
272        self.reference_reparses
273    }
274
275    pub fn blocks(&self) -> impl Iterator<Item = &MarkdownBlock> {
276        self.stable_blocks.iter().chain(&self.tail_blocks)
277    }
278
279    fn reparse_open_tail(&mut self) {
280        let tail = self.source[self.stable_prefix_len..].to_owned();
281        let parsed = self.parse(&tail);
282        let effective_definitions = self.effective_definitions(&parsed.definitions);
283        let references_changed = self
284            .stable_reference_dependencies
285            .iter()
286            .any(|label| self.reference_definitions.get(label) != effective_definitions.get(label));
287        if references_changed {
288            self.reference_reparses = self.reference_reparses.wrapping_add(1);
289            self.stable_prefix_len = 0;
290            self.stable_blocks.clear();
291            self.tail_blocks.clear();
292            self.stable_reference_definitions.clear();
293            self.reference_definitions.clear();
294            self.stable_reference_dependencies.clear();
295            let source = self.source.clone();
296            let parsed = self.parse(&source);
297            self.reference_definitions = self.effective_definitions(&parsed.definitions);
298            self.apply_parsed(parsed);
299            return;
300        }
301        self.reference_definitions = effective_definitions;
302        self.apply_parsed(parsed);
303    }
304
305    fn parse(&mut self, source: &str) -> ParsedDocument {
306        self.parse_passes = self.parse_passes.wrapping_add(1);
307        self.parsed_bytes = self.parsed_bytes.wrapping_add(source.len() as u64);
308        parse_document(source)
309    }
310
311    fn effective_definitions(
312        &self,
313        tail_definitions: &[ParsedReferenceDefinition],
314    ) -> BTreeMap<String, ReferenceDefinition> {
315        let mut definitions = self.stable_reference_definitions.clone();
316        for definition in tail_definitions {
317            definitions
318                .entry(definition.label.clone())
319                .or_insert_with(|| definition.definition.clone());
320        }
321        definitions
322    }
323
324    fn apply_parsed(&mut self, parsed: ParsedDocument) {
325        let split = parsed.blocks.last().map_or(0, |block| block.range.start);
326        self.stable_reference_dependencies.extend(
327            parsed
328                .blocks
329                .iter()
330                .filter(|block| block.range.end <= split)
331                .flat_map(|block| block.reference_dependencies.iter().cloned()),
332        );
333        self.stable_blocks.extend(
334            parsed
335                .blocks
336                .iter()
337                .filter(|block| block.range.end <= split)
338                .map(|block| block.block.clone()),
339        );
340        self.tail_blocks = parsed
341            .blocks
342            .into_iter()
343            .filter(|block| block.range.start >= split)
344            .map(|block| block.block)
345            .collect();
346        for definition in parsed.definitions {
347            if definition.span.end <= split {
348                self.stable_reference_definitions
349                    .entry(definition.label)
350                    .or_insert(definition.definition);
351            }
352        }
353        self.stable_prefix_len += split;
354    }
355}
356
357fn markdown_options() -> Options {
358    Options::ENABLE_STRIKETHROUGH
359        | Options::ENABLE_TABLES
360        | Options::ENABLE_TASKLISTS
361        | Options::ENABLE_GFM
362}
363
364struct ParsedBlock {
365    block: MarkdownBlock,
366    range: std::ops::Range<usize>,
367    reference_dependencies: BTreeSet<String>,
368}
369
370struct ParsedDocument {
371    blocks: Vec<ParsedBlock>,
372    definitions: Vec<ParsedReferenceDefinition>,
373}
374
375#[derive(Clone, Debug, Eq, PartialEq)]
376struct ReferenceDefinition {
377    destination: String,
378    title: Option<String>,
379}
380
381struct ParsedReferenceDefinition {
382    label: String,
383    definition: ReferenceDefinition,
384    span: std::ops::Range<usize>,
385}
386
387fn parse_document(source: &str) -> ParsedDocument {
388    let (definitions, events, broken_references) = {
389        let mut broken_references = Vec::new();
390        let mut broken_link_callback = |broken: BrokenLink<'_>| {
391            broken_references.push((
392                broken.span,
393                normalize_reference_label(broken.reference.as_ref()),
394            ));
395            None
396        };
397        let parser = Parser::new_with_broken_link_callback(
398            source,
399            markdown_options(),
400            Some(&mut broken_link_callback),
401        );
402        let definitions = parser
403            .reference_definitions()
404            .iter()
405            .map(|(label, definition)| ParsedReferenceDefinition {
406                label: normalize_reference_label(label),
407                definition: ReferenceDefinition {
408                    destination: definition.dest.to_string(),
409                    title: definition.title.as_ref().map(ToString::to_string),
410                },
411                span: definition.span.clone(),
412            })
413            .collect::<Vec<_>>();
414        let events = parser.into_offset_iter().collect::<Vec<_>>();
415        (definitions, events, broken_references)
416    };
417    let mut blocks = Vec::new();
418    let mut current = Vec::new();
419    let mut current_kind = MarkdownBlockKind::Other;
420    let mut current_start = 0;
421    let mut depth = 0usize;
422
423    for (event, range) in events {
424        if depth == 0 && current.is_empty() {
425            current_start = range.start;
426            current_kind = block_kind(&event);
427        }
428        let current_end = range.end;
429        match &event {
430            Event::Start(_) => depth += 1,
431            Event::End(_) => depth = depth.saturating_sub(1),
432            _ => {}
433        }
434        current.push(event.into_static());
435        if depth == 0 {
436            let range = current_start..current_end;
437            blocks.push(ParsedBlock {
438                block: MarkdownBlock {
439                    kind: current_kind,
440                    events: std::mem::take(&mut current),
441                    source: Arc::from(&source[range.clone()]),
442                    content_hash: foldhash::fast::FixedState::default()
443                        .hash_one(&source[range.clone()]),
444                    content_len: range.len(),
445                    rendered: Arc::new(Mutex::new(None)),
446                },
447                range,
448                reference_dependencies: BTreeSet::new(),
449            });
450        }
451    }
452    for block in &mut blocks {
453        for event in &block.block.events {
454            let Event::Start(Tag::Link { link_type, id, .. } | Tag::Image { link_type, id, .. }) =
455                event
456            else {
457                continue;
458            };
459            if matches!(
460                link_type,
461                LinkType::Reference
462                    | LinkType::ReferenceUnknown
463                    | LinkType::Collapsed
464                    | LinkType::CollapsedUnknown
465                    | LinkType::Shortcut
466                    | LinkType::ShortcutUnknown
467            ) {
468                block
469                    .reference_dependencies
470                    .insert(normalize_reference_label(id.as_ref()));
471            }
472        }
473        block.reference_dependencies.extend(
474            broken_references
475                .iter()
476                .filter(|(span, _)| span.start >= block.range.start && span.end <= block.range.end)
477                .map(|(_, label)| label.clone()),
478        );
479    }
480    ParsedDocument {
481        blocks,
482        definitions,
483    }
484}
485
486fn normalize_reference_label(label: &str) -> String {
487    label
488        .split_whitespace()
489        .collect::<Vec<_>>()
490        .join(" ")
491        .to_lowercase()
492}
493
494fn block_kind(event: &Event<'_>) -> MarkdownBlockKind {
495    match event {
496        Event::Start(Tag::Paragraph) => MarkdownBlockKind::Paragraph,
497        Event::Start(Tag::Heading { .. }) => MarkdownBlockKind::Heading,
498        Event::Start(Tag::BlockQuote(_)) => MarkdownBlockKind::Quote,
499        Event::Start(Tag::CodeBlock(_)) => MarkdownBlockKind::Code,
500        Event::Start(Tag::List(_)) => MarkdownBlockKind::List,
501        Event::Start(Tag::Table(_)) => MarkdownBlockKind::Table,
502        Event::Start(Tag::HtmlBlock) | Event::Html(_) => MarkdownBlockKind::Html,
503        Event::Rule => MarkdownBlockKind::ThematicBreak,
504        _ => MarkdownBlockKind::Other,
505    }
506}
507
508pub trait Highlighter: Send + Sync + 'static {
509    fn highlight(&self, language: &str, code: &str, theme: &Theme) -> Arc<[Line<'static>]>;
510
511    fn highlight_stable(&self, language: &str, code: &str, theme: &Theme) -> Arc<[Line<'static>]> {
512        self.highlight(language, code, theme)
513    }
514
515    fn cache_key(&self) -> u64 {
516        foldhash::fast::FixedState::default().hash_one((
517            TypeId::of::<Self>(),
518            std::ptr::from_ref(self).cast::<()>() as usize,
519        ))
520    }
521}
522
523#[derive(Default)]
524pub struct PlainHighlighter;
525
526impl Highlighter for PlainHighlighter {
527    fn highlight(&self, _language: &str, code: &str, _theme: &Theme) -> Arc<[Line<'static>]> {
528        plain_code_lines(code).into()
529    }
530}
531
532#[derive(Clone, Debug, Eq, Hash, PartialEq)]
533struct IncrementalHighlightKey {
534    language: String,
535    theme: ThemeKey,
536    syntect_theme: &'static str,
537}
538
539#[derive(Debug)]
540struct IncrementalHighlightEntry {
541    complete_source: String,
542    lines: Vec<Line<'static>>,
543    highlight_state: HighlightState,
544    parse_state: ParseState,
545}
546
547pub struct SyntectHighlighter {
548    syntaxes: SyntaxSet,
549    themes: ThemeSet,
550    plain: PlainHighlighter,
551    cache: Mutex<HighlightCache>,
552    incremental: Mutex<HashMap<IncrementalHighlightKey, IncrementalHighlightEntry>>,
553    #[cfg(test)]
554    highlight_calls: AtomicUsize,
555    #[cfg(test)]
556    incremental_bytes: AtomicUsize,
557}
558
559impl Default for SyntectHighlighter {
560    fn default() -> Self {
561        Self {
562            syntaxes: SyntaxSet::load_defaults_newlines(),
563            themes: ThemeSet::load_defaults(),
564            plain: PlainHighlighter,
565            cache: Mutex::new(HighlightCache::default()),
566            incremental: Mutex::new(HashMap::new()),
567            #[cfg(test)]
568            highlight_calls: AtomicUsize::new(0),
569            #[cfg(test)]
570            incremental_bytes: AtomicUsize::new(0),
571        }
572    }
573}
574
575impl Highlighter for SyntectHighlighter {
576    fn highlight(&self, language: &str, code: &str, theme: &Theme) -> Arc<[Line<'static>]> {
577        #[cfg(test)]
578        self.highlight_calls.fetch_add(1, Ordering::Relaxed);
579        let language = normalized_language(language);
580        let Some(syntax) = self
581            .syntaxes
582            .find_syntax_by_token(&language)
583            .or_else(|| self.syntaxes.find_syntax_by_extension(&language))
584        else {
585            return self.plain.highlight(&language, code, theme);
586        };
587        // Match syntax highlighting to the curated surface; colors quantize
588        // away in mono.
589        let theme_name = syntect_theme_name(theme);
590        let Some(syntax_theme) = self.themes.themes.get(theme_name) else {
591            return self.plain.highlight(&language, code, theme);
592        };
593        let complete_len = code.rfind('\n').map_or(0, |index| index + 1);
594        let complete_source = &code[..complete_len];
595        let partial_source = &code[complete_len..];
596        let key = IncrementalHighlightKey {
597            language: language.clone(),
598            theme: theme.key(),
599            syntect_theme: theme_name,
600        };
601        let mut cache = self
602            .incremental
603            .lock()
604            .unwrap_or_else(std::sync::PoisonError::into_inner);
605        let entry = cache.entry(key).or_insert_with(|| {
606            let highlighter = HighlightLines::new(syntax, syntax_theme);
607            let (highlight_state, parse_state) = highlighter.state();
608            IncrementalHighlightEntry {
609                complete_source: String::new(),
610                lines: Vec::new(),
611                highlight_state,
612                parse_state,
613            }
614        });
615        let extends_cached = complete_source.starts_with(&entry.complete_source);
616        let mut highlighter = if extends_cached {
617            HighlightLines::from_state(
618                syntax_theme,
619                entry.highlight_state.clone(),
620                entry.parse_state.clone(),
621            )
622        } else {
623            entry.complete_source.clear();
624            entry.lines.clear();
625            HighlightLines::new(syntax, syntax_theme)
626        };
627        let appended = if extends_cached {
628            &complete_source[entry.complete_source.len()..]
629        } else {
630            complete_source
631        };
632        #[cfg(test)]
633        self.incremental_bytes
634            .fetch_add(appended.len() + partial_source.len(), Ordering::Relaxed);
635        if append_highlighted_lines(
636            &mut highlighter,
637            appended,
638            &self.syntaxes,
639            theme,
640            &mut entry.lines,
641        )
642        .is_err()
643        {
644            let reset = HighlightLines::new(syntax, syntax_theme);
645            let (highlight_state, parse_state) = reset.state();
646            entry.complete_source.clear();
647            entry.lines.clear();
648            entry.highlight_state = highlight_state;
649            entry.parse_state = parse_state;
650            return self.plain.highlight(language.as_str(), code, theme);
651        }
652        entry.complete_source.clear();
653        entry.complete_source.push_str(complete_source);
654        let (highlight_state, parse_state) = highlighter.state();
655        entry.highlight_state = highlight_state;
656        entry.parse_state = parse_state;
657
658        let mut lines = entry.lines.clone();
659        if !partial_source.is_empty() {
660            let mut partial = HighlightLines::from_state(
661                syntax_theme,
662                entry.highlight_state.clone(),
663                entry.parse_state.clone(),
664            );
665            if append_highlighted_lines(
666                &mut partial,
667                partial_source,
668                &self.syntaxes,
669                theme,
670                &mut lines,
671            )
672            .is_err()
673            {
674                return self.plain.highlight(language.as_str(), code, theme);
675            }
676        }
677        if lines.is_empty() {
678            lines.push(Line::default());
679        }
680        lines.into()
681    }
682
683    fn highlight_stable(&self, language: &str, code: &str, theme: &Theme) -> Arc<[Line<'static>]> {
684        let incremental_key = IncrementalHighlightKey {
685            language: normalized_language(language),
686            theme: theme.key(),
687            syntect_theme: syntect_theme_name(theme),
688        };
689        self.incremental
690            .lock()
691            .unwrap_or_else(std::sync::PoisonError::into_inner)
692            .remove(&incremental_key);
693        let key = HighlightCacheKey {
694            content_hash: foldhash::fast::FixedState::default().hash_one((language, code)),
695            code_len: code.len(),
696            language: language.to_owned(),
697            theme: theme.key(),
698            syntect_theme: syntect_theme_name(theme),
699        };
700        {
701            let mut cache = self
702                .cache
703                .lock()
704                .unwrap_or_else(std::sync::PoisonError::into_inner);
705            cache.set_theme(theme.key());
706            if let Some(lines) = cache.get(&key, code) {
707                return lines;
708            }
709        }
710
711        #[cfg(test)]
712        self.highlight_calls.fetch_add(1, Ordering::Relaxed);
713        let lines = self.highlight_fresh(language, code, theme);
714        let mut cache = self
715            .cache
716            .lock()
717            .unwrap_or_else(std::sync::PoisonError::into_inner);
718        cache.set_theme(theme.key());
719        cache.insert(key, code, Arc::clone(&lines));
720        lines
721    }
722}
723
724impl SyntectHighlighter {
725    fn highlight_fresh(&self, language: &str, code: &str, theme: &Theme) -> Arc<[Line<'static>]> {
726        let language = normalized_language(language);
727        let Some(syntax) = self
728            .syntaxes
729            .find_syntax_by_token(&language)
730            .or_else(|| self.syntaxes.find_syntax_by_extension(&language))
731        else {
732            return self.plain.highlight(&language, code, theme);
733        };
734        let Some(syntax_theme) = self.themes.themes.get(syntect_theme_name(theme)) else {
735            return self.plain.highlight(&language, code, theme);
736        };
737        let mut highlighter = HighlightLines::new(syntax, syntax_theme);
738        let mut lines = Vec::new();
739        if append_highlighted_lines(&mut highlighter, code, &self.syntaxes, theme, &mut lines)
740            .is_err()
741        {
742            return self.plain.highlight(&language, code, theme);
743        }
744        if lines.is_empty() {
745            lines.push(Line::default());
746        }
747        lines.into()
748    }
749}
750
751fn append_highlighted_lines(
752    highlighter: &mut HighlightLines<'_>,
753    source: &str,
754    syntaxes: &SyntaxSet,
755    theme: &Theme,
756    lines: &mut Vec<Line<'static>>,
757) -> Result<(), syntect::Error> {
758    for source_line in LinesWithEndings::from(source) {
759        let ranges = highlighter.highlight_line(source_line, syntaxes)?;
760        let spans = ranges
761            .into_iter()
762            .filter_map(|(syntect_style, content)| {
763                let content = content.strip_suffix('\n').unwrap_or(content);
764                let content = content.strip_suffix('\r').unwrap_or(content);
765                (!content.is_empty()).then(|| {
766                    Span::styled(
767                        content.to_owned(),
768                        terminal_style_from_syntect(syntect_style, theme),
769                    )
770                })
771            })
772            .collect::<Vec<_>>();
773        lines.push(Line::from(spans));
774    }
775    Ok(())
776}
777
778#[cfg(test)]
779impl SyntectHighlighter {
780    fn highlight_calls(&self) -> usize {
781        self.highlight_calls.load(Ordering::Relaxed)
782    }
783
784    fn incremental_bytes(&self) -> usize {
785        self.incremental_bytes.load(Ordering::Relaxed)
786    }
787
788    fn set_cache_budget(&self, budget_bytes: usize) {
789        let mut cache = self
790            .cache
791            .lock()
792            .unwrap_or_else(std::sync::PoisonError::into_inner);
793        cache.entries = HashMap::new();
794        cache.used_bytes = 0;
795        cache.budget_bytes = budget_bytes;
796    }
797}
798
799fn syntect_theme_name(theme: &Theme) -> &'static str {
800    match theme.key().kind {
801        ThemeKind::Dark | ThemeKind::HighContrast => "base16-eighties.dark",
802        ThemeKind::Default | ThemeKind::Mono => "InspiredGitHub",
803    }
804}
805
806fn terminal_style_from_syntect(syntect_style: SyntectStyle, theme: &Theme) -> Style {
807    let mut style = Style::default();
808    if let Some(color) = theme.quantize_rgb(
809        syntect_style.foreground.r,
810        syntect_style.foreground.g,
811        syntect_style.foreground.b,
812    ) {
813        style = style.fg(color);
814    }
815    let mut modifiers = Modifier::empty();
816    if syntect_style.font_style.contains(FontStyle::BOLD) {
817        modifiers |= Modifier::BOLD;
818    }
819    if syntect_style.font_style.contains(FontStyle::ITALIC) {
820        modifiers |= Modifier::ITALIC;
821    }
822    if syntect_style.font_style.contains(FontStyle::UNDERLINE) {
823        modifiers |= Modifier::UNDERLINED;
824    }
825    style.add_modifier(modifiers)
826}
827
828fn plain_code_lines(code: &str) -> Vec<Line<'static>> {
829    let mut lines = code
830        .lines()
831        .map(|line| Line::from(line.strip_suffix('\r').unwrap_or(line).to_owned()))
832        .collect::<Vec<_>>();
833    if lines.is_empty() {
834        lines.push(Line::default());
835    }
836    lines
837}
838
839// --- Table layout -----------------------------------------------------------
840//
841// Terminal-native tables: accessible box borders, a distinct header style
842// (bold in every theme, colored where available), per-column alignment, and
843// deterministic width allocation. Narrow terminals fall back to a stacked
844// `Header: value` representation.
845
846use pulldown_cmark::Alignment;
847use unicode_segmentation::UnicodeSegmentation as _;
848use unicode_width::UnicodeWidthStr;
849
850/// Display width of one rendered cell (widest span sequence).
851fn cell_width(spans: &[Span<'static>]) -> usize {
852    spans
853        .iter()
854        .map(|span| UnicodeWidthStr::width(span.content.as_ref()))
855        .sum()
856}
857
858/// Deterministic column widths for the available text width. Natural widths
859// shrink proportionally (columns at least 3 wide); the total never exceeds
860/// `available` when the table fits, otherwise a stacked fallback is used.
861fn table_columns(natural: &[usize], available: usize) -> Option<Vec<usize>> {
862    let columns = natural.len();
863    if columns == 0 {
864        return None;
865    }
866    let borders = columns * 3 + 1; // "│ " per column + closing "│"
867    let budget = available.saturating_sub(borders);
868    let minimum = columns * 3;
869    if budget < minimum.max(8) {
870        return None;
871    }
872    let budget = budget.max(minimum);
873    let total: usize = natural.iter().sum();
874    if total <= budget {
875        return Some(natural.to_vec());
876    }
877    // Shrink the widest columns first, never below 3.
878    let mut widths: Vec<usize> = natural.to_vec();
879    let mut overflow = total - budget;
880    while overflow > 0 {
881        let Some((index, widest)) = widths
882            .iter()
883            .enumerate()
884            .max_by_key(|(_, width)| *width)
885            .map(|(index, width)| (index, *width))
886        else {
887            break;
888        };
889        if widest <= 3 {
890            break;
891        }
892        widths[index] -= 1;
893        overflow -= 1;
894    }
895    Some(widths)
896}
897
898/// Wrap cell spans to a column width on grapheme boundaries, preserving
899/// styles and alignment padding per wrapped row.
900fn wrap_cell(
901    spans: &[Span<'static>],
902    width: usize,
903    alignment: Alignment,
904) -> Vec<Vec<Span<'static>>> {
905    let width = width.max(1);
906    let mut words: Vec<Vec<Span<'static>>> = Vec::new();
907    let mut word = Vec::new();
908    for span in spans {
909        for grapheme in span.content.graphemes(true) {
910            let is_space = grapheme.chars().all(char::is_whitespace);
911            if is_space && !word.is_empty() {
912                word.push(Span::styled(grapheme.to_owned(), span.style));
913                words.push(std::mem::take(&mut word));
914            } else if let Some(last) = word.last_mut()
915                && last.style == span.style
916            {
917                last.content.to_mut().push_str(grapheme);
918            } else {
919                word.push(Span::styled(grapheme.to_owned(), span.style));
920            }
921        }
922    }
923    if !word.is_empty() {
924        words.push(word);
925    }
926
927    let mut rows: Vec<Vec<Span<'static>>> = vec![Vec::new()];
928    let mut column = 0usize;
929    for word in words {
930        let word_width = cell_width(&word);
931        if column > 0 && column + word_width > width {
932            rows.push(Vec::new());
933            column = 0;
934        }
935        if word_width <= width {
936            column += word_width;
937            rows.last_mut().expect("one row").extend(word);
938            continue;
939        }
940        for span in word {
941            for grapheme in span.content.graphemes(true) {
942                let grapheme_width = UnicodeWidthStr::width(grapheme).max(1);
943                if column > 0 && column + grapheme_width > width {
944                    rows.push(Vec::new());
945                    column = 0;
946                }
947                if let Some(last) = rows.last_mut().and_then(|row| row.last_mut())
948                    && last.style == span.style
949                {
950                    last.content.to_mut().push_str(grapheme);
951                } else {
952                    rows.last_mut()
953                        .expect("one row")
954                        .push(Span::styled(grapheme.to_owned(), span.style));
955                }
956                column += grapheme_width;
957            }
958        }
959    }
960    // Alignment is applied at line assembly; here rows are raw cell content.
961    let _ = alignment;
962    rows
963}
964
965fn pad_cell(
966    mut spans: Vec<Span<'static>>,
967    width: usize,
968    alignment: Alignment,
969) -> Vec<Span<'static>> {
970    let used = cell_width(&spans);
971    if used > width {
972        let mut truncated = Vec::new();
973        let mut remaining = width;
974        for span in spans {
975            let mut content = String::new();
976            for grapheme in span.content.graphemes(true) {
977                let grapheme_width = UnicodeWidthStr::width(grapheme).max(1);
978                if grapheme_width > remaining {
979                    break;
980                }
981                content.push_str(grapheme);
982                remaining -= grapheme_width;
983            }
984            if !content.is_empty() {
985                truncated.push(Span::styled(content, span.style));
986            }
987            if remaining == 0 {
988                break;
989            }
990        }
991        return truncated;
992    }
993    if used >= width {
994        return spans;
995    }
996    let padding = width - used;
997    let (left, right) = match alignment {
998        Alignment::Left => (0, padding),
999        Alignment::Right => (padding, 0),
1000        Alignment::Center => (padding / 2, padding - padding / 2),
1001        Alignment::None => (0, padding),
1002    };
1003    let mut padded = Vec::new();
1004    if left > 0 {
1005        padded.push(Span::raw(" ".repeat(left)));
1006    }
1007    padded.append(&mut spans);
1008    if right > 0 {
1009        padded.push(Span::raw(" ".repeat(right)));
1010    }
1011    padded
1012}
1013
1014/// Sanitize cell text: control characters (except those already handled by
1015/// the parser) render as the replacement character so terminal control
1016/// sequences can never be injected through table content.
1017fn sanitize_cell(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
1018    let sanitized = spans
1019        .into_iter()
1020        .map(|span| {
1021            let content = span
1022                .content
1023                .chars()
1024                .map(|character| {
1025                    if character.is_control() && character != '\t' {
1026                        '\u{FFFD}'
1027                    } else {
1028                        character
1029                    }
1030                })
1031                .collect::<String>();
1032            Span::styled(content, span.style)
1033        })
1034        .collect();
1035    expand_tabs_in_spans(sanitized)
1036}
1037
1038fn border_row(
1039    left: &str,
1040    junction: &str,
1041    right: &str,
1042    widths: &[usize],
1043    quote_prefix: &str,
1044    theme: &Theme,
1045) -> Line<'static> {
1046    let mut spans = vec![
1047        Span::styled(quote_prefix.to_owned(), theme.quote()),
1048        Span::styled(left.to_owned(), theme.code_border()),
1049    ];
1050    for (index, width) in widths.iter().enumerate() {
1051        spans.push(Span::styled("─".repeat(width + 2), theme.code_border()));
1052        spans.push(Span::styled(
1053            if index + 1 == widths.len() {
1054                right.to_owned()
1055            } else {
1056                junction.to_owned()
1057            },
1058            theme.code_border(),
1059        ));
1060    }
1061    Line::from(spans)
1062}
1063
1064fn cell_row(
1065    cells: &[Vec<Span<'static>>],
1066    widths: &[usize],
1067    alignments: &[Alignment],
1068    quote_prefix: &str,
1069    theme: &Theme,
1070    style: Style,
1071) -> Vec<Line<'static>> {
1072    let wrapped: Vec<Vec<Vec<Span<'static>>>> = cells
1073        .iter()
1074        .enumerate()
1075        .map(|(index, cell)| {
1076            wrap_cell(
1077                cell,
1078                widths.get(index).copied().unwrap_or(3),
1079                alignments.get(index).copied().unwrap_or(Alignment::None),
1080            )
1081        })
1082        .collect();
1083    let height = wrapped.iter().map(Vec::len).max().unwrap_or(1);
1084    let mut lines = Vec::new();
1085    for row in 0..height {
1086        let mut spans = vec![
1087            Span::styled(quote_prefix.to_owned(), theme.quote()),
1088            Span::styled("│ ".to_owned(), theme.code_border()),
1089        ];
1090        for (index, width) in widths.iter().enumerate() {
1091            let cell = wrapped
1092                .get(index)
1093                .and_then(|rows| rows.get(row))
1094                .cloned()
1095                .unwrap_or_default();
1096            let mut padded = pad_cell(
1097                sanitize_cell(cell),
1098                *width,
1099                alignments.get(index).copied().unwrap_or(Alignment::None),
1100            );
1101            for span in &mut padded {
1102                span.style = span.style.patch(style);
1103            }
1104            spans.append(&mut padded);
1105            spans.push(Span::styled(
1106                if index + 1 == widths.len() {
1107                    " │".to_owned()
1108                } else {
1109                    " │ ".to_owned()
1110                },
1111                theme.code_border(),
1112            ));
1113        }
1114        lines.push(Line::from(spans));
1115    }
1116    lines
1117}
1118
1119fn render_table(
1120    alignments: &[Alignment],
1121    header: &[Vec<Span<'static>>],
1122    rows: &[Vec<Vec<Span<'static>>>],
1123    quote_prefix: &str,
1124    available: usize,
1125    theme: &Theme,
1126) -> Vec<Line<'static>> {
1127    let columns = alignments.len().max(header.len());
1128    let mut natural = vec![3usize; columns];
1129    for (index, width) in natural.iter_mut().enumerate() {
1130        if let Some(cell) = header.get(index) {
1131            *width = (*width).max(cell_width(cell));
1132        }
1133        for row in rows {
1134            if let Some(cell) = row.get(index) {
1135                *width = (*width).max(cell_width(cell));
1136            }
1137        }
1138        *width = (*width).min(48);
1139    }
1140    let Some(widths) = table_columns(&natural, available) else {
1141        // Stacked fallback: one "Header: value" row per body cell.
1142        let mut lines = Vec::new();
1143        for row in rows {
1144            for (index, cell) in row.iter().enumerate() {
1145                let header_text = header
1146                    .get(index)
1147                    .map(|spans| {
1148                        spans
1149                            .iter()
1150                            .map(|span| span.content.as_ref())
1151                            .collect::<String>()
1152                    })
1153                    .filter(|text| !text.is_empty())
1154                    .unwrap_or_else(|| format!("column {}", index + 1));
1155                let marker = if available >= 2 { "· " } else { "" };
1156                let mut spans = vec![
1157                    Span::styled(quote_prefix.to_owned(), theme.quote()),
1158                    Span::styled(marker.to_owned(), theme.code_border()),
1159                    Span::styled(format!("{header_text}: "), theme.heading()),
1160                ];
1161                spans.extend(sanitize_cell(cell.clone()));
1162                let wrapped = wrap_cell(
1163                    &spans[1..],
1164                    available.saturating_sub(UnicodeWidthStr::width(marker)),
1165                    Alignment::Left,
1166                );
1167                for row in wrapped {
1168                    let mut line = vec![spans[0].clone()];
1169                    line.extend(row);
1170                    lines.push(Line::from(line));
1171                }
1172            }
1173        }
1174        if lines.is_empty() {
1175            let marker = if available >= 2 { "· " } else { "" };
1176            let wrapped = wrap_cell(
1177                &[Span::styled(
1178                    format!("{marker}(empty table)"),
1179                    theme.muted(),
1180                )],
1181                available.saturating_sub(UnicodeWidthStr::width(marker)),
1182                Alignment::Left,
1183            );
1184            for row in wrapped {
1185                let mut line = vec![Span::styled(quote_prefix.to_owned(), theme.quote())];
1186                line.extend(row);
1187                lines.push(Line::from(line));
1188            }
1189        }
1190        return lines;
1191    };
1192    let mut lines = vec![border_row("┌", "┬", "┐", &widths, quote_prefix, theme)];
1193    lines.extend(cell_row(
1194        header,
1195        &widths,
1196        alignments,
1197        quote_prefix,
1198        theme,
1199        theme.heading(),
1200    ));
1201    lines.push(border_row("├", "┼", "┤", &widths, quote_prefix, theme));
1202    for row in rows {
1203        lines.extend(cell_row(
1204            row,
1205            &widths,
1206            alignments,
1207            quote_prefix,
1208            theme,
1209            theme.body(),
1210        ));
1211    }
1212    lines.push(border_row("└", "┴", "┘", &widths, quote_prefix, theme));
1213    lines
1214}
1215
1216pub fn render_markdown(
1217    document: &MarkdownDocument,
1218    theme: &Theme,
1219    highlighter: &dyn Highlighter,
1220) -> Vec<Line<'static>> {
1221    render_markdown_width(document, theme, highlighter, u16::MAX)
1222}
1223
1224/// Render with a known text width so tables can allocate columns and fall
1225/// back to a stacked form on narrow terminals. `u16::MAX` means unbounded.
1226pub fn render_markdown_width(
1227    document: &MarkdownDocument,
1228    theme: &Theme,
1229    highlighter: &dyn Highlighter,
1230    width: u16,
1231) -> Vec<Line<'static>> {
1232    render_markdown_lines_width(document, theme, highlighter, width)
1233        .into_iter()
1234        .map(|line| line.line)
1235        .collect()
1236}
1237
1238pub(crate) fn render_markdown_lines_width(
1239    document: &MarkdownDocument,
1240    theme: &Theme,
1241    highlighter: &dyn Highlighter,
1242    width: u16,
1243) -> Vec<MarkdownLine> {
1244    let mut lines = Vec::new();
1245    let highlighter_key = highlighter.cache_key();
1246    for block in &document.stable_blocks {
1247        let key = RenderedBlockCacheKey {
1248            content_hash: block.content_hash,
1249            code_len: block.content_len,
1250            width,
1251            theme: theme.key(),
1252            quote_depth: 0,
1253            highlighter: highlighter_key,
1254        };
1255        let cached = block
1256            .rendered
1257            .lock()
1258            .unwrap_or_else(std::sync::PoisonError::into_inner)
1259            .as_ref()
1260            .filter(|entry| entry.key == key && entry.source == block.source)
1261            .map(|entry| Arc::clone(&entry.lines));
1262        let rendered = cached.unwrap_or_else(|| {
1263            let rendered: Arc<[MarkdownLine]> =
1264                render_block(block, true, theme, highlighter, width).into();
1265            *block
1266                .rendered
1267                .lock()
1268                .unwrap_or_else(std::sync::PoisonError::into_inner) =
1269                Some(RenderedBlockCacheEntry {
1270                    key,
1271                    source: Arc::clone(&block.source),
1272                    lines: Arc::clone(&rendered),
1273                });
1274            rendered
1275        });
1276        push_block_lines(&mut lines, rendered.iter().cloned());
1277    }
1278    // The streaming tail renders block by block like the stable prefix, so a
1279    // block looks the same before and after it settles.
1280    for block in &document.tail_blocks {
1281        push_block_lines(
1282            &mut lines,
1283            render_block(block, false, theme, highlighter, width),
1284        );
1285    }
1286    if lines.is_empty() {
1287        lines.push(MarkdownLine::default());
1288    }
1289    lines
1290}
1291
1292/// Append one top-level block's lines, separated from the previous block by
1293/// a blank line. Blocks that render nothing (reference definitions, empty
1294/// HTML) add no gap.
1295fn push_block_lines(lines: &mut Vec<MarkdownLine>, block: impl IntoIterator<Item = MarkdownLine>) {
1296    let mut block = block.into_iter().peekable();
1297    if block.peek().is_none() {
1298        return;
1299    }
1300    if !lines.is_empty() {
1301        lines.push(MarkdownLine::default());
1302    }
1303    lines.extend(block);
1304}
1305
1306fn render_block(
1307    block: &MarkdownBlock,
1308    stable: bool,
1309    theme: &Theme,
1310    highlighter: &dyn Highlighter,
1311    width: u16,
1312) -> Vec<MarkdownLine> {
1313    let mut renderer = MarkdownRenderer::new(theme, highlighter);
1314    renderer.width = usize::from(width);
1315    renderer.begin_block(stable);
1316    for event in &block.events {
1317        renderer.event(event);
1318    }
1319    // Stable blocks render independently for cacheability. This flush is
1320    // intentionally a block boundary, so trailing HTML/footnote text cannot
1321    // merge into the following paragraph as it did before block caching.
1322    renderer.flush();
1323    renderer.lines
1324}
1325
1326pub(crate) fn normalized_language(language: &str) -> String {
1327    let language = language
1328        .split_whitespace()
1329        .next()
1330        .unwrap_or_default()
1331        .trim_matches(['{', '}'])
1332        .trim_start_matches('.')
1333        .to_ascii_lowercase();
1334    match language.as_str() {
1335        "rs" => "rust".into(),
1336        "sh" | "shell" | "zsh" => "bash".into(),
1337        "js" | "jsx" => "javascript".into(),
1338        "ts" | "tsx" => "typescript".into(),
1339        "py" => "python".into(),
1340        "rb" => "ruby".into(),
1341        "yml" => "yaml".into(),
1342        "md" => "markdown".into(),
1343        "c++" => "cpp".into(),
1344        "c#" => "cs".into(),
1345        _ => language,
1346    }
1347}
1348
1349/// Half-block caps around a tinted inline-code chip: the right half of the
1350/// cell before the code and the left half of the cell after it are filled
1351/// with the chip colour. Copy extraction drops them by their cap style.
1352pub(crate) const INLINE_CODE_OPEN: &str = "▐";
1353pub(crate) const INLINE_CODE_CLOSE: &str = "▌";
1354
1355struct MarkdownRenderer<'a> {
1356    theme: &'a Theme,
1357    highlighter: &'a dyn Highlighter,
1358    width: usize,
1359    lines: Vec<MarkdownLine>,
1360    current: Vec<Span<'static>>,
1361    current_kind: MarkdownLineKind,
1362    styles: Vec<Style>,
1363    lists: Vec<ListState>,
1364    quote_depth: usize,
1365    code: Option<CodeCapture>,
1366    links: Vec<String>,
1367    image_depth: usize,
1368    table: Option<TableCapture<'a>>,
1369    stable_block: bool,
1370}
1371
1372/// In-progress table capture. Cell inline markup uses a nested renderer so
1373/// emphasis/links/inline code keep their semantic styles inside cells.
1374struct TableCapture<'a> {
1375    alignments: Vec<pulldown_cmark::Alignment>,
1376    header: Vec<Vec<Span<'static>>>,
1377    rows: Vec<Vec<Vec<Span<'static>>>>,
1378    current_row: Vec<Vec<Span<'static>>>,
1379    cell: Option<Box<MarkdownRenderer<'a>>>,
1380}
1381
1382struct ListState {
1383    next: Option<u64>,
1384    item_content_indent: Option<usize>,
1385}
1386
1387struct CodeCapture {
1388    language: String,
1389    code: String,
1390    stable: bool,
1391}
1392
1393impl<'a> MarkdownRenderer<'a> {
1394    fn new(theme: &'a Theme, highlighter: &'a dyn Highlighter) -> Self {
1395        Self {
1396            theme,
1397            highlighter,
1398            width: usize::MAX,
1399            lines: Vec::new(),
1400            current: Vec::new(),
1401            current_kind: MarkdownLineKind::Prose,
1402            styles: vec![theme.body()],
1403            lists: Vec::new(),
1404            quote_depth: 0,
1405            code: None,
1406            links: Vec::new(),
1407            image_depth: 0,
1408            table: None,
1409            stable_block: false,
1410        }
1411    }
1412
1413    fn begin_block(&mut self, stable_block: bool) {
1414        self.stable_block = stable_block;
1415    }
1416
1417    fn event(&mut self, event: &Event<'static>) {
1418        if let Some(code) = &mut self.code {
1419            match event {
1420                Event::Text(text) => code.code.push_str(text),
1421                Event::End(TagEnd::CodeBlock) => self.finish_code(),
1422                _ => {}
1423            }
1424            return;
1425        }
1426        // Inside a table, structure events drive the capture; everything else
1427        // is forwarded into the active cell renderer.
1428        if self.table.is_some()
1429            && !matches!(
1430                event,
1431                Event::Start(Tag::Table(_) | Tag::TableHead | Tag::TableRow | Tag::TableCell)
1432                    | Event::End(
1433                        TagEnd::Table | TagEnd::TableHead | TagEnd::TableRow | TagEnd::TableCell
1434                    )
1435            )
1436        {
1437            if let Some(cell) = self
1438                .table
1439                .as_mut()
1440                .and_then(|table| table.cell.as_deref_mut())
1441            {
1442                cell.event(event);
1443            }
1444            return;
1445        }
1446        match event {
1447            Event::Start(tag) => self.start(tag),
1448            Event::End(tag) => self.end(*tag),
1449            Event::Text(text) | Event::Html(text) | Event::InlineHtml(text) => self.text(text),
1450            // Inline code is marked by its own colour where the theme has
1451            // one, by a tinted chip widened with half-block caps where the
1452            // theme tints it, and otherwise by its backticks.
1453            Event::Code(code) => {
1454                let style = self
1455                    .styles
1456                    .last()
1457                    .copied()
1458                    .unwrap_or_default()
1459                    .patch(self.theme.inline_code());
1460                match self.theme.inline_code_cap() {
1461                    Some(cap) => {
1462                        self.span(INLINE_CODE_OPEN.to_owned(), cap);
1463                        self.span(code.to_string(), style);
1464                        self.span(INLINE_CODE_CLOSE.to_owned(), cap);
1465                    }
1466                    None if self.theme.inline_code_is_coloured() => {
1467                        self.span(code.to_string(), style);
1468                    }
1469                    None => self.span(format!("`{code}`"), style),
1470                }
1471            }
1472            Event::InlineMath(math) => self.span(format!("${math}$"), self.theme.inline_code()),
1473            Event::DisplayMath(math) => {
1474                self.flush();
1475                self.span(format!("$$ {math} $$"), self.theme.inline_code());
1476                self.flush();
1477            }
1478            Event::FootnoteReference(reference) => {
1479                self.span(format!("[^{reference}]"), self.theme.link())
1480            }
1481            Event::SoftBreak => self.text(" "),
1482            Event::HardBreak => {
1483                self.flush();
1484                self.start_line_prefix();
1485            }
1486            Event::Rule => {
1487                self.flush();
1488                self.start_line_prefix();
1489                let prefix_width = cell_width(&self.current);
1490                let rule_width = if self.width == usize::from(u16::MAX) {
1491                    16
1492                } else {
1493                    self.width.saturating_sub(prefix_width)
1494                };
1495                self.span("─".repeat(rule_width), self.theme.muted());
1496                self.flush();
1497            }
1498            Event::TaskListMarker(done) => self.span(
1499                if *done { "[x] " } else { "[ ] " }.into(),
1500                self.theme.tool(),
1501            ),
1502        }
1503    }
1504
1505    fn start(&mut self, tag: &Tag<'static>) {
1506        match tag {
1507            Tag::Paragraph => self.start_line_prefix(),
1508            Tag::Heading { level, .. } => {
1509                self.flush();
1510                self.start_line_prefix();
1511                let style = match level {
1512                    HeadingLevel::H1 => self
1513                        .theme
1514                        .heading()
1515                        .add_modifier(Modifier::BOLD | Modifier::UNDERLINED),
1516                    HeadingLevel::H2 => self.theme.heading().remove_modifier(Modifier::UNDERLINED),
1517                    HeadingLevel::H3 | HeadingLevel::H4 | HeadingLevel::H5 | HeadingLevel::H6 => {
1518                        self.theme
1519                            .heading()
1520                            .remove_modifier(Modifier::BOLD | Modifier::UNDERLINED)
1521                            .add_modifier(Modifier::ITALIC)
1522                    }
1523                };
1524                self.styles.push(style);
1525            }
1526            Tag::BlockQuote(kind) => {
1527                self.flush();
1528                self.quote_depth += 1;
1529                self.start_line_prefix();
1530                if let Some(kind) = kind {
1531                    self.span(format!("{kind:?}: ").to_uppercase(), self.theme.quote());
1532                }
1533            }
1534            Tag::CodeBlock(kind) => {
1535                self.flush();
1536                self.code = Some(CodeCapture {
1537                    language: match kind {
1538                        CodeBlockKind::Indented => String::new(),
1539                        CodeBlockKind::Fenced(language) => language.to_string(),
1540                    },
1541                    code: String::new(),
1542                    stable: self.stable_block,
1543                });
1544            }
1545            Tag::List(start) => {
1546                self.flush();
1547                self.lists.push(ListState {
1548                    next: *start,
1549                    item_content_indent: None,
1550                });
1551            }
1552            Tag::Item => {
1553                self.flush();
1554                self.start_quote_prefix();
1555                let depth = self.lists.len().saturating_sub(1);
1556                self.span("  ".repeat(depth), self.theme.body());
1557                let marker = self.lists.last_mut().map_or_else(
1558                    || "• ".to_owned(),
1559                    |list| match &mut list.next {
1560                        Some(next) => {
1561                            let marker = format!("{next}. ");
1562                            *next += 1;
1563                            marker
1564                        }
1565                        None => "• ".to_owned(),
1566                    },
1567                );
1568                self.span(marker, self.theme.tool());
1569                let continuation_indent = cell_width(&self.current);
1570                self.current_kind = MarkdownLineKind::ListItem {
1571                    continuation_indent,
1572                };
1573                if let Some(list) = self.lists.last_mut() {
1574                    list.item_content_indent = Some(continuation_indent);
1575                }
1576            }
1577            Tag::Table(alignments) => {
1578                self.flush();
1579                self.table = Some(TableCapture {
1580                    alignments: alignments.clone(),
1581                    header: Vec::new(),
1582                    rows: Vec::new(),
1583                    current_row: Vec::new(),
1584                    cell: None,
1585                });
1586            }
1587            Tag::TableHead | Tag::TableRow => {
1588                if let Some(table) = &mut self.table {
1589                    table.current_row = Vec::new();
1590                }
1591            }
1592            Tag::TableCell => {
1593                if let Some(table) = &mut self.table {
1594                    table.cell = Some(Box::new(MarkdownRenderer::new(
1595                        self.theme,
1596                        self.highlighter,
1597                    )));
1598                }
1599            }
1600            Tag::Emphasis => self.push_modifier(Modifier::ITALIC),
1601            Tag::Strong => self.push_modifier(Modifier::BOLD),
1602            Tag::Strikethrough => self.push_modifier(Modifier::CROSSED_OUT),
1603            Tag::Link { dest_url, .. } => {
1604                self.links.push(dest_url.to_string());
1605                self.styles.push(self.theme.link());
1606            }
1607            Tag::Image { dest_url, .. } => {
1608                self.image_depth += 1;
1609                self.links.push(dest_url.to_string());
1610                self.span("[image: ".into(), self.theme.link());
1611            }
1612            Tag::HtmlBlock
1613            | Tag::FootnoteDefinition(_)
1614            | Tag::MetadataBlock(_)
1615            | Tag::DefinitionList
1616            | Tag::DefinitionListTitle
1617            | Tag::DefinitionListDefinition
1618            | Tag::Superscript
1619            | Tag::Subscript => {}
1620        }
1621    }
1622
1623    fn end(&mut self, tag: TagEnd) {
1624        match tag {
1625            TagEnd::Paragraph => self.flush(),
1626            TagEnd::Item => {
1627                self.flush();
1628                if let Some(list) = self.lists.last_mut() {
1629                    list.item_content_indent = None;
1630                }
1631            }
1632            TagEnd::Heading(_) => {
1633                self.styles.pop();
1634                self.flush();
1635            }
1636            TagEnd::BlockQuote(_) => {
1637                self.flush();
1638                self.quote_depth = self.quote_depth.saturating_sub(1);
1639            }
1640            TagEnd::List(_) => {
1641                self.flush();
1642                self.lists.pop();
1643            }
1644            TagEnd::Table => self.finish_table(),
1645            TagEnd::TableHead => {
1646                if let Some(table) = &mut self.table {
1647                    table.header = std::mem::take(&mut table.current_row);
1648                }
1649            }
1650            TagEnd::TableRow => {
1651                if let Some(table) = &mut self.table {
1652                    table.rows.push(std::mem::take(&mut table.current_row));
1653                }
1654            }
1655            TagEnd::TableCell => {
1656                if let Some(table) = &mut self.table
1657                    && let Some(cell) = table.cell.take()
1658                {
1659                    table.current_row.push(
1660                        cell.finish()
1661                            .into_iter()
1662                            .next()
1663                            .map(|line| line.line.spans)
1664                            .unwrap_or_default(),
1665                    );
1666                }
1667            }
1668            TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => {
1669                self.styles.pop();
1670            }
1671            TagEnd::Link => {
1672                self.styles.pop();
1673                if let Some(destination) = self.links.pop() {
1674                    self.span(format!(" <{destination}>"), self.theme.link());
1675                }
1676            }
1677            TagEnd::Image => {
1678                if let Some(destination) = self.links.pop() {
1679                    self.span(format!(" -> {destination}]"), self.theme.link());
1680                }
1681                self.image_depth = self.image_depth.saturating_sub(1);
1682            }
1683            TagEnd::CodeBlock
1684            | TagEnd::HtmlBlock
1685            | TagEnd::FootnoteDefinition
1686            | TagEnd::MetadataBlock(_)
1687            | TagEnd::DefinitionList
1688            | TagEnd::DefinitionListTitle
1689            | TagEnd::DefinitionListDefinition
1690            | TagEnd::Superscript
1691            | TagEnd::Subscript => {}
1692        }
1693    }
1694
1695    fn push_modifier(&mut self, modifier: Modifier) {
1696        let style = self
1697            .styles
1698            .last()
1699            .copied()
1700            .unwrap_or_default()
1701            .add_modifier(modifier);
1702        self.styles.push(style);
1703    }
1704
1705    fn text(&mut self, text: &str) {
1706        for (index, part) in text.split('\n').enumerate() {
1707            if index > 0 {
1708                self.flush();
1709                self.start_line_prefix();
1710            }
1711            if !part.is_empty() {
1712                self.span(
1713                    part.to_owned(),
1714                    self.styles.last().copied().unwrap_or_default(),
1715                );
1716            }
1717        }
1718    }
1719
1720    fn start_line_prefix(&mut self) {
1721        if !self.current.is_empty() {
1722            return;
1723        }
1724        self.start_quote_prefix();
1725        if let Some(continuation_indent) = self
1726            .lists
1727            .iter()
1728            .rev()
1729            .find_map(|list| list.item_content_indent)
1730        {
1731            let prefix_width = cell_width(&self.current);
1732            self.span(
1733                " ".repeat(continuation_indent.saturating_sub(prefix_width)),
1734                self.theme.body(),
1735            );
1736            self.current_kind = MarkdownLineKind::ListItem {
1737                continuation_indent,
1738            };
1739        }
1740    }
1741
1742    fn start_quote_prefix(&mut self) {
1743        if self.current.is_empty() && self.quote_depth > 0 {
1744            self.span("> ".repeat(self.quote_depth), self.theme.quote());
1745        }
1746    }
1747
1748    fn span(&mut self, content: String, style: Style) {
1749        if let Some(last) = self.current.last_mut()
1750            && last.style == style
1751        {
1752            last.content.to_mut().push_str(&content);
1753        } else {
1754            self.current.push(Span::styled(content, style));
1755        }
1756    }
1757
1758    fn flush(&mut self) {
1759        if !self.current.is_empty() {
1760            self.lines.push(MarkdownLine {
1761                line: Line::from(std::mem::take(&mut self.current)),
1762                kind: self.current_kind,
1763            });
1764            self.current_kind = MarkdownLineKind::Prose;
1765        }
1766    }
1767
1768    fn finish_code(&mut self) {
1769        let Some(code) = self.code.take() else {
1770            return;
1771        };
1772        let label = code.language.split_whitespace().next().unwrap_or_default();
1773        // Highlighting is cached before code-band styling and width-dependent
1774        // wrapping, so stable entries survive terminal resizes.
1775        let highlighted = if code.stable {
1776            self.highlighter
1777                .highlight_stable(label, &code.code, self.theme)
1778        } else {
1779            self.highlighter.highlight(label, &code.code, self.theme)
1780        };
1781        // The language only picks the highlighter; it is never displayed.
1782        match self.theme.code_background() {
1783            Some(background) => self.finish_banded_code(&highlighted, background),
1784            None => self.finish_framed_code(&highlighted),
1785        }
1786    }
1787
1788    /// A tinted band is the block's whole frame: each code row padded so the
1789    /// band is a solid rectangle across the available width. Wrapped
1790    /// continuations start with `↪`. Each row's one-cell left marker carries
1791    /// the code-border style, which is how copy extraction drops it.
1792    fn finish_banded_code(&mut self, highlighted: &[Line<'static>], background: Color) {
1793        let quote_prefix = "> ".repeat(self.quote_depth);
1794        let band = Style::default().bg(background);
1795        let marker = self.theme.code_border().patch(band);
1796        let quote_width = UnicodeWidthStr::width(quote_prefix.as_str());
1797        let bounded = self.width != usize::from(u16::MAX);
1798        let content_width = self.width.saturating_sub(quote_width + 1).max(1);
1799        let mut rows = Vec::new();
1800        for line in highlighted {
1801            for (index, content) in wrap_code_spans(
1802                expand_tabs_in_spans(line.spans.clone()),
1803                content_width,
1804                content_width,
1805            )
1806            .into_iter()
1807            .enumerate()
1808            {
1809                let mut spans = vec![Span::styled(if index == 0 { " " } else { "↪" }, marker)];
1810                spans.extend(content.into_iter().map(|mut span| {
1811                    span.style = span.style.patch(band);
1812                    span
1813                }));
1814                rows.push(spans);
1815            }
1816        }
1817        let band_width = if bounded {
1818            self.width.saturating_sub(quote_width)
1819        } else {
1820            rows.iter()
1821                .map(|row| row.iter().map(Span::width).sum::<usize>())
1822                .max()
1823                .unwrap_or(0)
1824                + 1
1825        };
1826        for mut row in rows {
1827            let used = row.iter().map(Span::width).sum::<usize>();
1828            if used < band_width {
1829                row.push(Span::styled(" ".repeat(band_width - used), band));
1830            }
1831            let mut spans = vec![Span::styled(quote_prefix.clone(), self.theme.quote())];
1832            spans.extend(row);
1833            self.push_code_line(Line::from(spans));
1834        }
1835    }
1836
1837    /// Themes without a band keep the box-drawing frame so code stays
1838    /// distinct from prose.
1839    fn finish_framed_code(&mut self, highlighted: &[Line<'static>]) {
1840        let quote_prefix = "> ".repeat(self.quote_depth);
1841        let border = self.theme.code_border();
1842        self.push_code_line(Line::from(vec![
1843            Span::styled(quote_prefix.clone(), self.theme.quote()),
1844            Span::styled("┌─ code".to_owned(), border),
1845        ]));
1846        let quote_width = UnicodeWidthStr::width(quote_prefix.as_str());
1847        let first_width = self.width.saturating_sub(quote_width + 2).max(1);
1848        let continuation_width = self.width.saturating_sub(quote_width + 1).max(1);
1849        for line in highlighted {
1850            let line_style = line.style;
1851            for (index, content) in wrap_code_spans(
1852                expand_tabs_in_spans(line.spans.clone()),
1853                first_width,
1854                continuation_width,
1855            )
1856            .into_iter()
1857            .enumerate()
1858            {
1859                let mut spans = vec![
1860                    Span::styled(quote_prefix.clone(), self.theme.quote()),
1861                    Span::styled(if index == 0 { "│ " } else { "│" }, border),
1862                ];
1863                spans.extend(content);
1864                self.push_code_line(Line::from(spans).style(line_style));
1865            }
1866        }
1867        self.push_code_line(Line::from(vec![
1868            Span::styled(quote_prefix, self.theme.quote()),
1869            Span::styled("└─".to_owned(), border),
1870        ]));
1871    }
1872
1873    fn push_code_line(&mut self, line: Line<'static>) {
1874        self.lines.push(MarkdownLine {
1875            line,
1876            kind: MarkdownLineKind::Code,
1877        });
1878    }
1879
1880    /// Render the captured table as a terminal-native grid. Column widths
1881    /// come from display widths of grapheme-safe cell text; alignment markers
1882    /// are honored per column. Narrow tables fall back to a stacked
1883    /// `Header: value` representation rather than unusable slivers.
1884    fn finish_table(&mut self) {
1885        let Some(table) = self.table.take() else {
1886            return;
1887        };
1888        let quote_prefix = "> ".repeat(self.quote_depth);
1889        let prefix_width = UnicodeWidthStr::width(quote_prefix.as_str());
1890        let available = self.width.saturating_sub(prefix_width).max(1);
1891        for line in render_table(
1892            &table.alignments,
1893            &table.header,
1894            &table.rows,
1895            &quote_prefix,
1896            available,
1897            self.theme,
1898        ) {
1899            self.lines.push(MarkdownLine {
1900                line,
1901                kind: MarkdownLineKind::Table,
1902            });
1903        }
1904    }
1905
1906    fn finish(mut self) -> Vec<MarkdownLine> {
1907        self.flush();
1908        if self.lines.is_empty() {
1909            self.lines.push(MarkdownLine::default());
1910        }
1911        self.lines
1912    }
1913}
1914
1915/// Terminal tab stops: every eight columns.
1916pub(crate) const TAB_WIDTH: usize = 8;
1917
1918/// Spaces that take a tab at `column` to the next tab stop.
1919pub(crate) fn tab_advance(column: usize) -> usize {
1920    TAB_WIDTH - column % TAB_WIDTH
1921}
1922
1923/// Expand tabs to the next tab stop, counting columns across spans. Ratatui
1924/// draws nothing for a tab while unicode-width counts it as one column, so an
1925/// unexpanded tab both loses its alignment and leaves an unpainted cell at
1926/// the end of every padded band row.
1927pub(crate) fn expand_tabs_in_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
1928    let mut column = 0;
1929    spans
1930        .into_iter()
1931        .map(|span| {
1932            if !span.content.contains('\t') {
1933                column += span.width();
1934                return span;
1935            }
1936            let mut content = String::with_capacity(span.content.len());
1937            for character in span.content.chars() {
1938                if character == '\t' {
1939                    let advance = tab_advance(column);
1940                    content.extend(std::iter::repeat_n(' ', advance));
1941                    column += advance;
1942                } else {
1943                    content.push(character);
1944                    column += unicode_width::UnicodeWidthChar::width(character).unwrap_or(0);
1945                }
1946            }
1947            Span::styled(content, span.style)
1948        })
1949        .collect()
1950}
1951
1952pub(crate) fn wrap_code_spans(
1953    spans: Vec<Span<'static>>,
1954    first_width: usize,
1955    continuation_width: usize,
1956) -> Vec<Vec<Span<'static>>> {
1957    let mut lines = Vec::new();
1958    let mut current: Vec<Span<'static>> = Vec::new();
1959    let mut current_width = 0;
1960    let mut available = first_width.max(1);
1961
1962    for span in spans {
1963        for grapheme in span.content.graphemes(true) {
1964            let mut grapheme_width = UnicodeWidthStr::width(grapheme);
1965            if current_width > 0 && current_width + grapheme_width > available {
1966                lines.push(std::mem::take(&mut current));
1967                current_width = 0;
1968                available = continuation_width.max(1);
1969            }
1970            let grapheme = if grapheme_width > available {
1971                grapheme_width = 1;
1972                "�"
1973            } else {
1974                grapheme
1975            };
1976            if let Some(last) = current.last_mut()
1977                && last.style == span.style
1978            {
1979                last.content.to_mut().push_str(grapheme);
1980            } else {
1981                current.push(Span::styled(grapheme.to_owned(), span.style));
1982            }
1983            current_width += grapheme_width;
1984        }
1985    }
1986    if !current.is_empty() || lines.is_empty() {
1987        lines.push(current);
1988    }
1989    lines
1990}
1991
1992#[cfg(test)]
1993mod tests;