Files
Kigi-CLI/crates/codegen/kigi-markdown/src/open_code_highlighter.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

440 lines
19 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.
//! Streaming-render syntect caches for fenced code blocks in the unfrozen
//! tail. Two complementary strategies behind one entry point
//! ([`OpenCodeHighlighter::highlight_block`]):
//!
//! - **Still-open trailing block** (closing ``` not arrived): persists
//! syntect's *resumable* per-line state ([`ParseState`]/[`HighlightState`])
//! across `rerender_tail` calls so each committed line is highlighted
//! exactly once. Without it, every push re-ran syntect over the whole
//! growing block — O(N²) over the stream (~35 ms/push near the end of a
//! ~1000-line block).
//! - **Closed blocks trapped in the tail** (e.g. inside an open list, which
//! can never checkpoint): memoizes the batch highlight per
//! `(fence_info, body)` so syntect runs once per distinct fence body
//! instead of once per streamed chunk (~50100 ms per re-run,
//! recorded 4.5 s UI freeze).
//!
//! Both paths are byte-identical to a one-shot batch render. Invalidation is
//! wholesale: the streaming renderer drops this struct on any
//! theme/style/width reset.
//!
//! # Invariants relied upon (open-block path)
//!
//! - **Append-only:** while a block is open the source only grows by appending,
//! and (because nothing freezes) the block's start offset within the tail is
//! stable. Both are guarded defensively here; on any mismatch the persisted
//! state is discarded and rebuilt from scratch.
//! - **One `Event::Text` per pass:** `TextMergeWithOffset` coalesces the block
//! body into a single `Event::Text`, so this is invoked once per block per
//! render pass and only needs to persist *across* passes.
use std::collections::HashMap;
use syntect::highlighting::{
HighlightIterator, HighlightState, Highlighter, Style as SyntectStyle,
};
use syntect::parsing::{ParseState, ScopeStack};
use syntect::util::LinesWithEndings;
use crate::syntax::{Syntect, syntax_highlight_raw};
/// Per-line highlight output: styled `(style, text)` segments for one line.
type HlLine = Vec<(SyntectStyle, String)>;
/// Byte budget for memoized closed-fence bodies; cleared wholesale on
/// overflow. Sized in body bytes (not entries) because pulldown can split a
/// list-indented fence into per-line `Event::Text` fragments — an entry
/// count would overflow on one large fence. If live bodies ever exceed the
/// budget the memo degrades to recomputing each pass (the pre-memo batch
/// behavior), never to unbounded memory or wrong output.
const CLOSED_MEMO_CAP_BYTES: usize = 256 * 1024;
/// Streaming syntect caches for fenced code blocks in the unfrozen tail:
/// incremental state for the single still-open trailing block, plus a memo
/// for closed blocks the tail re-parses every pass (see module docs).
///
/// Owns all the low-level syntect state so the parser/renderer don't have to.
pub(crate) struct OpenCodeHighlighter {
/// Language/info token of the block currently cached. A change means a
/// different syntax (and colors), so the cache must be rebuilt.
fence_info: String,
/// Block start offset within the tail. A change means we are looking at a
/// different block, so the cache must be rebuilt.
start_in_tail: usize,
/// Bytes highlighted up to and including the last committed `\n`.
committed_len: usize,
/// Highlighted, newline-terminated lines (one entry per committed line).
committed_lines: Vec<HlLine>,
/// syntect parse state AFTER the last committed (newline-terminated) line.
parse_state: ParseState,
/// syntect highlight state AFTER the last committed line.
highlight_state: HighlightState,
/// Memo for **closed** fences still in the unfrozen tail
/// (`fence_info -> body -> highlighted lines`). Nested maps keep the hot
/// lookup allocation-free; invalidation is inherited from `self` (the
/// streaming renderer drops this struct on any theme/style/width reset).
closed_memo: HashMap<String, HashMap<String, Vec<HlLine>>>,
/// Total body bytes currently memoized, for the `CLOSED_MEMO_CAP_BYTES`
/// budget check.
closed_memo_bytes: usize,
}
impl OpenCodeHighlighter {
/// Create an empty cache. The `parse_state`/`highlight_state` are seeded
/// with the plain-text syntax purely as placeholders: the empty
/// `fence_info` sentinel guarantees the first real
/// [`highlight`](Self::highlight) call takes the rebuild branch and
/// discards them in favour of the correct syntax.
pub(crate) fn new(syn: &Syntect) -> Self {
let highlighter = Highlighter::new(&syn.theme);
Self {
fence_info: String::new(),
start_in_tail: 0,
committed_len: 0,
committed_lines: Vec::new(),
// Seeded invalid; rebuilt on first highlight (see doc above).
parse_state: ParseState::new(syn.syntax_set.find_syntax_plain_text()),
highlight_state: HighlightState::new(&highlighter, ScopeStack::new()),
closed_memo: HashMap::new(),
closed_memo_bytes: 0,
}
}
/// Highlight a fenced block body from the streaming tail, routing to the
/// right cache: the incremental open-block path when the body reaches the
/// EOF of the tail (block still streaming), the closed-fence memo
/// otherwise. Single entry point so the parser carries no cache policy.
pub(crate) fn highlight_block(
&mut self,
syn: &Syntect,
fence_info: &str,
start_in_tail: usize,
body_reaches_eof: bool,
text: &str,
) -> Option<Vec<HlLine>> {
if body_reaches_eof {
self.highlight(syn, fence_info, start_in_tail, text)
} else {
self.highlight_closed(syn, fence_info, text)
}
}
/// Batch-highlight a **closed** fence body, memoized on
/// `(fence_info, body)`.
///
/// Closed fences trapped in an unfreezable tail (e.g. inside an open
/// list) are re-parsed by every `rerender_tail` pass; the memo makes
/// syntect run once per distinct body. The compute path *is*
/// [`syntax_highlight_raw`], so output is byte-identical by construction.
/// Theme stability follows [`highlight`](Self::highlight): the streaming
/// renderer drops this struct on any style change.
fn highlight_closed(
&mut self,
syn: &Syntect,
fence_info: &str,
text: &str,
) -> Option<Vec<HlLine>> {
if let Some(hit) = self.closed_memo.get(fence_info).and_then(|m| m.get(text)) {
// Hit clone is the same accepted O(lines)/pass residual as the
// open-block return below (see TODO on `highlight`).
return Some(hit.clone());
}
let lines = syntax_highlight_raw(Some(syn), fence_info, text)?;
if self.closed_memo_bytes.saturating_add(text.len()) > CLOSED_MEMO_CAP_BYTES {
self.closed_memo.clear();
self.closed_memo_bytes = 0;
}
let prev = self
.closed_memo
.entry(fence_info.to_owned())
.or_default()
.insert(text.to_owned(), lines.clone());
debug_assert!(prev.is_none(), "miss-checked key cannot already exist");
self.closed_memo_bytes += text.len();
Some(lines)
}
/// Test-only view of memoized closed-fence body bytes.
#[cfg(test)]
pub(crate) fn closed_memo_bytes(&self) -> usize {
self.closed_memo_bytes
}
/// Highlight the open block body `text` (the full body so far, append-only),
/// reusing persisted syntect state where possible.
///
/// Returns one styled line per source line (including the trailing partial
/// line if the body does not end in `\n`), matching what a batch
/// `HighlightLines` run would produce. Returns `None` if the fence has no
/// known syntax or a line fails to parse, so the caller can fall back to the
/// plain/untagged code path exactly like [`syntax_highlight_raw`].
///
/// # Theme stability invariant
///
/// The persisted state and the already-highlighted `committed_lines` bake
/// in the colors of the `syn.theme` seen so far, so the caller MUST pass a
/// [`Syntect`] whose `theme` is stable for the lifetime of a given open
/// block. A theme swap must go through a cache reset (the streaming renderer
/// does this in `set_style`); otherwise committed lines keep their old
/// colors while newly-committed lines use the new theme. The batch path has
/// no such constraint because it re-highlights from scratch every call.
fn highlight(
&mut self,
syn: &Syntect,
fence_info: &str,
start_in_tail: usize,
text: &str,
) -> Option<Vec<HlLine>> {
// Rebuild from scratch when anything that would change the output from
// the very first line changes: the language (different syntax/colors),
// the block position (a different block), or a non-append-only edit to
// the body (the committed prefix no longer matches `text`).
let needs_rebuild = fence_info != self.fence_info
|| start_in_tail != self.start_in_tail
|| !self.committed_prefix_matches(text);
if needs_rebuild {
// `syntax` is only needed to (re)seed the parser, so it is resolved
// here rather than on the warm path where it would be dead work.
let syntax = syn.find_syntax_for_fence_info(fence_info)?;
let highlighter = Highlighter::new(&syn.theme);
fence_info.clone_into(&mut self.fence_info);
self.start_in_tail = start_in_tail;
self.committed_len = 0;
self.committed_lines.clear();
self.parse_state = ParseState::new(syntax);
self.highlight_state = HighlightState::new(&highlighter, ScopeStack::new());
}
// Nothing new since the last committed `\n`: return the cached lines
// without constructing a `Highlighter` at all.
if self.committed_len == text.len() {
return Some(self.committed_lines.clone());
}
// Walk only the not-yet-committed remainder.
let highlighter = Highlighter::new(&syn.theme);
let mut tentative: Option<HlLine> = None;
for line in LinesWithEndings::from(&text[self.committed_len..]) {
if line.ends_with('\n') {
// A newline-terminated line is final: highlight once and
// permanently advance the persisted state. On a (practically
// unreachable) parse error, invalidate the cache so the next
// pass rebuilds from scratch instead of resuming from a
// now-inconsistent `parse_state` — matching the stateless
// batch fallback.
let ops = match self.parse_state.parse_line(line, &syn.syntax_set) {
Ok(ops) => ops,
Err(_) => {
self.fence_info.clear();
return None;
}
};
let highlighted =
HighlightIterator::new(&mut self.highlight_state, &ops, line, &highlighter)
.map(|(s, t)| (s, t.to_string()))
.collect();
self.committed_lines.push(highlighted);
self.committed_len += line.len();
} else {
// The trailing line has no `\n` yet — it is still streaming and
// may be extended by the next push. Highlight it on CLONES so
// the committed state stays anchored at the last `\n`.
let mut parse_state = self.parse_state.clone();
let mut highlight_state = self.highlight_state.clone();
let ops = match parse_state.parse_line(line, &syn.syntax_set) {
Ok(ops) => ops,
Err(_) => {
self.fence_info.clear();
return None;
}
};
tentative = Some(
HighlightIterator::new(&mut highlight_state, &ops, line, &highlighter)
.map(|(s, t)| (s, t.to_string()))
.collect(),
);
}
}
// TODO: this clone keeps the open-block RETURN at O(lines)/pass
// = O(lines^2)/stream. It only copies precomputed style spans (the
// expensive syntect parse/highlight CPU is already O(N) total), and the
// surrounding tail render + url_scan are likewise O(N)/pass, so this is
// tracked as an accepted residual — not a regression. Removing it needs
// a borrowed return threaded through `Replace` + the render pipeline.
let mut out = self.committed_lines.clone();
if let Some(last) = tentative {
out.push(last);
}
Some(out)
}
/// Whether the committed prefix is still a prefix of `text` (append-only
/// safety check). Allocation-free: walks the stored styled segments, whose
/// texts concatenate back to the original first `committed_len` bytes.
fn committed_prefix_matches(&self, text: &str) -> bool {
if self.committed_len > text.len() {
return false;
}
let bytes = text.as_bytes();
let mut pos = 0;
for line in &self.committed_lines {
for (_, piece) in line {
let end = pos + piece.len();
if bytes.get(pos..end) != Some(piece.as_bytes()) {
return false;
}
pos = end;
}
}
pos == self.committed_len
}
}
#[cfg(test)]
mod tests {
use syntect::util::LinesWithEndings;
use super::*;
use crate::syntax::test_syntect;
/// Batch reference highlight, mirroring `parse::syntax_highlight_raw`.
fn batch(syn: &Syntect, fence: &str, text: &str) -> Vec<HlLine> {
let mut hl = syn
.highlight_lines_for_fence_info(fence)
.expect("syntax for fence");
LinesWithEndings::from(text)
.map(|line| {
hl.highlight_line(line, &syn.syntax_set)
.expect("highlight line")
.into_iter()
.map(|(s, t)| (s, t.to_string()))
.collect()
})
.collect()
}
#[test]
fn append_only_growth_matches_fresh_full_highlight() {
let syn = test_syntect();
let full = "foo: 1\nbar:\n - a\n - b\nbaz: true\n";
let mut cache = OpenCodeHighlighter::new(syn);
// Grow one byte at a time; every prefix must equal a one-shot batch
// highlight of that same prefix (incremental == batch, byte-for-byte).
for end in 1..=full.len() {
if !full.is_char_boundary(end) {
continue;
}
let got = cache.highlight(syn, "yaml", 0, &full[..end]).expect("hl");
assert_eq!(got, batch(syn, "yaml", &full[..end]), "prefix len {end}");
}
}
#[test]
fn fence_change_invalidates() {
let syn = test_syntect();
let text = "let x = 1;\nfn main() {}\n";
let mut cache = OpenCodeHighlighter::new(syn);
// Prime with yaml, then re-key to rust: output must match a fresh rust
// batch, proving the persisted yaml state was discarded.
let _ = cache.highlight(syn, "yaml", 0, text).expect("hl yaml");
let got = cache.highlight(syn, "rust", 0, text).expect("hl rust");
assert_eq!(got, batch(syn, "rust", text));
}
#[test]
fn start_offset_change_invalidates() {
let syn = test_syntect();
let mut cache = OpenCodeHighlighter::new(syn);
let a = "alpha: 1\nbeta: 2\n";
let _ = cache.highlight(syn, "yaml", 0, a).expect("hl a");
// Same language, different block position and body: the new body must
// be highlighted fresh (no stale committed lines from the old block).
let b = "gamma: 3\ndelta: 4\n";
let got = cache.highlight(syn, "yaml", 42, b).expect("hl b");
assert_eq!(got, batch(syn, "yaml", b));
}
#[test]
fn unknown_fence_returns_none() {
let syn = test_syntect();
let mut cache = OpenCodeHighlighter::new(syn);
assert!(
cache
.highlight(syn, "definitely-not-a-language-xyz", 0, "data\n")
.is_none(),
);
}
// ── highlight_closed (closed-fence memo) ─────────────────────────
#[test]
fn closed_memo_matches_batch_and_is_idempotent() {
let syn = test_syntect();
let mut cache = OpenCodeHighlighter::new(syn);
let body = "fn answer(x: u64) -> u64 {\n x.wrapping_mul(42)\n}\n";
// First call computes; must equal the batch reference exactly.
let first = cache.highlight_closed(syn, "rust", body).expect("hl");
assert_eq!(first, batch(syn, "rust", body));
assert_eq!(cache.closed_memo_bytes(), body.len());
// Second call is a memo hit: identical output, no new entry.
let second = cache.highlight_closed(syn, "rust", body).expect("hl");
assert_eq!(second, first);
assert_eq!(cache.closed_memo_bytes(), body.len());
}
#[test]
fn closed_memo_distinguishes_fence_info_and_body() {
let syn = test_syntect();
let mut cache = OpenCodeHighlighter::new(syn);
let body_a = "key: value\n";
let body_b = "other: thing\n";
let yaml_a = cache.highlight_closed(syn, "yaml", body_a).expect("hl");
let yaml_b = cache.highlight_closed(syn, "yaml", body_b).expect("hl");
let rust_a = cache.highlight_closed(syn, "rust", body_a).expect("hl");
assert_eq!(yaml_a, batch(syn, "yaml", body_a));
assert_eq!(yaml_b, batch(syn, "yaml", body_b));
assert_eq!(rust_a, batch(syn, "rust", body_a));
}
#[test]
fn closed_memo_does_not_disturb_open_block_state() {
let syn = test_syntect();
let mut cache = OpenCodeHighlighter::new(syn);
// Interleave closed-memo calls with open-block incremental growth
// (a tail with one closed fence above an open one); open-block
// output must stay batch-identical throughout.
let closed = "name: pinned\n";
let full = "a = 1\nb = 2\nc = 3\n";
for end in 1..=full.len() {
if !full.is_char_boundary(end) {
continue;
}
let _ = cache.highlight_closed(syn, "yaml", closed).expect("memo");
let got = cache.highlight(syn, "python", 7, &full[..end]).expect("hl");
assert_eq!(got, batch(syn, "python", &full[..end]), "prefix len {end}");
}
assert_eq!(cache.closed_memo_bytes(), closed.len());
}
#[test]
fn closed_memo_cap_overflow_keeps_output_correct() {
let syn = test_syntect();
let mut cache = OpenCodeHighlighter::new(syn);
// Bodies sized so a handful of distinct ones cross the byte budget
// and trigger the wholesale clear; output must stay batch-identical
// before, at, and after the eviction.
let filler = "x".repeat(CLOSED_MEMO_CAP_BYTES / 4);
for i in 0..6 {
let body = format!("key_{i}: \"{filler}\"\n");
let got = cache.highlight_closed(syn, "yaml", &body).expect("hl");
assert_eq!(got, batch(syn, "yaml", &body), "iteration {i}");
}
assert!(cache.closed_memo_bytes() <= CLOSED_MEMO_CAP_BYTES);
}
}