Files
Kigi-CLI/crates/codegen/kigi-markdown/src/latex_delimiters.rs
T
ZacharyZhang-NY a02b555e66 docs(comments): rewrite comments across all crates to the guidelines
Sweep every first-party crate source (1956 .rs files) to the project comment
guidelines: delete redundant restatements, decorative banners, change
narration, and end-of-line comments; keep and tighten the crucial ones
(invariants, bug rationale, SAFETY blocks, ported-source attribution).

No functional code changed. Every edit is proven comment-only against the
prior tree by a comment-stripping lexer (string/char/raw-string aware) plus a
separate doctest-fence check. Where removing a comment made rustfmt or clippy
want to re-lay-out adjacent code, the minimal triggering comment is restored so
code tokens stay byte-identical.

Gates green: cargo fmt --all --check (0 diffs), cargo check and cargo clippy
--workspace --all-targets (0 warnings).

Adds scripts/check_codegen_comment_guidelines.py — the enforcement gate for
these guidelines (flags banners, end-of-line comments, change narration, and
commented-out code).
2026-07-23 16:55:39 -04:00

1325 lines
52 KiB
Rust

//! Streaming normalization of LaTeX math delimiters into the canonical
//! `$...$` / `$$...$$` forms that `pulldown-cmark`'s math extension understands.
//!
//! Models overwhelmingly emit the backslash delimiter forms (`\(...\)`,
//! `\[...\]`) and sometimes `\begin{equation}...\end{equation}`. `pulldown-cmark`
//! only recognizes the `$` forms, so historically the backslash forms were
//! handled by bespoke post-parse source scanners — which were disabled inside
//! table cells (a bug). By rewriting every delimiter form into the
//! canonical `$`/`$$` form *before* parsing, the existing
//! `Event::InlineMath`/`Event::DisplayMath` handlers (which already convert math
//! in both prose and table cells) handle everything uniformly.
//!
//! # Transform set (applied only outside code, respecting escapes)
//!
//! | Input | Output |
//! |-------|--------|
//! | `\( … \)` | `$…$` (whitespace just inside the delimiters trimmed) |
//! | `\)` (unmatched) | `$` |
//! | `\[ … \]` / `$$ … $$` / `\begin{equation} … \end{equation}` | `$$…$$`, interior newlines joined |
//! | `\[` / `\]` (unmatched) | `$$` |
//! | `\begin{equation[*]}` / `\end{equation[*]}` (unmatched) | `$$` |
//!
//! Inline `\( … \)` is converted span-at-once: the matching unescaped `\)` is
//! located and the ASCII whitespace immediately inside the delimiters is
//! trimmed, so the emitted `$…$` has no space right after the opening `$` or
//! before the closing `$`. pulldown-cmark's dollar-math flanking rule rejects
//! `$ … $` (whitespace next to a delimiter) and would otherwise leave a `padded`
//! span as raw `$ … $` text. Interior newlines join to spaces (TeX treats them
//! as spaces) so a span wrapped across source lines cannot be re-parsed as
//! block structure.
//!
//! # Display spans are joined onto one line
//!
//! Every display-math opener — `\[`, `\begin{equation[*]}`, or a bare `$$` —
//! is resolved span-at-once: the matching close (`\]`, `\end{equation[*]}`, or
//! `$$`, whichever comes first) is located and the span is emitted as
//! `$$…$$` with each interior line trimmed and joined by a single space.
//! CommonMark gives *block* constructs priority over inline math, so a
//! multi-line `$$…$$` whose interior contains a line that looks like a block
//! start — a setext underline (`=`/`-` alone on a line), a `#`
//! heading, or a `-` list item — would otherwise be split into
//! heading/list/paragraph blocks and never reach the math parser. TeX treats
//! interior newlines as spaces, so joining is semantics-preserving (`\\` row
//! separators are untouched and still produce multi-line output downstream).
//!
//! The close-scan is bounded: it gives up (emitting the opener alone, exactly
//! the old behavior) past [`MAX_MATH_SOURCE_LEN`] look-ahead, at a blank line
//! (a paragraph break — two stray `$$` in prose must not fuse across
//! paragraphs), or at a line starting with `>` (blockquoted math carries `>`
//! markers that must not become span content; pulldown already handles the
//! quoted multi-line span after marker stripping).
//!
//! Bare single `$` is left untouched (so the pass is **idempotent**). Escaped
//! openers (`\\(`, `\\[`, `\$`) are left literal via backslash-pair consumption,
//! matching the old scanner's even/odd parity rule. Content inside inline code
//! spans and fenced code blocks is left verbatim (so LaTeX-in-backticks stays
//! raw, preserving prior behavior). Inner LaTeX environments such as
//! `\begin{aligned}` / `\begin{pmatrix}` are *not* touched — they live inside the
//! `$$...$$` span and are rendered by the LaTeX→Unicode converter.
//!
//! # Streaming
//!
//! [`LatexDelimiterNormalizer`] is fed chunks in order and is **chunk-split
//! invariant**: feeding the same total text produces the same output regardless
//! of where the chunk boundaries fall. It achieves this by holding back only a
//! bounded ambiguous suffix (a trailing `\`/`\begin{…` partial, a trailing
//! backtick/tilde run whose length isn't yet known, or an unclosed inline `\(`
//! whose `\)` has not arrived — bounded by the math size cap so an open that
//! never closes cannot stall the stream) until the next chunk, and by flushing
//! that suffix on [`finish`](LatexDelimiterNormalizer::finish).
//!
//! # Known divergences from CommonMark (bounded, documented)
//!
//! - 4-space *indented* code blocks are not treated as code (math inside them
//! would convert). Rare in model output.
//! - Inline code spans are treated as single-line: an unterminated `` ` `` reverts
//! to normal at the newline. This only changes behavior next to a stray,
//! unmatched backtick.
//!
//! Streaming-vs-one-shot equivalence is pinned by an exhaustive byte-split test.
use crate::latex::MAX_MATH_SOURCE_LEN;
const ENV_BEGIN: &str = "\\begin{equation}";
const ENV_BEGIN_STARRED: &str = "\\begin{equation*}";
const ENV_END: &str = "\\end{equation}";
const ENV_END_STARRED: &str = "\\end{equation*}";
/// Longest environment token we special-case (`\begin{equation*}` = 17 bytes).
/// Bounds how many trailing bytes a `push` may hold back for a `\begin`/`\end`.
const ENV_TOKENS: [&str; 4] = [ENV_BEGIN, ENV_BEGIN_STARRED, ENV_END, ENV_END_STARRED];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Normal,
/// Inside an inline code span opened by a run of `run` backticks.
InlineCode {
run: usize,
},
/// Inside a fenced code block opened by `len` copies of `ch` (`` ` `` or `~`).
Fenced {
ch: u8,
len: usize,
},
}
/// Streaming, code-aware, escape-aware LaTeX delimiter normalizer.
///
/// Feed chunks via [`push`](Self::push) and call [`finish`](Self::finish) at end
/// of stream. For a complete string in hand, use [`normalize_latex_delimiters`].
#[derive(Debug, Clone)]
pub struct LatexDelimiterNormalizer {
state: State,
/// True when the next byte begins a new line (start of input counts).
at_line_start: bool,
/// Raw bytes held back from a previous `push` because they may be the prefix
/// of a construct that needs more input to classify.
pending: String,
}
impl Default for LatexDelimiterNormalizer {
fn default() -> Self {
Self::new()
}
}
impl LatexDelimiterNormalizer {
pub fn new() -> Self {
Self {
state: State::Normal,
at_line_start: true,
pending: String::new(),
}
}
/// Reset to the initial state, dropping any held-back bytes.
pub fn reset(&mut self) {
self.state = State::Normal;
self.at_line_start = true;
self.pending.clear();
}
/// Push a raw chunk; returns the finalized normalized prefix.
///
/// A bounded ambiguous suffix may be held back and emitted by a later
/// `push` or by [`finish`](Self::finish).
pub fn push(&mut self, chunk: &str) -> String {
if chunk.is_empty() {
return String::new();
}
let mut buf = std::mem::take(&mut self.pending);
buf.push_str(chunk);
let (out, consumed) = self.process(&buf, false);
self.pending = buf[consumed..].to_string();
out
}
/// Flush any held-back bytes as literal (end of stream).
pub fn finish(&mut self) -> String {
let buf = std::mem::take(&mut self.pending);
if buf.is_empty() {
return String::new();
}
let (out, consumed) = self.process(&buf, true);
debug_assert_eq!(consumed, buf.len(), "final flush must consume all input");
out
}
/// Process `buf` from the start, advancing internal state. Returns the
/// emitted text and the number of bytes consumed; bytes `[consumed..]` are
/// the held-back ambiguous suffix (always empty when `final_flush`).
fn process(&mut self, buf: &str, final_flush: bool) -> (String, usize) {
let bytes = buf.as_bytes();
let n = bytes.len();
let mut out = String::with_capacity(n + 8);
let mut i = 0;
while i < n {
match self.state {
State::Normal => {
if self.at_line_start {
match scan_fence_open(bytes, i, final_flush) {
FenceScan::NeedMore => break,
FenceScan::Match { ch, len, end } => {
out.push_str(&buf[i..end]);
i = end;
self.state = State::Fenced { ch, len };
self.at_line_start = false;
continue;
}
FenceScan::No => {}
}
}
match bytes[i] {
b'\n' => {
out.push('\n');
i += 1;
self.at_line_start = true;
}
b'`' => {
let run = count_run(bytes, i, b'`');
if i + run == n && !final_flush {
// run may extend; hold it back
break;
}
out.push_str(&buf[i..i + run]);
i += run;
self.state = State::InlineCode { run };
self.at_line_start = false;
}
b'\\' => {
// Every non-`break` arm below advances past a delimiter
// mid-line, so `at_line_start` is cleared once here; the
// `break`s (hold-backs) skip it and preserve it for
// the retry, making that invariant structural.
match classify_backslash(buf, i, final_flush) {
Bs::NeedMore => break,
Bs::InlineOpen => match find_inline_close(bytes, i, final_flush) {
InlineClose::Found { close } => {
// Trim pulldown's flanking whitespace so `$…$` is
// accepted; the custom set (vs `char::is_ascii_whitespace`)
// exists only to add vertical tab (0x0B).
let inner = buf[i + 2..close]
.trim_matches(|c: char| matches!(c, ' ' | '\t'..='\r'));
if inner.is_empty() {
// Empty after trim: a lone `$` keeps the old
// position-for-position output (`$<ws>$`, or
// `$$` when the interior is truly empty).
out.push('$');
i += 2;
} else {
// Join interior newlines: a `$…$` wrapped
// across source lines would otherwise be
// vulnerable to block re-parsing (setext
// underlines, list markers).
out.push('$');
push_joined_lines(&mut out, inner);
out.push('$');
i = close + 2;
}
}
// Too far, or unclosed at EOF: lone `$`, no trim.
InlineClose::Unmatched => {
out.push('$');
i += 2;
}
// Hold back from `\(` until the `\)` arrives.
InlineClose::NeedMore => break,
},
Bs::DisplayOpen { len } => {
match find_display_close(buf, i + len, final_flush) {
DisplayClose::Found { close, close_len } => {
emit_display_span(&mut out, &buf[i + len..close]);
i = close + close_len;
}
// No close in reach: emit the canonical opener
// alone (old position-for-position behavior)
// and process the interior normally.
DisplayClose::Unmatched => {
out.push_str("$$");
i += len;
}
// Hold back from the opener until the close
// (or an abort condition) arrives.
DisplayClose::NeedMore => break,
}
}
Bs::Convert { to, len } => {
out.push_str(to);
i += len;
}
Bs::Literal { len } => {
out.push_str(&buf[i..i + len]);
i += len;
}
}
self.at_line_start = false;
}
b'$' => {
let run = count_run(bytes, i, b'$');
if run == 1 && i + 1 == n && !final_flush {
// may become `$$`; hold it back
break;
}
if run >= 2 {
// A display opener is exactly two `$`; any further
// `$`s are span content for the close-scan. Consuming
// two (not the whole run) keeps emitted spans fixed
// points: output like `$` + `$$…$$` re-tokenizes to
// the same bytes on a second pass (idempotency).
match find_display_close(buf, i + 2, final_flush) {
DisplayClose::Found { close, close_len } => {
emit_display_span(&mut out, &buf[i + 2..close]);
i = close + close_len;
}
// No close in reach: `$$` stays literal (pulldown
// decides), interior is processed normally.
DisplayClose::Unmatched => {
out.push_str("$$");
i += 2;
}
DisplayClose::NeedMore => break,
}
} else {
// Single `$` (inline math / currency) passes through
// verbatim; pulldown handles it.
out.push('$');
i += 1;
}
self.at_line_start = false;
}
_ => {
// Copy a run of ordinary bytes up to the next
// interesting ASCII byte. Multibyte UTF-8 bytes
// (>= 0x80) never equal the ASCII delimiters, so
// they are copied whole and slices stay valid.
let start = i;
while i < n && !matches!(bytes[i], b'\n' | b'`' | b'\\' | b'$') {
i += 1;
}
out.push_str(&buf[start..i]);
self.at_line_start = false;
}
}
}
State::InlineCode { run } => {
// Single-line span: copy verbatim until a matching-length
// backtick run closes it, the line ends (unterminated → revert
// to Normal so later math still converts), or EOF.
let start = i;
let mut handled = false;
while i < n {
match bytes[i] {
b'\n' => {
i += 1;
out.push_str(&buf[start..i]);
self.state = State::Normal;
self.at_line_start = true;
handled = true;
break;
}
b'`' => {
let r = count_run(bytes, i, b'`');
if i + r == n && !final_flush {
out.push_str(&buf[start..i]);
// hold back the trailing run
return (out, i);
}
if r == run {
i += r;
out.push_str(&buf[start..i]);
self.state = State::Normal;
self.at_line_start = false;
handled = true;
break;
}
// non-matching run is literal content
i += r;
}
_ => i += 1,
}
}
if !handled {
// EOF inside code
out.push_str(&buf[start..i]);
}
}
State::Fenced { ch, len } => {
if self.at_line_start {
match scan_fence_close(bytes, i, ch, len, final_flush) {
FenceScan::NeedMore => break,
FenceScan::Match { end, .. } => {
out.push_str(&buf[i..end]);
i = end;
self.state = State::Normal;
self.at_line_start = false;
continue;
}
FenceScan::No => {}
}
}
// Copy the rest of this line verbatim (fenced content).
let start = i;
while i < n && bytes[i] != b'\n' {
i += 1;
}
if i < n {
// include the newline
i += 1;
self.at_line_start = true;
} else {
self.at_line_start = false;
}
out.push_str(&buf[start..i]);
}
}
}
(out, i)
}
}
/// One-shot normalization == `push(s)` + `finish()`. Used by batch render
/// entries and tests.
pub fn normalize_latex_delimiters(s: &str) -> String {
let mut nz = LatexDelimiterNormalizer::new();
let mut out = nz.push(s);
out.push_str(&nz.finish());
out
}
fn count_run(bytes: &[u8], start: usize, ch: u8) -> usize {
let mut j = start;
while j < bytes.len() && bytes[j] == ch {
j += 1;
}
j - start
}
/// Result of scanning for a fence open/close marker at a line start.
enum FenceScan {
/// Marker found; `end` is the index just past the run of fence chars.
Match { ch: u8, len: usize, end: usize },
/// Definitely not a fence marker here.
No,
/// Not enough input to decide; caller should hold back from the line start.
NeedMore,
}
/// Scan for an opening fence (`` ``` `` / `~~~`, length >= 3) at line start,
/// allowing up to 3 leading spaces. An info string may follow the run.
fn scan_fence_open(bytes: &[u8], i: usize, final_flush: bool) -> FenceScan {
let n = bytes.len();
let mut j = i;
let mut spaces = 0;
while j < n && bytes[j] == b' ' && spaces < 4 {
spaces += 1;
j += 1;
}
if spaces >= 4 {
// indented; not treated as a fence opener
return FenceScan::No;
}
if j == n {
return if final_flush {
FenceScan::No
} else {
// ≤3 spaces then EOF: a fence may still start
FenceScan::NeedMore
};
}
let ch = bytes[j];
if ch != b'`' && ch != b'~' {
return FenceScan::No;
}
let run = count_run(bytes, j, ch);
if j + run == n && !final_flush {
// run may extend
return FenceScan::NeedMore;
}
if run < 3 {
// inline code / stray tildes, not a fence
return FenceScan::No;
}
FenceScan::Match {
ch,
len: run,
end: j + run,
}
}
/// Scan for a closing fence at line start: up to 3 spaces, a run of `ch` with
/// length >= `len`, then only whitespace to end of line.
fn scan_fence_close(bytes: &[u8], i: usize, ch: u8, len: usize, final_flush: bool) -> FenceScan {
let n = bytes.len();
let mut j = i;
let mut spaces = 0;
while j < n && bytes[j] == b' ' && spaces < 4 {
spaces += 1;
j += 1;
}
if spaces >= 4 {
return FenceScan::No;
}
if j == n {
return if final_flush {
FenceScan::No
} else {
FenceScan::NeedMore
};
}
if bytes[j] != ch {
return FenceScan::No;
}
let run = count_run(bytes, j, ch);
if j + run == n && !final_flush {
// run may still grow to >= len
return FenceScan::NeedMore;
}
if run < len {
return FenceScan::No;
}
// A close line carries no info string: only trailing whitespace allowed.
let mut k = j + run;
while k < n && matches!(bytes[k], b' ' | b'\t') {
k += 1;
}
if k == n {
return if final_flush {
FenceScan::Match {
ch,
len: run,
end: j + run,
}
} else {
FenceScan::NeedMore
};
}
if bytes[k] == b'\n' {
FenceScan::Match {
ch,
len: run,
end: j + run,
}
} else {
// non-whitespace after the run → info string → content
FenceScan::No
}
}
/// Classification of a backslash sequence starting at `i` (where `bytes[i]` is
/// `\`).
enum Bs {
/// Replace `buf[i..i+len]` with `to`.
Convert { to: &'static str, len: usize },
/// An inline math open `\(`: the caller locates the matching `\)` and emits
/// a whitespace-trimmed `$…$` span (see [`find_inline_close`]).
InlineOpen,
/// A display math open (`\[` or `\begin{equation[*]}`, `len` bytes): the
/// caller locates the matching close and emits a line-joined `$$…$$` span
/// (see [`find_display_close`]).
DisplayOpen { len: usize },
/// Emit `buf[i..i+len]` verbatim (consumes the sequence so escape parity
/// holds; e.g. `\\` is consumed as a pair).
Literal { len: usize },
/// Not enough input to classify; hold back from `i`.
NeedMore,
}
fn classify_backslash(buf: &str, i: usize, final_flush: bool) -> Bs {
let bytes = buf.as_bytes();
let n = bytes.len();
debug_assert_eq!(bytes[i], b'\\');
if i + 1 >= n {
return if final_flush {
Bs::Literal { len: 1 }
} else {
Bs::NeedMore
};
}
match bytes[i + 1] {
// Escaped backslash: emit the pair so a following `(`/`[` is not read as
// a delimiter (this is the even/odd parity rule, applied incrementally).
b'\\' => Bs::Literal { len: 2 },
// Inline open: the caller scans for the matching `\)` to emit a
// whitespace-trimmed `$…$`. A lone `\)` (unmatched close) still maps to
// `$` position-for-position.
b'(' => Bs::InlineOpen,
b')' => Bs::Convert { to: "$", len: 2 },
// Display open: the caller scans for the matching close to emit a
// line-joined `$$…$$`. A lone `\]` (unmatched close) still maps to
// `$$` position-for-position.
b'[' => Bs::DisplayOpen { len: 2 },
b']' => Bs::Convert { to: "$$", len: 2 },
b'b' | b'e' => match match_env(buf, i, final_flush) {
// `\begin{equation[*]}` opens a display span; a stray
// `\end{equation[*]}` still maps to `$$` position-for-position.
EnvScan::Convert(len) => {
if bytes[i + 1] == b'b' {
Bs::DisplayOpen { len }
} else {
Bs::Convert { to: "$$", len }
}
}
EnvScan::NeedMore => Bs::NeedMore,
// Not one of our envs (e.g. `\begin{aligned}`): emit just the `\` and
// let the rest be copied as ordinary text (verbatim).
EnvScan::No => Bs::Literal { len: 1 },
},
// `\$`, `\x`, etc: emit the `\`, process the next char normally.
_ => Bs::Literal { len: 1 },
}
}
/// Outcome of scanning an inline `\(` span for its matching close.
enum InlineClose {
/// Unescaped `\)` found; `close` is the byte index of its backslash.
Found { close: usize },
/// No usable close: either none within the look-ahead cap, or the open is
/// still unclosed at end of stream. The caller emits a lone `$` (no trim),
/// reproducing the old position-for-position behavior.
Unmatched,
/// Buffer ends within the cap without a close and more input may still
/// arrive; the caller holds back from the open until the `\)` shows up.
NeedMore,
}
/// Scan for the unescaped `\)` closing an inline `\(` at `open`
/// (`bytes[open..open + 2] == b"\\("`). The inner length is bounded by
/// [`MAX_MATH_SOURCE_LEN`] (the converter's own input cap) so an unclosed `\(`
/// cannot stall the stream; the bound is a distance relative to `open`, so the
/// Found/Unmatched decision is the same whether the input arrives whole or
/// split. Mirrors [`classify_backslash`]'s `final_flush`: at end of stream an
/// unfound close resolves to `Unmatched` instead of `NeedMore`.
///
/// Backslash parity matches [`classify_backslash`]: `\\` is an escaped pair
/// (its following byte is literal), a lone `\)` is the close, and any other
/// `\x` consumes both bytes as span content.
fn find_inline_close(bytes: &[u8], open: usize, final_flush: bool) -> InlineClose {
debug_assert!(
bytes.get(open) == Some(&b'\\') && bytes.get(open + 1) == Some(&b'('),
"find_inline_close must start at a `\\(`"
);
let n = bytes.len();
let mut k = open + 2;
while k < n {
// A close at `k` would give inner `buf[open + 2..k]`; stop once that
// would exceed what `latex_to_unicode_inline` accepts.
if k - (open + 2) > MAX_MATH_SOURCE_LEN {
return InlineClose::Unmatched;
}
if bytes[k] == b'\\' {
match bytes.get(k + 1) {
// trailing `\`: need the next byte to classify
None => break,
Some(b')') => return InlineClose::Found { close: k },
// `\\` pair or `\x` escape: skip both bytes
Some(_) => k += 2,
}
} else {
k += 1;
}
}
// End of buffer within the cap (or a trailing `\`): unclosed at EOF emits a
// lone `$`; otherwise hold back for more input.
if final_flush {
InlineClose::Unmatched
} else {
InlineClose::NeedMore
}
}
/// Outcome of scanning a display span (opened by `\[`, `$$`, or
/// `\begin{equation[*]}`) for its close.
enum DisplayClose {
/// Close token found; `close` is its byte index, `close_len` its length.
Found { close: usize, close_len: usize },
/// No usable close: past the look-ahead cap, aborted at a blank line or a
/// blockquote marker, or unclosed at end of stream. The caller emits the
/// canonical `$$` opener alone (the old position-for-position behavior).
Unmatched,
/// Buffer ends without a decision and more input may still arrive; the
/// caller holds back from the opener.
NeedMore,
}
/// Scan for the token closing a display span whose content starts at
/// `content_start`. Any display close token counts — `\]`, `$$`, or
/// `\end{equation[*]}` — matching the pre-existing behavior where mismatched
/// opener/close pairs (e.g. `\[ … $$`) still formed a span because every
/// delimiter normalized to `$$` independently.
///
/// The scan is bounded by [`MAX_MATH_SOURCE_LEN`] relative to `content_start`
/// (so the Found/Unmatched decision is split-invariant) and aborts — leaving
/// the source for normal processing — at:
///
/// - a blank line: a paragraph break means the opener was almost certainly not
/// math (e.g. `$$` used as prose), and two stray `$$` must not fuse across
/// paragraphs;
/// - a line starting with `>`: blockquoted display math carries `>` markers
/// that would otherwise be joined into the span as literal content
/// (pulldown handles the quoted multi-line span itself after stripping the
/// markers).
///
/// Backslash parity matches [`find_inline_close`]: `\\` and other `\x` pairs
/// are span content, consumed two bytes at a time.
fn find_display_close(buf: &str, content_start: usize, final_flush: bool) -> DisplayClose {
let bytes = buf.as_bytes();
let n = bytes.len();
let mut k = content_start;
while k < n {
if k - content_start > MAX_MATH_SOURCE_LEN {
return DisplayClose::Unmatched;
}
match bytes[k] {
b'\\' => match bytes.get(k + 1) {
// trailing `\`: need the next byte to classify
None => break,
Some(b']') => {
return DisplayClose::Found {
close: k,
close_len: 2,
};
}
Some(b'e') => {
// `\end{equation}` / `\end{equation*}` closes the span.
let rest = &buf[k..];
let mut matched = None;
let mut could_extend = false;
for tok in [ENV_END, ENV_END_STARRED] {
if rest.len() >= tok.len() {
if rest.starts_with(tok) {
matched =
Some(matched.map_or(tok.len(), |m: usize| m.max(tok.len())));
}
} else if tok.starts_with(rest) {
could_extend = true;
}
}
if let Some(close_len) = matched {
return DisplayClose::Found {
close: k,
close_len,
};
}
if could_extend && !final_flush {
return DisplayClose::NeedMore;
}
// `\e…` of something else: span content
k += 2;
}
// `\\` pair or `\x` escape: span content
Some(_) => k += 2,
},
b'$' => {
let run = count_run(bytes, k, b'$');
if run >= 2 {
return DisplayClose::Found {
close: k,
close_len: 2,
};
}
if k + run == n && !final_flush {
// lone `$` at EOB may extend
return DisplayClose::NeedMore;
}
k += run;
}
b'\n' => {
// Look at the next line's start: blank line or `>` marker
// aborts the span (see doc comment).
let mut j = k + 1;
while j < n && matches!(bytes[j], b' ' | b'\t') {
j += 1;
}
if j == n {
// need the next line's first byte to decide
break;
}
if matches!(bytes[j], b'\n' | b'>') {
return DisplayClose::Unmatched;
}
k += 1;
}
_ => k += 1,
}
}
if final_flush {
DisplayClose::Unmatched
} else {
DisplayClose::NeedMore
}
}
/// Emit `interior` as a canonical `$$…$$` span, joining interior lines.
///
/// Single-line interiors are emitted verbatim (bare `$$…$$` input passes
/// through byte-for-byte, keeping the pass idempotent). Multi-line interiors
/// have each line trimmed and joined with a single space so CommonMark block
/// parsing (setext underlines, list items, headings) cannot split the span;
/// TeX treats the newlines as spaces, so rendering is `unchanged`.
fn emit_display_span(out: &mut String, interior: &str) {
out.push_str("$$");
push_joined_lines(out, interior);
out.push_str("$$");
}
/// Push `text` onto `out`; if it spans multiple lines, trim each line and
/// join the non-empty ones with single spaces (single-line text is verbatim).
fn push_joined_lines(out: &mut String, text: &str) {
if !text.contains('\n') {
out.push_str(text);
return;
}
let mut first = true;
for line in text.lines() {
let trimmed = line.trim_matches([' ', '\t', '\r']);
if trimmed.is_empty() {
continue;
}
if !first {
out.push(' ');
}
out.push_str(trimmed);
first = false;
}
}
enum EnvScan {
Convert(usize),
No,
NeedMore,
}
/// Match `\begin{equation}` / `\end{equation}` (and starred variants) at `i`.
fn match_env(buf: &str, i: usize, final_flush: bool) -> EnvScan {
let rest = &buf[i..];
let mut best: Option<usize> = None;
let mut could_extend = false;
for tok in ENV_TOKENS {
if rest.len() >= tok.len() {
if rest.starts_with(tok) {
best = Some(best.map_or(tok.len(), |b: usize| b.max(tok.len())));
}
} else if tok.starts_with(rest) {
could_extend = true;
}
}
if let Some(len) = best {
// `\begin{equation}` is not a prefix of `\begin{equation*}` (char 16 is
// `}` vs `*`), so the longest full match is unambiguous.
return EnvScan::Convert(len);
}
if could_extend && !final_flush {
return EnvScan::NeedMore;
}
EnvScan::No
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
fn norm(s: &str) -> String {
normalize_latex_delimiters(s)
}
// Basic conversions
#[test]
fn inline_paren_converts() {
assert_eq!(norm("\\(x^2\\)"), "$x^2$");
assert_eq!(norm("a \\(x\\) b"), "a $x$ b");
}
// Inline `\( … \)` boundary-whitespace trimming (the regression)
#[test]
fn normalize_inline_paren_trims_boundary_ws() {
// Padding on both flanks is stripped so pulldown's dollar-math flanking
// rule accepts the emitted `$…$`.
assert_eq!(norm("a \\( x+y \\) b"), "a $x+y$ b");
// One-sided padding.
assert_eq!(norm("\\(x \\)"), "$x$");
assert_eq!(norm("\\( x\\)"), "$x$");
// Multiple spaces / tabs collapse away at the boundaries only.
assert_eq!(norm("\\( x+y \\)"), "$x+y$");
assert_eq!(norm("\\(\tx\t\\)"), "$x$");
// VT (0x0B) is the one flanking-whitespace char `char::is_ascii_whitespace`
// omits, so this pins the custom trim predicate against a regression to std.
assert_eq!(norm("\\(\u{0b}x\u{0b}\\)"), "$x$");
// Interior whitespace and inner escaped braces are preserved.
assert_eq!(norm("\\( a + b \\)"), "$a + b$");
assert_eq!(norm("\\( \\{x\\} \\)"), "$\\{x\\}$");
}
#[test]
fn normalize_inline_paren_trim_leaves_escapes_and_dollars_alone() {
// Escaped `\\(`/`\\)` is a literal backslash + paren, not a math span.
assert_eq!(norm("\\\\( x \\\\)"), "\\\\( x \\\\)");
// Only the backslash forms are ours: a space-`padded` bare `$ x $` is NOT
// trimmed (currency untouched-ness is covered by `currency_not_misconverted`).
assert_eq!(norm("$ x $"), "$ x $");
}
#[test]
fn normalize_inline_paren_empty_span_degrades_position_for_position() {
// A whitespace-only span keeps its interior between two lone `$` (the
// old position-for-position form) rather than trimming to a `$$` opener.
assert_eq!(norm("\\( \\)"), "$ $");
assert_eq!(norm("\\( \\)"), "$ $");
// A truly-empty `\(\)` has no interior to separate the `$`, so it still
// collapses to `$$` — matching the pre-fix behavior (pinned, not a goal).
assert_eq!(norm("\\(\\)"), "$$");
}
#[test]
fn display_bracket_converts() {
assert_eq!(norm("\\[x^2\\]"), "$$x^2$$");
assert_eq!(norm("a\n\\[x\\]\nb"), "a\n$$x$$\nb");
}
// Multi-line display spans join onto one line
#[test]
fn multiline_display_with_setext_hazard_joins() {
// A lone `=` line inside a display span is a CommonMark setext
// underline: unjoined, pulldown parses a heading and the math is
// never seen (the raw-LaTeX bug).
assert_eq!(norm("$$\nx\n=\ny\n$$"), "$$x = y$$");
assert_eq!(norm("\\[\nx\n=\ny\n\\]"), "$$x = y$$");
// `-` (setext H2 / list marker) likewise.
assert_eq!(norm("$$\na\n- b\n$$"), "$$a - b$$");
}
#[test]
fn multiline_display_bracket_joins() {
assert_eq!(norm("\\[\n\\frac{a+b}{2}\n\\]"), "$$\\frac{a+b}{2}$$");
// Indented continuation lines are trimmed.
assert_eq!(norm("$$\n x +\n y\n$$"), "$$x + y$$");
}
#[test]
fn multiline_equation_env_joins() {
assert_eq!(
norm("\\begin{equation}\nE\n=\nmc^2\n\\end{equation}"),
"$$E = mc^2$$"
);
}
#[test]
fn mismatched_display_delimiters_still_join() {
// Every opener accepts every closer, matching the old behavior where
// each token normalized to `$$` independently.
assert_eq!(norm("\\[\nx\n=\ny\n$$"), "$$x = y$$");
assert_eq!(norm("$$\nx\n\\]"), "$$x$$");
}
#[test]
fn single_line_display_spans_unchanged() {
assert_eq!(norm("$$x = y$$"), "$$x = y$$");
assert_eq!(norm("text $$ a $$ more"), "text $$ a $$ more");
// Whitespace inside single-line spans is preserved verbatim.
assert_eq!(norm("$$ x $$"), "$$ x $$");
}
#[test]
fn display_join_aborts_at_blank_line() {
// Two stray `$$` across a paragraph break must not fuse into a span.
let input = "Tickets cost $$.\n\nDinner cost $$.";
assert_eq!(norm(input), input);
// Math with an interior blank line stays as-is too (pre-existing
// breakage; joining across paragraphs would be worse).
let math = "$$\nx\n\ny\n$$";
assert_eq!(norm(math), math);
}
#[test]
fn display_join_aborts_at_blockquote_marker() {
// Quoted display math keeps its `>` markers: pulldown strips them per
// line and handles the span; joining would make them span content.
let input = "> $$\n> x + y\n> $$";
assert_eq!(norm(input), input);
}
#[test]
fn unclosed_display_dollar_stays_literal() {
assert_eq!(norm("a $$ x = y"), "a $$ x = y");
assert_eq!(norm("$$"), "$$");
// Triple-and-more dollar runs pass through verbatim.
assert_eq!(norm("$$$"), "$$$");
assert_eq!(norm("$$$$"), "$$$$");
}
#[test]
fn display_join_gives_up_past_cap() {
// No close within MAX_MATH_SOURCE_LEN: the opener stays literal and
// the interior is processed normally.
let big = "y".repeat(MAX_MATH_SOURCE_LEN + 10);
let input = format!("$$\nx\n{big}");
assert_eq!(norm(&input), input);
}
#[test]
fn display_join_handles_crlf() {
assert_eq!(norm("$$\r\nx\r\n=\r\ny\r\n$$"), "$$x = y$$");
}
#[test]
fn interior_dollar_escapes_are_span_content() {
assert_eq!(norm("$$\nprice \\$5\n=\nz\n$$"), "$$price \\$5 = z$$");
}
// Inline `\(…\)` spans join interior newlines
#[test]
fn multiline_inline_paren_joins() {
// A wrapped inline span is equally vulnerable to setext re-parsing.
assert_eq!(norm("\\(a\n=\nb\\)"), "$a = b$");
assert_eq!(norm("\\(x +\n y\\)"), "$x + y$");
}
#[test]
fn equation_env_converts() {
assert_eq!(norm("\\begin{equation} x=1 \\end{equation}"), "$$ x=1 $$");
assert_eq!(norm("\\begin{equation*} y \\end{equation*}"), "$$ y $$");
}
#[test]
fn dollar_forms_unchanged() {
assert_eq!(norm("$x$"), "$x$");
assert_eq!(norm("$$x$$"), "$$x$$");
assert_eq!(norm("text $a+b$ more"), "text $a+b$ more");
}
#[test]
fn idempotent() {
for s in [
"\\(x\\)",
"\\[y\\]",
"a \\(x\\) and \\[y\\] and $z$",
"\\begin{equation} q \\end{equation}",
"`\\(code\\)`",
"```\n\\(c\\)\n```\n",
"$$\nx\n=\ny\n$$",
"\\[\nx\n=\ny\n\\]",
"\\begin{equation}\nE\n=\nmc^2\n\\end{equation}",
"\\(a\n=\nb\\)",
"a $$ x = y",
"> $$\n> x\n> $$",
"Tickets cost $$.\n\nDinner cost $$.",
] {
let once = norm(s);
let twice = norm(&once);
assert_eq!(once, twice, "not idempotent for {s:?}");
}
}
// Escapes & currency
#[test]
fn escaped_backslash_paren_stays_literal() {
// `\\(` = escaped backslash + literal paren → must NOT become math.
assert_eq!(norm("\\\\(x\\\\)"), "\\\\(x\\\\)");
// `\\\(` = escaped backslash + real `\(` → the `\(` converts.
assert_eq!(norm("\\\\\\(x\\\\\\)"), "\\\\$x\\\\$");
}
#[test]
fn escaped_dollar_stays_literal() {
assert_eq!(norm("price \\$5"), "price \\$5");
}
#[test]
fn currency_not_misconverted() {
assert_eq!(norm("$5 and $10"), "$5 and $10");
assert_eq!(norm("\\(a\\) costs $5"), "$a$ costs $5");
}
// Code is left verbatim
#[test]
fn inline_code_latex_untouched() {
assert_eq!(norm("`\\(x\\)`"), "`\\(x\\)`");
assert_eq!(norm("see `\\[y\\]` here"), "see `\\[y\\]` here");
// Double-backtick code span with an embedded single backtick.
assert_eq!(norm("``a ` \\(x\\)``"), "``a ` \\(x\\)``");
}
#[test]
fn fenced_code_latex_untouched() {
assert_eq!(norm("```\n\\(x\\)\n```\n"), "```\n\\(x\\)\n```\n");
assert_eq!(norm("```latex\n\\[y\\]\n```\n"), "```latex\n\\[y\\]\n```\n");
// Tilde fence.
assert_eq!(norm("~~~\n\\(x\\)\n~~~\n"), "~~~\n\\(x\\)\n~~~\n");
}
#[test]
fn math_around_code_still_converts() {
assert_eq!(norm("\\(a\\) `code` \\(b\\)"), "$a$ `code` $b$");
assert_eq!(
norm("\\(a\\)\n```\nx\n```\n\\(b\\)"),
"$a$\n```\nx\n```\n$b$"
);
}
#[test]
fn fence_with_three_space_indent() {
assert_eq!(
norm(" ```\n \\(x\\)\n ```\n"),
" ```\n \\(x\\)\n ```\n"
);
}
// Math inside tables (the bug)
#[test]
fn table_cell_backslash_math_converts() {
let input = "| Mode | Metric |\n|---|---|\n| Open | Decay vs \\(L_{x}\\) |\n";
let expected = "| Mode | Metric |\n|---|---|\n| Open | Decay vs $L_{x}$ |\n";
assert_eq!(norm(input), expected);
}
// Streaming equivalence (the key invariant)
const RICH_DOC: &str = concat!(
"Inline \\(a+b\\), dollar $c+d$, display \\[e=mc^2\\].\n\n",
"Padded \\( x + y \\) and \\( \\alpha + \\beta \\) spans.\n\n",
"| Col | Math |\n|---|---|\n| x | \\(\\alpha\\) | $\\beta$ |\n\n",
"Code `\\(not math\\)` stays raw.\n\n",
"```latex\n\\(also not\\)\n\\[block\\]\n```\n\n",
"Env \\begin{equation} x=1 \\end{equation} done.\n\n",
"Escaped \\\\(literal\\\\), price $5 and $10.\n",
"List:\n- item \\(p\\to q\\)\n- plain\n\n",
"> quote \\[E=mc^2\\]\n\n",
"## Heading \\(h=x^3\\)\n",
);
fn assert_split_invariant(doc: &str) {
let oneshot = norm(doc);
// 2-way: split at every char boundary.
for split in 0..=doc.len() {
if !doc.is_char_boundary(split) {
continue;
}
let mut nz = LatexDelimiterNormalizer::new();
let mut got = nz.push(&doc[..split]);
got.push_str(&nz.push(&doc[split..]));
got.push_str(&nz.finish());
assert_eq!(got, oneshot, "2-way split at byte {split}");
}
}
fn assert_char_by_char(doc: &str) {
let oneshot = norm(doc);
let mut nz = LatexDelimiterNormalizer::new();
let mut got = String::new();
for ch in doc.chars() {
got.push_str(&nz.push(ch.encode_utf8(&mut [0u8; 4])));
}
got.push_str(&nz.finish());
assert_eq!(got, oneshot, "char-by-char stream");
}
#[test]
fn streaming_matches_oneshot_all_splits() {
assert_split_invariant(RICH_DOC);
}
#[test]
fn streaming_matches_oneshot_char_by_char() {
assert_char_by_char(RICH_DOC);
}
#[test]
fn streaming_matches_oneshot_edge_fixtures() {
for doc in [
"\\(x\\)",
"\\[x\\]",
"\\begin{equation}z\\end{equation}",
"trailing backslash \\",
"ends with paren open \\(",
" ambiguous \\beg",
"backtick run at end ```",
" ",
"\\\\(escaped\\\\)",
"`unterminated \\(x\\)\nafter \\(y\\)",
// `Padded` inline spans exercise the look-ahead + trim hold-back.
"\\( x \\)",
"a \\( x+y \\) b",
"\\( \\alpha + \\beta \\)",
"\\( \\{x\\} \\)",
"\\( \\) empty",
// Unclosed `padded` open: held back until finish() flushes a lone `$`.
"unclosed padded \\( x + y",
// Display spans exercise the close-scan hold-back and its aborts.
"$$\nx\n=\ny\n$$",
"\\[\n\\boxed{ x\n=\ny }\n\\]",
"\\begin{equation}\na\n=\nb\n\\end{equation}",
"$$\nx\n\ny\n$$",
"> $$\n> x\n> $$",
"a $$ unclosed",
"$$$",
"trailing dollars $$",
"$$\r\nx\r\n$$",
"\\(a\n=\nb\\)",
"text $5 and $$ x $$ and $10",
] {
assert_split_invariant(doc);
assert_char_by_char(doc);
}
}
// finish() flushes held-back partials literally
#[test]
fn finish_flushes_partial_backslash() {
let mut nz = LatexDelimiterNormalizer::new();
let mut got = nz.push("a\\");
got.push_str(&nz.finish());
assert_eq!(got, "a\\");
}
#[test]
fn finish_flushes_partial_env() {
let mut nz = LatexDelimiterNormalizer::new();
let mut got = nz.push("x \\begin{eq");
got.push_str(&nz.finish());
assert_eq!(got, "x \\begin{eq");
}
#[test]
fn reset_clears_state() {
let mut nz = LatexDelimiterNormalizer::new();
let _ = nz.push("```\ncode \\(x\\)");
nz.reset();
// After reset we are back in Normal at line start.
let mut got = nz.push("\\(y\\)");
got.push_str(&nz.finish());
assert_eq!(got, "$y$");
}
#[test]
fn trailing_closing_backtick_held_until_finish_repro_auto_wake() {
let msg = "That was just a stale progress check finishing — no new work. \
The review is already complete at:\n\n\
`/tmp/project/results/report.html`";
let mut nz = LatexDelimiterNormalizer::new();
let pre_finish = nz.push(msg);
assert!(
!pre_finish.ends_with('`'),
"pre-finish source must hold back the trailing closer; got {:?}",
&pre_finish[pre_finish.len().saturating_sub(40)..]
);
assert!(
pre_finish.contains('`') && pre_finish.contains("/tmp/project/results"),
"opener + path should already be emitted"
);
let mut full = pre_finish;
full.push_str(&nz.finish());
assert!(
full.ends_with('`'),
"finish() must flush the held-back closing backtick"
);
assert_eq!(full, msg);
let mut nz = LatexDelimiterNormalizer::new();
let mut streamed = nz.push(&msg[..msg.len() - 1]);
streamed.push_str(&nz.push("`"));
assert!(
!streamed.ends_with('`') || streamed.matches('`').count() < 2,
"closing backtick still held after final chunk without finish(); got {:?}",
&streamed[streamed.len().saturating_sub(40)..]
);
streamed.push_str(&nz.finish());
assert_eq!(streamed, msg);
}
}
#[cfg(test)]
mod token_soup_stress {
use super::*;
/// Randomized delimiter-soup stress. Two invariants are universal and
/// pinned here for arbitrary input:
///
/// 1. the normalizer never panics;
/// 2. streaming char-by-char matches the one-shot output (chunk-split
/// invariance — what production streaming actually relies on).
///
/// Full byte-idempotency is deliberately *not* asserted on soup: a
/// conversion can glue a new `$$` out of adjacent tokens (e.g. `$` + an
/// unmatched `\)` → `$$`), which a second pass would then scan as a
/// display opener. Production normalizes exactly once per stream (the
/// streaming renderer's `clone()` re-appends already-normalized source
/// verbatim), and idempotency for realistic documents is pinned by the
/// `idempotent` test's curated list.
#[test]
fn token_soup_never_panics_and_streams_consistently() {
const TOKENS: [&str; 18] = [
"$$",
"$",
"\\[",
"\\]",
"\\(",
"\\)",
"\n",
"\n\n",
"=",
"-",
">",
"`",
"```",
"x y",
"\\begin{equation}",
"\\end{equation}",
"\\\\",
"\r\n",
];
// Simple deterministic LCG so failures are reproducible.
let mut state: u64 = 0x243F6A8885A308D3;
let mut next = move || {
state = state
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(state >> 33) as usize
};
for _ in 0..4000 {
let len = 1 + next() % 12;
let doc: String = (0..len).map(|_| TOKENS[next() % TOKENS.len()]).collect();
let oneshot = normalize_latex_delimiters(&doc);
// Char-by-char streaming must match one-shot.
let mut nz = LatexDelimiterNormalizer::new();
let mut got = String::new();
for ch in doc.chars() {
got.push_str(&nz.push(ch.encode_utf8(&mut [0u8; 4])));
}
got.push_str(&nz.finish());
assert_eq!(got, oneshot, "stream mismatch for {doc:?}");
}
}
}