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).
2057 lines
88 KiB
Rust
2057 lines
88 KiB
Rust
//! 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 (``, `�`, …). 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 (`<`, `>`, `&`, numeric, …) and decode each via a
|
||
/// pretty-mode transform, so e.g. `<` 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 (`∳`)
|
||
// 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,
|
||
}
|
||
}
|
||
}
|