Files
Kigi-CLI/crates/codegen/kigi-markdown/src/parse.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

2057 lines
88 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Markdown parser - transforms markdown text into styled highlight ranges.
//!
//! The parser processes markdown events and populates buffers with:
//! - Highlights: Style ranges for inline formatting
//! - Replaces: Syntax-highlighted code blocks
//! - Transforms: Character substitutions (bullets, etc.)
//! - Table replaces: Formatted table content
use std::ops::Range;
use anstyle::Style;
use pulldown_cmark::{CodeBlockKind, CowStr, Event, Tag, TagEnd, TextMergeWithOffset};
use ratatui::style::Stylize as RatatuiStylize;
use ratatui::text::{Line, Span};
use crate::buffers::{
CodeBlockMeta, Highlight, LinkTarget, MarkdownBuffers, Replace, StyledCell, TableHyperlink,
TableReplace, TableState, Transform, floor_char_boundary, unicode_display_width,
};
use crate::checkpoint::CheckpointKind;
use crate::latex;
use crate::open_code_highlighter::OpenCodeHighlighter;
use crate::style::{MarkdownStyle, TableBorders};
use crate::syntax::{Syntect, syntax_highlight_raw};
/// Trait for converting anstyle to ratatui style.
trait StyleInto<T> {
fn style_into(self) -> T;
}
impl StyleInto<ratatui::style::Style> for Style {
fn style_into(self) -> ratatui::style::Style {
use ratatui::style::{Modifier, Style as RStyle};
let mut style = RStyle::default();
if let Some(fg) = self.get_fg_color() {
style = style.fg(anstyle_to_ratatui_color(fg));
}
if let Some(bg) = self.get_bg_color() {
style = style.bg(anstyle_to_ratatui_color(bg));
}
let effects = self.get_effects();
let mut modifiers = Modifier::empty();
if effects.contains(anstyle::Effects::BOLD) {
modifiers |= Modifier::BOLD;
}
if effects.contains(anstyle::Effects::DIMMED) {
modifiers |= Modifier::DIM;
}
if effects.contains(anstyle::Effects::ITALIC) {
modifiers |= Modifier::ITALIC;
}
if effects.contains(anstyle::Effects::UNDERLINE) {
modifiers |= Modifier::UNDERLINED;
}
if effects.contains(anstyle::Effects::STRIKETHROUGH) {
modifiers |= Modifier::CROSSED_OUT;
}
if effects.contains(anstyle::Effects::HIDDEN) {
modifiers |= Modifier::HIDDEN;
}
style.add_modifier(modifiers)
}
}
fn anstyle_to_ratatui_color(color: anstyle::Color) -> ratatui::style::Color {
use ratatui::style::Color;
match color {
anstyle::Color::Ansi(ansi) => match ansi {
anstyle::AnsiColor::Black => Color::Black,
anstyle::AnsiColor::Red => Color::Red,
anstyle::AnsiColor::Green => Color::Green,
anstyle::AnsiColor::Yellow => Color::Yellow,
anstyle::AnsiColor::Blue => Color::Blue,
anstyle::AnsiColor::Magenta => Color::Magenta,
anstyle::AnsiColor::Cyan => Color::Cyan,
anstyle::AnsiColor::White => Color::Gray,
anstyle::AnsiColor::BrightBlack => Color::DarkGray,
anstyle::AnsiColor::BrightRed => Color::LightRed,
anstyle::AnsiColor::BrightGreen => Color::LightGreen,
anstyle::AnsiColor::BrightYellow => Color::LightYellow,
anstyle::AnsiColor::BrightBlue => Color::LightBlue,
anstyle::AnsiColor::BrightMagenta => Color::LightMagenta,
anstyle::AnsiColor::BrightCyan => Color::LightCyan,
anstyle::AnsiColor::BrightWhite => Color::White,
},
anstyle::Color::Ansi256(idx) => Color::Indexed(idx.index()),
anstyle::Color::Rgb(rgb) => Color::Rgb(rgb.0, rgb.1, rgb.2),
}
}
/// Find a substring within a haystack, optionally searching outside or using rfind.
fn find_substring(
haystack: &str,
needle: &CowStr,
allow_outside: bool,
rfind: bool,
) -> Option<Range<usize>> {
if needle.is_empty() || haystack.is_empty() || needle.len() > haystack.len() {
return None;
}
if !allow_outside {
if let CowStr::Borrowed(needle) = needle {
let (hp, np) = (haystack.as_ptr(), needle.as_ptr());
unsafe {
let (he, ne) = (hp.add(haystack.len()), np.add(needle.len()));
if np >= hp && ne <= he {
let offset = np.offset_from(hp) as usize;
let range = offset..(offset + needle.len());
if cfg!(debug_assertions) {
assert_eq!(&haystack.as_bytes()[range.clone()], needle.as_bytes());
}
return Some(range);
}
}
}
None
} else {
if rfind {
haystack.rfind(needle.as_ref())
} else {
haystack.find(needle.as_ref())
}
.map(|pos| pos..(pos + needle.len()))
}
}
/// Decode a single HTML character entity reference (`entity` includes the
/// leading `&` and trailing `;`) into its replacement string.
///
/// Delegates to [`html_escape`] for the full HTML5 named set plus numeric
/// references (decimal `&#NN;` and hexadecimal `&#xNN;`), matching what
/// pulldown-cmark decodes in table cells so prose and tables stay consistent.
///
/// Returns `None` when:
/// - the reference is unrecognized (`html_escape` leaves it unchanged), or
/// - it decodes to a control character (`&#27;`, `&#0;`, …). Substituting raw
/// control bytes would let untrusted markdown inject terminal escape
/// sequences, so the raw source is left literal instead.
fn decode_html_entity(entity: &str) -> Option<String> {
let decoded = html_escape::decode_html_entities(entity);
// Unchanged output means `html_escape` did not recognize the reference.
if decoded.as_ref() == entity {
return None;
}
if decoded.chars().any(char::is_control) {
return None;
}
Some(decoded.into_owned())
}
/// Check if there's a blank line (empty line) after the given position.
fn has_blank_line_after(text: &str, pos: usize) -> bool {
text.as_bytes()[pos..]
.iter()
.copied()
.find(|&c| c != b' ' && c != b'\t')
== Some(b'\n')
}
/// Transient state for the fenced code block currently being parsed.
///
/// Fenced blocks never nest (an inner fence closes the outer), so a single
/// `Option` suffices. Finalized in the `TagEnd::CodeBlock` arm, where the body
/// range and the block range together decide whether the fence was closed.
struct PendingCodeBlock {
info: String,
/// Body byte range in the raw source. Initialized to an empty range just
/// past the opening fence line, then widened to the merged body text range
/// as text events arrive (`body_seen` distinguishes the empty-body case).
body_range: Range<usize>,
/// De-prefixed body content: pulldown's merged text gives the logical code
/// with container markers (blockquote `>`, list indent) stripped and CRLF
/// normalized to `\n` — i.e. the clean diagram/code source.
body_text: String,
body_seen: bool,
}
/// Markdown parser that processes events and populates buffers.
///
/// After calling `parse()`, the transient state (tag_stack, table_state, depth)
/// is dropped and a `ParsedMarkdown` is returned for rendering.
pub struct MarkdownParser<'a, 'b, 'syn, 'oc> {
text: &'a str,
ms: MarkdownStyle,
buffers: &'b mut MarkdownBuffers,
syntect: Option<&'syn Syntect>,
/// Incremental highlighter for the trailing still-open fenced code block.
/// Only set by the streaming tail re-render; `None` for batch renders, in
/// which case code blocks go through the from-scratch [`syntax_highlight_raw`].
open_code: Option<&'oc mut OpenCodeHighlighter>,
// Transient state (dropped after parse)
tag_stack: Vec<Tag<'a>>,
table_state: Option<TableState>,
depth: usize,
/// Current blockquote nesting depth (0 = not in any blockquote).
/// Used to determine which `>` on a line belongs to the current level.
bq_depth: usize,
last_checkpoint: Option<(CheckpointKind, usize)>,
/// Maximum width for rendered tables (in display columns).
/// When `Some(w)`, column widths are shrunk proportionally so the table
/// fits within `w` columns. When `None`, columns use natural widths.
max_table_width: Option<usize>,
/// Monotonically increasing counter for assigning stable link IDs.
/// Persisted across `rerender_tail` calls via the streaming renderer.
link_id_counter: u32,
/// When `true` (default), CommonMark soft breaks inside a paragraph
/// collapse to a single space. When `false`, the source newline is
/// preserved so each source line surfaces as its own visual line —
/// required by the line-numbered plan preview, where rendered lines
/// must map 1:1 to file lines.
collapse_soft_breaks: bool,
/// In-progress fenced code block, set between its start and end events.
pending_code_block: Option<PendingCodeBlock>,
}
/// Custom word separator for table cells.
///
/// Like `AsciiSpace`, but also treats punctuation and symbol characters as
/// break opportunities when followed by a letter. This lets tables break
/// lines at e.g. `foo/bar` or `hello-world` without ever splitting mid-word.
///
/// For each break point, the punctuation character is attached to whichever
/// side produces the shorter maximum segment — e.g. `ABCD-EFG` becomes
/// `ABCD` + `-EFG` (max 4) rather than `ABCD-` + `EFG` (max 5).
///
/// Only `,` and `.` between digits suppress the break — these are number
/// formatting (e.g. `$145,000`, `3.14`). All other punctuation can break
/// even between digits, so phone numbers (`555-0101`), dates (`2019-03-15`)
/// etc. become breakable.
///
/// Returns `true` for `<br>`, `<br/>`, `<br />`, etc. (case-insensitive).
fn is_br_tag(html: &str) -> bool {
let Some(inner) = html
.trim()
.strip_prefix('<')
.and_then(|s| s.strip_suffix('>'))
else {
return false;
};
let tag = inner.trim();
let tag = tag.strip_suffix('/').map_or(tag, str::trim);
tag.eq_ignore_ascii_case("br")
}
/// URLs (validated via `url::Url::parse`) are treated as unbreakable
/// words — no break points are placed within a URL so that terminal
/// hyperlink detection (Cmd+Click) continues to work when cells wrap.
pub(crate) fn cell_word_separator<'a>(
line: &'a str,
) -> Box<dyn Iterator<Item = textwrap::core::Word<'a>> + 'a> {
// Pass 1: find break-point byte positions.
// A break point sits between a punctuation/symbol char and the alphabetic
// char that follows it. We record (break_byte_idx, punct_byte_start)
// where break_byte_idx is where the next word would start if we attach
// the punct char to the left, and punct_byte_start is where the punct
// char begins (for attaching it to the right instead).
let mut breaks: Vec<(usize, usize)> = Vec::new();
{
let mut in_whitespace = false;
let mut after_break_char = false;
let mut prev_is_digit = false; // was the *previous* char a digit?
let mut digit_before_break = false; // was the char before the break char a digit?
let mut last_break_ch: char = '\0';
let mut break_char_start: usize = 0;
for (idx, ch) in line.char_indices() {
let is_space = ch == ' ';
let is_break_char = !is_space && !ch.is_alphanumeric();
// After a break char, decide if we should split here.
//
// Two cases allow a break:
// a) Followed by a letter → always break (new word boundary).
// b) Followed by a digit AND the char before the punct was
// also a digit → break, UNLESS the punct is `,` or `.`
// (number formatting like `$145,000` or `3.14`).
//
// This means:
// - `foo/bar` breaks (letter after punct) ✓
// - `555-0101` breaks (digit-hyphen-digit) ✓
// - `$145,000` stays (digit-comma-digit) ✓
// - `$145` stays (no digit before `$`) ✓
// - `EMP-1001` breaks at hyphen (letter before it) ✓
let should_break = if in_whitespace && !is_space {
true
} else if after_break_char {
if ch.is_alphabetic() {
true
} else if ch.is_ascii_digit() && digit_before_break {
// digit-punct-digit: only break for non-formatting punct
last_break_ch != ',' && last_break_ch != '.'
} else {
false
}
} else {
false
};
if should_break {
if in_whitespace {
breaks.push((idx, idx));
} else {
breaks.push((idx, break_char_start));
}
}
if is_break_char {
break_char_start = idx;
last_break_ch = ch;
digit_before_break = prev_is_digit;
}
prev_is_digit = ch.is_ascii_digit();
in_whitespace = is_space;
after_break_char = is_break_char;
}
}
// Filter out break points that fall inside a URL.
// Each whitespace-delimited token is tested with `url::Url::parse`;
// tokens that parse as valid URLs are protected from splitting.
let url_ranges: Vec<Range<usize>> = {
let mut ranges = Vec::new();
let mut pos = 0;
for token in line.split_whitespace() {
let start = line[pos..].find(token).unwrap() + pos;
let end = start + token.len();
if url::Url::parse(token).is_ok() {
ranges.push(start..end);
}
pos = end;
}
ranges
};
breaks.retain(|&(break_pos, _)| {
!url_ranges
.iter()
.any(|r| break_pos > r.start && break_pos < r.end)
});
// Pass 2: decide attachment for each break point.
// For punct breaks, choose the side that minimizes max(left_len, right_len).
let mut split_positions: Vec<usize> = Vec::with_capacity(breaks.len());
{
let len = line.len();
for (i, &(attach_left, attach_right)) in breaks.iter().enumerate() {
if attach_left == attach_right {
// Whitespace break — no choice.
split_positions.push(attach_left);
} else {
// Determine segment boundaries for this break.
let seg_start = if i == 0 { 0 } else { split_positions[i - 1] };
let seg_end = if i + 1 < breaks.len() {
// Use the leftward attachment of the next break as a
// conservative estimate of the right segment end.
breaks[i + 1].0
} else {
len
};
let left_if_attach_left = unicode_display_width(&line[seg_start..attach_left]);
let right_if_attach_left = unicode_display_width(&line[attach_left..seg_end]);
let max_attach_left = left_if_attach_left.max(right_if_attach_left);
let left_if_attach_right = unicode_display_width(&line[seg_start..attach_right]);
let right_if_attach_right = unicode_display_width(&line[attach_right..seg_end]);
let max_attach_right = left_if_attach_right.max(right_if_attach_right);
if max_attach_right < max_attach_left {
split_positions.push(attach_right);
} else {
split_positions.push(attach_left);
}
}
}
}
// Pass 3: emit Words at the chosen split positions.
let mut pos = 0usize;
let mut idx = 0usize;
Box::new(std::iter::from_fn(move || {
if pos >= line.len() {
return None;
}
let end = if idx < split_positions.len() {
let e = split_positions[idx];
idx += 1;
e
} else {
line.len()
};
let word = textwrap::core::Word::from(&line[pos..end]);
pos = end;
Some(word)
}))
}
/// Output of [`MarkdownParser::format_table`]: the rendered lines of a single table.
#[derive(Default)]
struct FormattedTable {
/// Plain-text lines (for ANSI rendering).
lines: Vec<String>,
/// Styled lines (for ratatui rendering).
styled_lines: Vec<Line<'static>>,
/// Per-line source offset within the table (0 = header, 1 = separator, 2+ = body rows).
line_source_offsets: Vec<usize>,
/// Hyperlinks (in table-local line coordinates) for links inside cells.
hyperlinks: Vec<TableHyperlink>,
}
impl<'a, 'b, 'syn, 'oc> MarkdownParser<'a, 'b, 'syn, 'oc> {
pub fn new(
text: &'a str,
ms: MarkdownStyle,
buffers: &'b mut MarkdownBuffers,
syntect: Option<&'syn Syntect>,
) -> Self {
Self {
text,
ms,
buffers,
syntect,
open_code: None,
tag_stack: Vec::new(),
table_state: None,
depth: 0,
bq_depth: 0,
last_checkpoint: None,
max_table_width: None,
link_id_counter: 0,
collapse_soft_breaks: true,
pending_code_block: None,
}
}
/// Set whether CommonMark soft breaks collapse to a space.
///
/// Defaults to `true`. Set `false` for source-faithful rendering (plan
/// preview) where each source line must keep its own visual line and
/// `line_source_map` entry.
pub fn collapse_soft_breaks(mut self, collapse: bool) -> Self {
self.collapse_soft_breaks = collapse;
self
}
/// Set the maximum width for rendered tables.
///
/// When set, column widths are shrunk proportionally so the table
/// fits within the given number of display columns.
pub fn max_table_width(mut self, width: Option<usize>) -> Self {
self.max_table_width = width;
self
}
/// Set the starting link ID counter (for streaming renderer continuity).
///
/// Internal: only the in-crate streaming renderer needs to manage the
/// link counter across `rerender_tail` calls. Consumers should use
/// `StreamingMarkdownRenderer` instead of touching the parser directly.
pub(crate) fn link_id_start(mut self, id: u32) -> Self {
self.link_id_counter = id;
self
}
/// Provide an incremental highlighter for the trailing still-open fenced
/// code block (streaming tail re-render only).
///
/// Internal: lets `rerender_tail` persist syntect's resumable per-line state
/// across passes so an open code block is highlighted in O(N) total instead
/// of O(N²). Batch/non-streaming callers leave this `None`.
pub(crate) fn open_code(mut self, cache: Option<&'oc mut OpenCodeHighlighter>) -> Self {
self.open_code = cache;
self
}
/// Parse markdown and return a ParsedMarkdown ready for rendering.
///
/// Consumes self, dropping transient parsing state.
pub fn parse(mut self) -> ParsedMarkdown<'a, 'b> {
self.tag_stack.clear();
self.buffers.clear();
self.table_state = None;
self.depth = 0;
self.last_checkpoint = None;
self.pending_code_block = None;
for (event, range) in TextMergeWithOffset::new(kigi_markdown_core::offset_events(self.text))
{
self.on_event(event, range);
}
ParsedMarkdown::new(
self.text,
self.ms,
self.buffers,
self.last_checkpoint,
self.link_id_counter,
)
}
fn push_highlight(&mut self, style: Option<Style>, range: &Range<usize>) {
self.buffers.highlights.push(Highlight {
style,
range: range.clone(),
});
}
fn on_event(&mut self, event: Event<'a>, range: Range<usize>) {
let mut parent_code_block = None;
// Apply ALL ancestors' inner styles to non-marker events.
let skip_inner_style = matches!(
event,
Event::Start(_) | Event::End(_) | Event::Code(_) | Event::InlineMath(_)
);
// Collect ancestor styles first (to avoid borrow issues)
let ancestor_styles: Vec<Option<Style>> = if !skip_inner_style {
// Inside a link, inline-format ancestors (strong/emphasis/
// strikethrough) must not recolor the link text: their inner
// styles carry the theme's default text fg, and these highlights
// land *after* the link_text highlight pushed at Tag::Link start
// — merge_styles is last-wins on fg, so keeping the fg would
// clobber the link color (e.g. `**[bold link](url)**`). Only the
// fg competes with link_text today, so effects (and any bg) pass
// through.
let in_link = self
.tag_stack
.iter()
.any(|t| matches!(t, Tag::Link { .. } | Tag::Image { .. }));
let strip_fg_in_link =
|style: Style| if in_link { style.fg_color(None) } else { style };
self.tag_stack
.iter()
.filter_map(|ancestor| match ancestor {
Tag::Heading { level, .. } => {
Some(Some(self.ms.heading_inner[(*level as i32) as usize - 1]))
}
Tag::Emphasis => Some(Some(strip_fg_in_link(self.ms.emphasis_inner))),
Tag::Strong => Some(Some(strip_fg_in_link(self.ms.strong_inner))),
Tag::Strikethrough => Some(Some(strip_fg_in_link(self.ms.strikethrough_inner))),
// Link/Image already push their own inner-style highlight
// (link_text) during on_start. We just need ancestor_styles
// to be non-empty so the Event::Text branch below skips
// pushing ms.text — which would otherwise override the
// link_text foreground color via merge_styles' last-wins
// ordering.
Tag::Link { .. } | Tag::Image { .. } => Some(None),
Tag::CodeBlock(block) => {
parent_code_block = Some(match block {
CodeBlockKind::Fenced(lang) if !lang.is_empty() => {
Some(lang.to_owned())
}
_ => None,
});
None
}
_ => None,
})
.collect()
} else {
if let Some(Tag::CodeBlock(block)) = self.tag_stack.last() {
parent_code_block = Some(match block {
CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(lang.to_owned()),
_ => None,
});
}
Vec::new()
};
for style in &ancestor_styles {
self.push_highlight(*style, &range);
}
match event {
Event::Start(tag) => self.on_start(tag, range),
Event::End(tag_end) => self.on_end(tag_end, range),
Event::Text(text) => {
// Capture text into table cell if we're inside a table
if let Some(ref mut state) = self.table_state {
state.push_text(&text);
}
// Record the enclosing fenced block's raw byte range and its
// de-prefixed body content. pulldown merges the body into one
// text event, but accumulate defensively in case it is split.
if parent_code_block.is_some()
&& let Some(pending) = self.pending_code_block.as_mut()
{
if pending.body_seen {
pending.body_range.start = pending.body_range.start.min(range.start);
pending.body_range.end = pending.body_range.end.max(range.end);
} else {
pending.body_range = range.clone();
pending.body_seen = true;
}
pending.body_text.push_str(&text);
}
if let Some(parent_code_block) = parent_code_block {
// Closed mermaid fences render as a diagram; open ones fall
// through so the source shows while still streaming.
if let Some(lang) = parent_code_block.as_deref()
&& lang
.split_whitespace()
.next()
.is_some_and(|t| t.eq_ignore_ascii_case("mermaid"))
&& range.end < self.text.len()
&& self.try_push_mermaid(&text, &range)
{
return;
}
let highlighted = match parent_code_block {
Some(lang) => {
if let Some(syn) = self.syntect
&& let Some(cache) = self.open_code.as_deref_mut()
{
// Streaming tail: the cache routes between the
// incremental open-block path and the
// closed-fence memo.
cache.highlight_block(
syn,
&lang,
range.start,
range.end >= self.text.len(),
&text,
)
} else {
// Batch render (no streaming caches attached).
syntax_highlight_raw(self.syntect, &lang, &text)
}
}
None => None,
};
if let Some(highlighted) = highlighted {
self.buffers.replaces.push(Replace {
highlighted,
range: range.clone(),
});
} else {
self.push_highlight(Some(self.ms.code_untagged), &range);
self.buffers.untagged_code_ranges.push(range.clone());
}
} else {
if ancestor_styles.is_empty() {
// Apply the default text style only when no ancestor
// (heading, strong, emphasis, etc.) already provides a
// color — otherwise ms.text would override them.
self.push_highlight(Some(self.ms.text), &range);
} else {
self.push_highlight(None, &range);
}
if self.table_state.is_none() {
self.scan_inline_html_entities(&range);
}
}
}
Event::Code(code) => {
// Capture code content into table cell if we're inside a table
if let Some(ref mut state) = self.table_state {
let prev_code = state.cell_code;
state.cell_code = true;
state.push_text(&code);
state.cell_code = prev_code;
}
self.style_inline_code_span(&code, &range);
}
Event::InlineMath(math) => {
// `$...$` inline math: render the TeX to Unicode and swap it
// in via a pretty-mode transform. Falls back to inline-code
// presentation when conversion declines (oversized input) or
// produces nothing visible.
let rendered = latex::latex_to_unicode_inline(&math).filter(|r| !r.is_empty());
if let Some(ref mut state) = self.table_state {
match &rendered {
Some(r) => {
let prev_italic = state.cell_italic;
state.cell_italic = true;
state.push_text(r);
state.cell_italic = prev_italic;
}
None => {
let prev_code = state.cell_code;
state.cell_code = true;
state.push_text(&math);
state.cell_code = prev_code;
}
}
}
match rendered {
Some(r) => {
// One highlight + one transform spanning the entire
// `$...$` range: pretty mode shows the rendered math,
// raw mode shows the TeX source in the math style.
self.push_highlight(Some(self.ms.math), &range);
self.buffers.transforms.push(Transform {
range: range.clone(),
to: r,
force: false,
});
}
None => self.style_inline_code_span(&math, &range),
}
}
Event::SoftBreak => {
// Collapse soft breaks to spaces unless the next source
// byte is a list-item indent or blockquote `>` marker
// (the byte immediately after pulldown's SoftBreak range),
// in which case the line ending belongs to a block
// continuation and the renderer surfaces it as its own
// visual line. The transform spans the full range so CRLF
// (`\r\n`, 2 bytes) preserves byte length.
if let Some(ref mut state) = self.table_state {
state.push_text(" ");
} else {
let next = self.text.as_bytes().get(range.end);
let is_continuation = matches!(next, Some(b' ' | b'\t' | b'>' | b'|'));
if self.collapse_soft_breaks && !is_continuation {
let span = range.end - range.start;
debug_assert!(span >= 1, "SoftBreak range must cover at least one byte");
self.buffers.transforms.push(Transform {
range: range.clone(),
to: " ".repeat(span),
force: true,
});
}
}
self.push_highlight(None, &range);
}
Event::HardBreak => {
if let Some(ref mut state) = self.table_state {
state.push_text("\n");
}
self.push_highlight(None, &range);
}
Event::Html(_) => {
// Render HTML block content as regular text (not code).
// pulldown-cmark treats XML-like tags (e.g. <example>) as HTML
// blocks, which previously got code-block styling via Replace.
self.push_highlight(Some(self.ms.text), &range);
}
Event::InlineHtml(html) => {
if is_br_tag(&html) {
if let Some(ref mut state) = self.table_state {
state.push_text("\n");
self.push_highlight(Some(self.ms.text), &range);
} else {
self.buffers.transforms.push(Transform {
range: range.clone(),
to: "\n".to_string(),
force: false,
});
self.push_highlight(None, &range);
}
} else if let Some(ref mut state) = self.table_state {
state.push_text(&html);
self.push_highlight(Some(self.ms.text), &range);
} else if let Some(highlighted) = syntax_highlight_raw(self.syntect, "html", &html)
{
self.buffers.replaces.push(Replace {
highlighted,
range: range.clone(),
});
}
}
Event::DisplayMath(math) => {
// `$$...$$` display math: render to Unicode block lines.
if let Some(ref mut state) = self.table_state {
// Inside a table cell there is no room for a block:
// render single-line (rows joined with `; `).
match latex::latex_to_unicode_inline(&math).filter(|r| !r.is_empty()) {
Some(r) => {
let prev_italic = state.cell_italic;
state.cell_italic = true;
state.push_text(&r);
state.cell_italic = prev_italic;
}
None => {
let prev_code = state.cell_code;
state.cell_code = true;
state.push_text(&math);
state.cell_code = prev_code;
}
}
self.push_highlight(Some(self.ms.math), &range);
} else if self.push_display_math_block(range.clone(), &math) {
// Raw mode shows the TeX source in the math style; pretty
// mode consumes the range via the block replacement.
self.push_highlight(Some(self.ms.math), &range);
} else {
// Fallback (conversion declined / nothing visible):
// legacy presentation — TeX source highlighted as code.
self.push_highlight(Some(self.ms.code_outer), &range);
let outer_text = &self.text[range.clone()];
if let Some(r) = find_substring(outer_text, &math, true, false) {
let inner_range = (range.start + r.start)..(range.start + r.end);
if let Some(highlighted) = syntax_highlight_raw(self.syntect, "tex", &math)
{
self.buffers.replaces.push(Replace {
highlighted,
range: inner_range,
});
} else {
self.push_highlight(Some(self.ms.code_untagged), &inner_range);
}
}
}
}
Event::FootnoteReference(_) => {
self.push_highlight(Some(self.ms.link_outer), &range);
}
Event::Rule => {
// Style and transform "---" to "───" (horizontal rule)
self.push_highlight(Some(self.ms.rule), &range);
let rule_text = &self.text[range.clone()];
if let Some(marker_end) = rule_text.find('\n') {
// Transform only up to the newline
self.buffers.transforms.push(Transform {
range: range.start..range.start + marker_end,
to: "───".to_string(),
force: false,
});
} else {
// No trailing newline, transform the whole range
self.buffers.transforms.push(Transform {
range: range.clone(),
to: "───".to_string(),
force: false,
});
}
if self.depth == 0 {
self.last_checkpoint = Some((CheckpointKind::ThematicBreak, range.end));
}
}
Event::TaskListMarker(checked) => {
let style = if checked {
self.ms.task_checked
} else {
self.ms.task_unchecked
};
self.push_highlight(Some(style), &range);
}
}
}
fn on_start(&mut self, tag: Tag<'a>, range: Range<usize>) {
// Track nesting depth for checkpoint detection
match &tag {
Tag::BlockQuote(_) | Tag::List(_) | Tag::Item | Tag::Table(_) => {
self.depth += 1;
}
_ => {}
}
if matches!(&tag, Tag::BlockQuote(_)) {
self.bq_depth += 1;
}
let mut more = Vec::new();
let style = match &tag {
Tag::Paragraph => None,
Tag::Heading { level, .. } => {
let level_usize = (*level as usize).saturating_sub(1).min(5);
let heading_text = &self.text[range.clone()];
if let Some(marker_end) = heading_text.find(|c: char| c != '#' && c != ' ') {
let marker_range = range.start..range.start + marker_end;
more.push(Highlight {
style: Some(self.ms.heading_outer[level_usize]),
range: marker_range,
});
None
} else {
Some(self.ms.heading_outer[level_usize])
}
}
Tag::BlockQuote(_) => {
// Transform the `>` belonging to THIS blockquote level to `│`.
//
// For nested blockquotes (`> > inner`), pulldown-cmark emits nested
// BlockQuote events. The outer event's range covers all lines,
// so each line in the outer range has `>` at position 0. The inner
// event's range starts mid-line on the first line (after `> `) but
// at column 0 on subsequent lines. On those subsequent lines, the
// outer `>` is included in the inner range, so we must skip it.
//
// Strategy: on each line, determine how many `>` characters belong
// to outer blockquote levels (by checking if the line starts at a
// real line boundary in the source). If it does, skip (bq_depth-1)
// `>`s. If it starts mid-line (first fragment), skip none.
let bq_text = &self.text[range.clone()];
let mut pos = range.start;
for line in bq_text.split_inclusive('\n') {
// Does this fragment start at a source line boundary?
let at_line_start =
pos == 0 || self.text.as_bytes().get(pos - 1) == Some(&b'\n');
// If at a line start, outer levels already have `>`s that
// we must skip. If mid-line (first fragment of range),
// the outer `>`s are before the range so skip 0.
let skip = if at_line_start { self.bq_depth - 1 } else { 0 };
let mut found = 0usize;
for (byte_offset, ch) in line.char_indices() {
if ch == '>' {
if found == skip {
let gt_pos = pos + byte_offset;
self.buffers.transforms.push(Transform {
range: gt_pos..gt_pos + 1,
to: "│".to_string(),
force: false,
});
more.push(Highlight {
style: Some(self.ms.blockquote_outer),
range: gt_pos..gt_pos + 1,
});
break;
}
found += 1;
}
}
pos += line.len();
}
// Return None - we've handled the styling via per-line highlights
None
}
Tag::CodeBlock(code) => {
// Track the fenced block so its body span can be reported once
// the fence closes. The body starts just past the opening fence
// line; an empty-body fence keeps this empty range. Indented
// code blocks are not fences and report no span.
self.pending_code_block = match code {
CodeBlockKind::Fenced(lang) => {
let body_start = self.text[range.start..]
.find('\n')
.map_or(range.end, |nl| range.start + nl + 1);
Some(PendingCodeBlock {
info: lang.to_string(),
body_range: body_start..body_start,
body_text: String::new(),
body_seen: false,
})
}
CodeBlockKind::Indented => None,
};
// pulldown-cmark reports the code-block range starting at the
// fence marker (```), excluding any leading indentation on the
// opening-fence line. That indentation is present whenever the
// block is indented at the top level or nested inside a list.
// Extend the hidden `code_outer` highlight back over it so the
// whole fence line is hidden in pretty mode. Without this, the
// indentation leaks onto the first rendered code line, and the
// renderer's fence-start detection (which checks that the byte
// before the fence is a newline) misfires — mistaking the
// closing fence for an opening one and emitting a spurious
// blank line. Only extend when the prefix is pure whitespace so
// structural prefixes (e.g. a blockquote `> `) are left intact.
let line_start = self.text[..range.start].rfind('\n').map_or(0, |p| p + 1);
let fence_start = if self.text[line_start..range.start]
.bytes()
.all(|b| b == b' ' || b == b'\t')
{
line_start
} else {
range.start
};
more.push(Highlight {
style: Some(self.ms.code_outer),
range: fence_start..range.end,
});
match code {
CodeBlockKind::Fenced(lang) if !lang.is_empty() => {
if let Some(r) =
find_substring(&self.text[range.clone()], lang, true, false)
{
let range = (r.start + range.start)..(r.end + range.start);
more.push(Highlight {
style: Some(self.ms.code_language),
range,
});
}
}
_ => (),
}
None
}
Tag::HtmlBlock => {
// Don't syntax-highlight HTML blocks as code. In LLM output,
// these are typically XML-like structural tags (e.g. <example>)
// from system prompts, not actual HTML. Treating them as code
// blocks (with background styling) causes visual inconsistency
// because pulldown-cmark ends HTML blocks at blank lines,
// making the first part look like code and the rest like text.
None
}
Tag::List(_) => None,
Tag::Item => {
let item_text = &self.text[range.clone()];
let trimmed = item_text.trim_start();
let leading_ws = item_text.len() - trimmed.len();
let marker_len = if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
2
} else if let Some(pos) = trimmed.find(". ") {
if pos > 0 && trimmed[..pos].chars().all(|c| c.is_ascii_digit()) {
pos + 2
} else {
0
}
} else if let Some(pos) = trimmed.find(") ") {
if pos > 0 && trimmed[..pos].chars().all(|c| c.is_ascii_digit()) {
pos + 2
} else {
0
}
} else {
0
};
if marker_len > 0 {
let marker_start = range.start + leading_ws;
let marker_end = marker_start + marker_len;
more.push(Highlight {
style: Some(self.ms.list_item),
range: marker_start..marker_end,
});
if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
self.buffers.transforms.push(Transform {
range: marker_start..marker_start + 1,
to: "•".to_string(),
force: false,
});
}
}
None
}
Tag::Table(alignments) => {
self.table_state = Some(TableState::new(alignments.to_vec(), range.start));
Some(self.ms.table_outer)
}
Tag::TableHead => {
if let Some(ref mut state) = self.table_state {
state.in_header = true;
}
Some(self.ms.table_outer)
}
Tag::TableRow => {
if let Some(ref mut state) = self.table_state {
state.current_row.clear();
}
Some(self.ms.table_outer)
}
Tag::TableCell => {
if let Some(ref mut state) = self.table_state {
state.current_cell.clear();
}
self.push_highlight(None, &range);
None
}
Tag::Emphasis => {
if let Some(ref mut state) = self.table_state {
state.cell_italic = true;
}
Some(self.ms.emphasis_outer)
}
Tag::Strong => {
if let Some(ref mut state) = self.table_state {
state.cell_bold = true;
}
Some(self.ms.strong_outer)
}
Tag::Strikethrough => Some(self.ms.strikethrough_outer),
Tag::Link {
dest_url, title, ..
}
| Tag::Image {
dest_url, title, ..
} => {
// Links inside a table cell go through the table renderer's
// own hyperlink path (TableHyperlink in TableReplace). The
// paragraph link path (LinkTarget + chunk_link_offsets) can't
// project links onto rendered table cells because the table
// replace consumes the entire source range — no text chunk
// ever covers the cell's link text.
//
// We still want a stable link `id` so terminal UIs can group
// wrapped link fragments; assign one from the same counter
// used by paragraph links and stash it in `cell_link` so the
// following `Event::Text`s tag their CellSpans with it.
if let Some(ref mut state) = self.table_state {
let id = self.link_id_counter;
self.link_id_counter += 1;
state.cell_link = Some((dest_url.to_string(), id));
self.tag_stack.push(tag);
return;
}
let tag_str = &self.text[range.clone()];
if !title.is_empty() {
for t in [format!("\"{title}\""), format!("'{title}'")].map(CowStr::from) {
if let Some(r) = find_substring(tag_str, &t, true, true) {
let title_range = (r.start + range.start)..(r.end + range.start);
more.push(Highlight {
style: Some(self.ms.link_title),
range: title_range,
});
}
}
}
// We intentionally use allow_outside=true here (instead of the previous
// pointer-based allow_outside=false) and then do an rfind on the prefix
// before the (last) dest_url occurrence. This is required because dest_url
// may be a CowStr::Owned (after percent-decoding or HTML entity expansion)
// and therefore may not be a sub-slice of tag_str. The rfind on the strict
// prefix guarantees we find the *structural* `](` closer even when the link
// text, title, or the dest literal itself contains the byte sequence `](`.
let url_rel_opt = find_substring(tag_str, dest_url, true, true);
if let Some(r) = &url_rel_opt {
let url_range = (r.start + range.start)..(r.end + range.start);
more.push(Highlight {
style: Some(self.ms.link_url),
range: url_range,
});
}
let bracket_pos_opt = url_rel_opt
.as_ref()
.and_then(|r| tag_str[..r.start].rfind("](").map(|p| p..p + 2));
if let Some(bracket_pos) = bracket_pos_opt {
let open_bracket = if tag_str.starts_with("![") { 1 } else { 0 };
if open_bracket > 0 {
more.push(Highlight {
style: Some(self.ms.link_outer),
range: range.start..range.start + 1,
});
}
more.push(Highlight {
style: Some(self.ms.link_outer),
range: range.start + open_bracket..range.start + open_bracket + 1,
});
let text_start = range.start + open_bracket + 1;
let text_end = bracket_pos.start + range.start;
if text_end > text_start {
more.push(Highlight {
style: Some(self.ms.link_text),
range: text_start..text_end,
});
}
let bracket_abs = bracket_pos.start + range.start;
more.push(Highlight {
style: Some(self.ms.link_outer),
range: bracket_abs..bracket_abs + 2,
});
more.push(Highlight {
style: Some(self.ms.link_outer),
range: range.end - 1..range.end,
});
self.buffers.transforms.push(Transform {
range: range.start + open_bracket..range.start + open_bracket + 1,
to: "".to_string(),
force: false,
});
self.buffers.transforms.push(Transform {
range: bracket_abs..bracket_abs + 2,
to: " (".to_string(),
force: false,
});
if text_end > text_start {
self.buffers.link_targets.push(LinkTarget {
source_range: text_start..text_end,
url: dest_url.to_string(),
id: self.link_id_counter,
});
self.link_id_counter += 1;
}
None
} else {
self.buffers.link_targets.push(LinkTarget {
source_range: range.clone(),
url: dest_url.to_string(),
id: self.link_id_counter,
});
self.link_id_counter += 1;
Some(self.ms.link_outer)
}
}
_ => None,
};
if let Some(style) = style {
self.push_highlight(Some(style), &range);
}
for hl in more {
self.buffers.highlights.push(hl);
}
self.tag_stack.push(tag);
}
fn on_end(&mut self, tag_end: TagEnd, range: Range<usize>) {
self.tag_stack.pop();
// Handle tag-specific end logic and determine if we need to push a style
let style = match &tag_end {
TagEnd::Emphasis => {
// Reset italic for table cells (no highlight pushed)
if let Some(ref mut state) = self.table_state {
state.cell_italic = false;
}
None
}
TagEnd::Strong => {
// Reset bold for table cells (no highlight pushed)
if let Some(ref mut state) = self.table_state {
state.cell_bold = false;
}
None
}
TagEnd::Strikethrough => None, // No highlight pushed
TagEnd::CodeBlock => {
// pulldown synthesizes a block end at end-of-input even for an
// unterminated fence, so the end event alone does not prove
// closure. A closing fence always sits after the body, so the
// block range extends past the body exactly when the fence
// closed. `take` clears the pending block in either case.
if let Some(pending) = self.pending_code_block.take()
&& pending.body_range.end < range.end
{
self.buffers.code_blocks.push(CodeBlockMeta {
info: pending.info,
body: pending.body_text,
body_source_range: pending.body_range,
});
}
None
}
TagEnd::Link | TagEnd::Image => {
// Clear link state for table cells so subsequent text in
// the same cell isn't tagged as part of this link.
if let Some(ref mut state) = self.table_state {
state.cell_link = None;
}
None
}
TagEnd::TableCell => {
// Finish current cell
if let Some(ref mut state) = self.table_state {
state
.current_row
.push(std::mem::take(&mut state.current_cell));
// Reset cell styles
state.cell_bold = false;
state.cell_italic = false;
state.cell_code = false;
state.cell_link = None;
}
None
}
TagEnd::TableRow => {
// Finish body row (if not in header)
if let Some(ref mut state) = self.table_state
&& !state.in_header
{
let row = std::mem::take(&mut state.current_row);
state.rows.push(row);
}
None
}
TagEnd::TableHead => {
// Finish header row
if let Some(ref mut state) = self.table_state {
state.header = std::mem::take(&mut state.current_row);
state.in_header = false;
}
None
}
TagEnd::Table => {
// Finish table: format and store the replacement
if let Some(mut state) = self.table_state.take() {
state.range.end = range.end;
let FormattedTable {
lines,
styled_lines,
line_source_offsets,
hyperlinks,
} = self.format_table(&state);
self.buffers.table_replaces.push(TableReplace {
lines,
styled_lines,
range: state.range,
line_source_offsets,
hyperlinks,
});
}
None
}
_ => None,
};
if let Some(style) = style {
self.push_highlight(Some(style), &range);
}
// Track depth and checkpoints
match &tag_end {
TagEnd::BlockQuote(_) | TagEnd::List(_) | TagEnd::Item | TagEnd::Table => {
self.depth = self.depth.saturating_sub(1);
}
_ => {}
}
if matches!(&tag_end, TagEnd::BlockQuote(_)) {
self.bq_depth = self.bq_depth.saturating_sub(1);
}
// Record checkpoint at depth=0 block boundaries
if self.depth == 0 {
let kind = match &tag_end {
TagEnd::Paragraph => Some(CheckpointKind::Paragraph),
TagEnd::Heading(_) => Some(CheckpointKind::Heading),
TagEnd::CodeBlock => Some(CheckpointKind::CodeBlock),
TagEnd::BlockQuote(_) => Some(CheckpointKind::BlockQuote),
TagEnd::List(_) => Some(CheckpointKind::List),
TagEnd::Table => Some(CheckpointKind::Table),
TagEnd::HtmlBlock => Some(CheckpointKind::HtmlBlock),
_ => None,
};
if let Some(kind) = kind {
let has_blank = has_blank_line_after(self.text, range.end);
let is_code_block = matches!(kind, CheckpointKind::CodeBlock);
let at_eof = range.end >= self.text.len();
let code_block_properly_closed = is_code_block && !at_eof;
if has_blank || code_block_properly_closed {
// For code blocks, include one newline to properly close the block.
// For other blocks (paragraphs, headings, blockquotes, lists),
// DON'T include the trailing newline so that when the next chunk
// is added, the blank line separator is re-rendered.
let checkpoint_pos = if is_code_block && has_blank {
range.end + 1
} else {
range.end
};
self.last_checkpoint = Some((kind, checkpoint_pos));
}
}
}
}
/// Render a mermaid code block into a [`MermaidReplace`]; `true` if drawn.
fn try_push_mermaid(&mut self, text: &str, range: &Range<usize>) -> bool {
let line_style = self.ms.rule.style_into();
let styles = crate::mermaid::MermaidStyles {
border: line_style,
node_text: self.ms.text.style_into(),
edge: line_style,
edge_label: self.ms.emphasis_inner.style_into(),
title: self.ms.strong_inner.style_into(),
};
match crate::mermaid::render(text, &styles, self.max_table_width) {
Some(art) => {
self.buffers
.mermaid_replaces
.push(crate::buffers::MermaidReplace {
lines: art.plain_lines,
styled_lines: art.styled_lines,
range: range.clone(),
});
true
}
None => false,
}
}
/// Apply inline-code styling to a code/math span: dim the delimiters,
/// style the content. Shared by `Event::Code` and the inline-math
/// fallback path.
fn style_inline_code_span(&mut self, code: &CowStr<'_>, range: &Range<usize>) {
// Find the actual content range (excluding the delimiters).
let outer_text = &self.text[range.clone()];
if let Some(inner_range) = find_substring(outer_text, code, false, false)
.or_else(|| find_substring(outer_text, code, true, false))
{
let absolute_inner = (range.start + inner_range.start)..(range.start + inner_range.end);
// Left delimiter
if inner_range.start > 0 {
self.push_highlight(
Some(self.ms.inline_code_outer),
&(range.start..absolute_inner.start),
);
}
// Inner code
self.push_highlight(Some(self.ms.inline_code_inner), &absolute_inner);
// Right delimiter
if range.end > absolute_inner.end {
self.push_highlight(
Some(self.ms.inline_code_outer),
&(absolute_inner.end..range.end),
);
}
} else {
self.push_highlight(Some(self.ms.inline_code_inner), range);
}
}
/// Scan a prose `Event::Text` source range for HTML character entity
/// references (`&lt;`, `&gt;`, `&amp;`, numeric, …) and decode each via a
/// pretty-mode transform, so e.g. `&lt;` displays as `<`.
///
/// The source-faithful renderer renders the raw source bytes for prose,
/// which would otherwise leave entities undecoded (table cells already
/// decode through the cell-text path at `Event::Text` → `push_text`). The
/// transform is non-`force`, so raw mode still shows the verbatim source.
///
/// A `None`-style highlight is pushed over each entity's byte range so the
/// renderer splits a chunk exactly there: this keeps the substitution from
/// straddling a chunk boundary (which would emit the replacement twice)
/// while leaving the surrounding text/ancestor styling untouched. Code
/// spans and fenced blocks never reach here, so entities inside code stay
/// literal.
///
/// Panic-safety: pulldown-cmark guarantees `range` is a valid sub-slice
/// of `self.text`; even so, the access goes through `str::get` and
/// `slice::get` so a future invariant violation degrades to a no-op rather
/// than panicking. The inner loop only advances over ASCII bytes
/// (`#`/`a-z`/`A-Z`/`0-9`/`;`), guaranteeing `i` and `end` stay on UTF-8
/// char boundaries.
fn scan_inline_html_entities(&mut self, range: &Range<usize>) {
let Some(slice) = self.text.get(range.clone()) else {
debug_assert!(false, "pulldown-cmark text range out of bounds");
return;
};
if !slice.contains('&') {
return;
}
// Longest HTML5 named entity reference (`&CounterClockwiseContourIntegral;`)
// is 33 bytes including the leading `&` and trailing `;`. Bounding the
// scan keeps a run of bare `&` characters from degrading to O(n²).
const MAX_ENTITY_LEN: usize = 33;
let bytes = slice.as_bytes();
let mut i = 0;
while let Some(&b) = bytes.get(i) {
if b != b'&' {
i += 1;
continue;
}
// An entity reference contains only ASCII name/numeric characters
// and no internal `;`, so the first `;` reached while consuming
// valid characters closes it. Stopping on any other byte avoids
// both quadratic scans and slicing through a multi-byte char.
let max = (i + MAX_ENTITY_LEN).min(bytes.len());
let mut j = i + 1;
let end = loop {
if j >= max {
break None;
}
match bytes.get(j) {
Some(b';') => break Some(j),
Some(b'#' | b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9') => j += 1,
_ => break None,
}
};
if let Some(end) = end
&& let Some(entity) = slice.get(i..=end)
&& let Some(decoded) = decode_html_entity(entity)
{
let abs = (range.start + i)..(range.start + end + 1);
// An earlier scan (e.g. `\(...\)` math) may have already claimed
// these bytes with its own transform. Overlapping transforms
// would each emit their replacement, so leave the entity to the
// existing transform rather than double-substituting.
let overlaps = self
.buffers
.transforms
.iter()
.any(|t| t.range.start < abs.end && abs.start < t.range.end);
if !overlaps {
self.push_highlight(None, &abs);
self.buffers.transforms.push(Transform {
range: abs,
to: decoded,
force: false,
});
}
i = end + 1;
continue;
}
i += 1;
}
}
/// Push a pretty-mode block replacement rendering `latex_src` as display
/// math over `range`. Returns `false` when conversion declines
/// (oversized input) or produces nothing visible; callers then fall back
/// to a raw presentation.
///
/// Reuses the table block-replacement machinery: pre-rendered styled
/// lines that substitute the source range in pretty mode only, so raw
/// mode keeps showing the TeX source.
fn push_display_math_block(&mut self, range: Range<usize>, latex_src: &str) -> bool {
let Some(rendered) = latex::latex_to_unicode_display(latex_src) else {
return false;
};
if rendered.is_empty() {
return false;
}
// Consume the line ending right after the closing delimiter, like
// table ranges do. Without this, a batch render emits an extra blank
// line after the block (the source newline) that the streaming
// checkpoint+tail path does not, breaking render convergence.
let mut range = range;
if self.text[range.end..].starts_with("\r\n") {
range.end += 2;
} else if self.text[range.end..].starts_with('\n') {
range.end += 1;
}
let style: ratatui::style::Style = self.ms.math.style_into();
let src_newlines = self.text[range.clone()]
.bytes()
.filter(|&b| b == b'\n')
.count();
let mut lines = Vec::with_capacity(rendered.len());
let mut styled_lines = Vec::with_capacity(rendered.len());
let mut line_source_offsets = Vec::with_capacity(rendered.len());
for (i, line) in rendered.iter().enumerate() {
let text = format!(" {line}");
styled_lines.push(Line::from(Span::styled(text.clone(), style)));
lines.push(text);
// Best-effort scroll mapping: the i-th rendered line maps to the
// i-th content line of the block (clamped to its source lines).
line_source_offsets.push((i + 1).min(src_newlines));
}
self.buffers.table_replaces.push(TableReplace {
lines,
styled_lines,
range,
line_source_offsets,
hyperlinks: Vec::new(),
});
true
}
/// Format a buffered table into lines with box-drawing borders.
fn format_table(&self, state: &TableState) -> FormattedTable {
let borders = TableBorders::BOX;
let padding = 1;
// Style already adapted - no need to call adapt_style again
let border_style: ratatui::style::Style = self.ms.rule.style_into().dim();
let all_rows: Vec<&Vec<StyledCell>> = std::iter::once(&state.header)
.chain(state.rows.iter())
.filter(|r| !r.is_empty())
.collect();
if all_rows.is_empty() {
return FormattedTable::default();
}
let num_cols = all_rows.iter().map(|r| r.len()).max().unwrap_or(0);
if num_cols == 0 {
return FormattedTable::default();
}
let mut col_widths: Vec<usize> = vec![0; num_cols];
for row in &all_rows {
for (col_idx, cell) in row.iter().enumerate() {
let text = cell.plain_text();
let cell_width = text
.split('\n')
.map(unicode_display_width)
.max()
.unwrap_or(0);
if col_idx < col_widths.len() {
col_widths[col_idx] = col_widths[col_idx].max(cell_width);
}
}
}
// Constrain column widths to fit within max_table_width if set.
// Table width = 1 (left border) + sum(col_width + 2*padding) + (num_cols-1) separators + 1 (right border)
// = 1 + sum(col_width) + num_cols * 2 * padding + (num_cols - 1) + 1
// = num_cols * (2 * padding + 1) + sum(col_width) + 2 - 1
if let Some(max_width) = self.max_table_width {
let overhead = num_cols * (2 * padding + 1) + 1; // borders + padding
let content_budget = max_width.saturating_sub(overhead);
let total_content: usize = col_widths.iter().sum();
if total_content > content_budget && total_content > 0 {
// Compute per-column minimum widths: the longest unbreakable
// word across all cells in each column. The word separator
// determines what counts as unbreakable (e.g. "Catherine",
// "$145,000", "EMP-1001").
let mut min_col_widths: Vec<usize> = vec![1; num_cols];
for row in &all_rows {
for (col, cell) in row.iter().enumerate() {
if col >= num_cols {
break;
}
let text = cell.plain_text();
for word in cell_word_separator(&text) {
let w = unicode_display_width(word.word);
min_col_widths[col] = min_col_widths[col].max(w);
}
}
}
// Start every column at its minimum, then distribute the
// remaining budget proportionally to how much extra width
// each column wants (natural minimum). This guarantees
// the total never exceeds the budget while respecting mins.
let min_total: usize = min_col_widths.iter().sum();
let extra_budget = content_budget.saturating_sub(min_total);
// How much each column *wants* above its minimum.
let extra_wants: Vec<usize> = col_widths
.iter()
.enumerate()
.map(|(i, &w)| w.saturating_sub(min_col_widths[i]))
.collect();
let total_extra_want: usize = extra_wants.iter().sum();
let mut new_widths = min_col_widths.clone();
if total_extra_want > 0 && extra_budget > 0 {
// Distribute proportionally.
for (i, &want) in extra_wants.iter().enumerate() {
let share = (want as f64 * extra_budget as f64 / total_extra_want as f64)
.floor() as usize;
new_widths[i] += share;
}
// Hand out any remaining columns (from floor rounding)
// to columns with the most unmet want, one at a time.
let used: usize = new_widths.iter().sum();
let mut remaining = content_budget.saturating_sub(used);
if remaining > 0 {
let mut indices: Vec<usize> = (0..num_cols).collect();
// Sort by unmet want descending.
indices.sort_by(|&a, &b| {
let unmet_a = col_widths[a].saturating_sub(new_widths[a]);
let unmet_b = col_widths[b].saturating_sub(new_widths[b]);
unmet_b.cmp(&unmet_a)
});
for &idx in &indices {
if remaining == 0 {
break;
}
// Don't grow beyond original natural width.
if new_widths[idx] < col_widths[idx] {
new_widths[idx] += 1;
remaining -= 1;
}
}
}
}
col_widths = new_widths;
}
}
let alignments: Vec<_> = (0..num_cols)
.map(|i| {
state
.alignments
.get(i)
.copied()
.unwrap_or(pulldown_cmark::Alignment::None)
})
.collect();
let mut lines = Vec::new();
let mut styled_lines = Vec::new();
let mut line_source_offsets: Vec<usize> = Vec::new();
let mut hyperlinks: Vec<TableHyperlink> = Vec::new();
// Source line layout within a table:
// offset 0: header row (| Col A | Col B |)
// offset 1: separator (|-------|-------|)
// offset 2+: body rows (| val1 | val2 |)
let header_offset = 0usize;
let separator_offset = 1usize;
// Top border — belongs to the header line
let top_border = self.format_border_line(
&col_widths,
padding,
borders.c_tl(),
borders.t_t(),
borders.c_tr(),
borders.h(),
);
styled_lines.push(Line::styled(top_border.clone(), border_style));
lines.push(top_border);
line_source_offsets.push(header_offset);
// Header row
if !state.header.is_empty() {
let (row_plains, row_styleds, row_links) = self.format_styled_content_lines(
&state.header,
&col_widths,
&alignments,
padding,
borders.v(),
border_style,
true,
);
let base_line = styled_lines.len();
for (p, s) in row_plains.into_iter().zip(row_styleds) {
lines.push(p);
styled_lines.push(s);
line_source_offsets.push(header_offset);
}
for mut link in row_links {
link.line_offset += base_line;
hyperlinks.push(link);
}
// Header separator
let sep = self.format_border_line(
&col_widths,
padding,
borders.t_l(),
borders.x(),
borders.t_r(),
borders.h(),
);
styled_lines.push(Line::styled(sep.clone(), border_style));
lines.push(sep);
line_source_offsets.push(separator_offset);
}
// Body rows
for (i, row) in state.rows.iter().enumerate() {
let row_offset = separator_offset + 1 + i; // offset 2, 3, ...
let (row_plains, row_styleds, row_links) = self.format_styled_content_lines(
row,
&col_widths,
&alignments,
padding,
borders.v(),
border_style,
false,
);
let base_line = styled_lines.len();
for (p, s) in row_plains.into_iter().zip(row_styleds) {
lines.push(p);
styled_lines.push(s);
line_source_offsets.push(row_offset);
}
for mut link in row_links {
link.line_offset += base_line;
hyperlinks.push(link);
}
// Row divider between body rows (not after last row)
if i < state.rows.len().saturating_sub(1) {
let row_sep = self.format_border_line(
&col_widths,
padding,
borders.t_l(),
borders.x(),
borders.t_r(),
borders.h(),
);
styled_lines.push(Line::styled(row_sep.clone(), border_style));
lines.push(row_sep);
line_source_offsets.push(row_offset);
}
}
// Bottom border — belongs to the last body row
let last_row_offset = separator_offset + state.rows.len();
let bottom_border = self.format_border_line(
&col_widths,
padding,
borders.c_bl(),
borders.t_b(),
borders.c_br(),
borders.h(),
);
styled_lines.push(Line::styled(bottom_border.clone(), border_style));
lines.push(bottom_border);
line_source_offsets.push(last_row_offset);
FormattedTable {
lines,
styled_lines,
line_source_offsets,
hyperlinks,
}
}
/// Word-wrap a cell's plain text into lines of at most `width` display columns.
/// Returns a Vec of Strings, one per visual line.
///
/// Delegates to `textwrap::wrap` with a custom word separator that allows
/// line breaks after spaces, punctuation, and symbol characters — but never
/// mid-word. If a single word is wider than `width` it overflows rather
/// than being chopped.
fn wrap_cell_text(text: &str, width: usize) -> Vec<String> {
if width == 0 {
return vec![String::new()];
}
let opts = textwrap::Options::new(width)
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit)
.word_separator(textwrap::WordSeparator::Custom(cell_word_separator))
.break_words(false);
let wrapped = textwrap::wrap(text, opts);
if wrapped.is_empty() {
vec![String::new()]
} else {
wrapped.into_iter().map(|cow| cow.into_owned()).collect()
}
}
/// Format a table row that may span multiple visual lines (when cells wrap).
///
/// Returns `(plain_lines, styled_lines, hyperlinks)` — one plain + styled
/// entry per visual line, plus any hyperlinks discovered in cell spans.
/// Hyperlink `line_offset`s are relative to the first visual line of
/// this row (caller adds the absolute base to embed in the table).
#[allow(clippy::too_many_arguments)]
fn format_styled_content_lines(
&self,
cells: &[StyledCell],
col_widths: &[usize],
alignments: &[pulldown_cmark::Alignment],
padding: usize,
v: char,
border_style: ratatui::style::Style,
is_header: bool,
) -> (Vec<String>, Vec<Line<'static>>, Vec<TableHyperlink>) {
// 1. Wrap each cell's text into lines constrained to col_widths[i].
let wrapped_cells: Vec<Vec<String>> = (0..col_widths.len())
.map(|i| {
let text = cells.get(i).map(|c| c.plain_text()).unwrap_or_default();
Self::wrap_cell_text(&text, col_widths[i])
})
.collect();
// 2. Determine the number of visual lines (max wrapped lines across cells).
let num_visual_lines = wrapped_cells.iter().map(|c| c.len()).max().unwrap_or(1);
// 3. Build each visual line.
let mut all_plains = Vec::with_capacity(num_visual_lines);
let mut all_styled = Vec::with_capacity(num_visual_lines);
let mut all_links: Vec<TableHyperlink> = Vec::new();
for vis_line in 0..num_visual_lines {
let mut plain = String::new();
let mut spans: Vec<Span<'static>> = Vec::new();
// Running display column on this visual line; used to record
// hyperlink column ranges in the table-local coordinate system.
let mut display_col: usize = 0;
plain.push(v);
spans.push(Span::styled(v.to_string(), border_style));
display_col += unicode_display_width(&v.to_string());
for (i, width) in col_widths.iter().enumerate() {
let cell_line_text = wrapped_cells[i]
.get(vis_line)
.map(|s| s.as_str())
.unwrap_or("");
let cell_line_width = unicode_display_width(cell_line_text);
let total_padding = width.saturating_sub(cell_line_width);
let alignment = alignments
.get(i)
.copied()
.unwrap_or(pulldown_cmark::Alignment::None);
let (left_pad, right_pad) = match alignment {
pulldown_cmark::Alignment::Left | pulldown_cmark::Alignment::None => {
(0, total_padding)
}
pulldown_cmark::Alignment::Right => (total_padding, 0),
pulldown_cmark::Alignment::Center => {
let left = total_padding / 2;
(left, total_padding - left)
}
};
// Left padding
let left_space = " ".repeat(padding + left_pad);
let left_space_width = unicode_display_width(&left_space);
plain.push_str(&left_space);
spans.push(Span::raw(left_space));
display_col += left_space_width;
// Cell text — slice original styled spans to match this
// visual line's character range, preserving per-span formatting
// (bold, italic, code, link) across wrap boundaries.
if !cell_line_text.is_empty() {
if let Some(cell) = cells.get(i) {
// Find the byte offset of this visual line within the full
// cell plain text, then emit styled spans covering that range.
let full_text = cell.plain_text();
// Sum of all previous visual lines' lengths + spaces between
// them gives our start offset in the full plain text.
let prev_len: usize = (0..vis_line)
.map(|vl| wrapped_cells[i].get(vl).map(|s| s.len()).unwrap_or(0))
.sum();
// Wrapped-line byte lengths may not land on a char boundary.
let prev_len = floor_char_boundary(&full_text, prev_len);
// Account for spaces consumed by textwrap between lines
let line_start = full_text[prev_len..]
.find(cell_line_text)
.map(|off| prev_len + off)
.unwrap_or(prev_len);
let line_end = (line_start + cell_line_text.len()).min(full_text.len());
// Walk the cell's spans, emitting the slice that overlaps
// [line_start..line_end].
let mut offset = 0usize;
for cell_span in &cell.spans {
let span_start = offset;
let span_end = offset + cell_span.text.len();
offset = span_end;
// Intersect [span_start..span_end] with [line_start..line_end]
let start = span_start.max(line_start);
let end = span_end.min(line_end);
if start >= end {
continue;
}
let slice = &full_text[start..end];
if slice.is_empty() {
continue;
}
let mut style: ratatui::style::Style = self.ms.text.style_into();
if is_header || cell_span.bold {
style = style.bold();
}
if cell_span.italic {
style = style.italic();
}
if cell_span.code {
style = self.ms.inline_code_inner.style_into();
}
if let Some((url, id)) = &cell_span.link {
// Apply link styling additively (preserves
// bold/italic if combined). link_text style
// typically adds underline + accent color so
// the cell visually matches paragraph link
// rendering.
let link_style: ratatui::style::Style =
self.ms.link_text.style_into();
style = style.patch(link_style);
let slice_width = unicode_display_width(slice);
all_links.push(TableHyperlink {
line_offset: vis_line,
column_range: display_col..(display_col + slice_width),
url: url.clone(),
id: *id,
});
}
let slice_width = unicode_display_width(slice);
plain.push_str(slice);
spans.push(Span::styled(slice.to_string(), style));
display_col += slice_width;
}
} else {
plain.push_str(cell_line_text);
spans.push(Span::raw(cell_line_text.to_string()));
display_col += cell_line_width;
}
}
// Right padding
let right_space = " ".repeat(right_pad + padding);
let right_space_width = unicode_display_width(&right_space);
plain.push_str(&right_space);
spans.push(Span::raw(right_space));
display_col += right_space_width;
// Column separator
plain.push(v);
spans.push(Span::styled(v.to_string(), border_style));
display_col += unicode_display_width(&v.to_string());
}
all_plains.push(plain);
all_styled.push(Line::from(spans));
}
(all_plains, all_styled, all_links)
}
fn format_border_line(
&self,
col_widths: &[usize],
padding: usize,
left: char,
mid: char,
right: char,
h: char,
) -> String {
let mut line = String::new();
line.push(left);
for (i, &width) in col_widths.iter().enumerate() {
let total_width = width + padding * 2;
for _ in 0..total_width {
line.push(h);
}
if i < col_widths.len() - 1 {
line.push(mid);
}
}
line.push(right);
line
}
}
/// Parsed markdown ready for rendering.
///
/// Created by `MarkdownParser::parse()`. Contains the source text, style,
/// and a reference to the populated buffers. Transient parsing state has
/// been dropped at this point.
pub struct ParsedMarkdown<'a, 'b> {
pub(crate) text: &'a str,
pub(crate) ms: MarkdownStyle,
pub(crate) buffers: &'b mut MarkdownBuffers,
pub(crate) last_checkpoint: Option<(CheckpointKind, usize)>,
pub(crate) next_link_id: u32,
}
impl<'a, 'b> ParsedMarkdown<'a, 'b> {
pub fn new(
text: &'a str,
ms: MarkdownStyle,
buffers: &'b mut MarkdownBuffers,
last_checkpoint: Option<(CheckpointKind, usize)>,
next_link_id: u32,
) -> Self {
Self {
text,
ms,
buffers,
last_checkpoint,
next_link_id,
}
}
}