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).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,532 @@
//! Core renderer: sequences, commands, scripts, fractions, accents.
use std::fmt::Write as _;
use super::cursor::Cursor;
use super::environments::render_environment;
use super::math_box::MathBox;
use super::symbols::{
map_mathbb, map_mathbf, map_mathcal, map_mathfrak, symbol, to_subscript, to_superscript,
};
use super::{MAX_DEPTH, Mode};
/// Render an atom's source to a flat (single-line) Unicode string.
///
/// Atoms are arguments to commands (fraction sides, script bodies, accent
/// targets); they always render flat — multi-row content inside them joins
/// with `; `.
pub(super) fn render_atom(atom: &str, depth: usize, mode: Mode) -> String {
let mut cursor = Cursor::new(atom);
let mut out = MathBox::new(true);
render_sequence(&mut cursor, &mut out, depth + 1, mode, None);
out.into_lines().concat()
}
/// Core renderer: walks `cursor`, appending Unicode to `out`.
///
/// `stop_at` optionally terminates the sequence at an unbalanced `}` (used
/// when rendering inside a group whose `{` was consumed by the caller).
pub(super) fn render_sequence(
cursor: &mut Cursor<'_>,
out: &mut MathBox,
depth: usize,
mode: Mode,
stop_at: Option<char>,
) {
while let Some(ch) = cursor.peek() {
if Some(ch) == stop_at {
cursor.bump();
return;
}
match ch {
'\\' => {
cursor.bump();
render_command(cursor, out, depth, mode);
}
'{' => {
cursor.bump();
if depth >= MAX_DEPTH {
// Too deep: render the group body flat, without recursing.
out.push_str(cursor.read_group_body());
} else {
// Render the group body into the same box so environments
// inside groups keep their 2D layout.
let body = cursor.read_group_body();
let mut sub = Cursor::new(body);
render_sequence(&mut sub, out, depth + 1, mode, None);
}
}
'}' => {
// Unbalanced closing brace: drop it.
cursor.bump();
}
'^' => {
cursor.bump();
render_script(cursor, out, depth, mode, Script::Super);
}
'_' => {
cursor.bump();
render_script(cursor, out, depth, mode, Script::Sub);
}
'~' => {
cursor.bump();
out.push(' ');
}
'&' => {
// Alignment marker outside an environment: drop.
cursor.bump();
}
'$' => {
// Stray math delimiter inside math: drop.
cursor.bump();
}
'-' if mode == Mode::Math => {
cursor.bump();
out.push('');
}
'\'' if mode == Mode::Math => {
cursor.bump();
out.push('');
}
c if c.is_whitespace() => {
cursor.skip_ws();
// TeX collapses whitespace runs (including newlines) to
// nothing semantically; keep a single space for readability.
if !out.at_line_start() && !out.ends_with_space() {
out.push(' ');
}
}
c => {
cursor.bump();
out.push(c);
}
}
}
}
/// Which script position is being rendered.
#[derive(Copy, Clone, PartialEq, Eq)]
enum Script {
Super,
Sub,
}
/// Render `^atom` / `_atom` using Unicode script chars when every char of
/// the rendered atom has a script form; otherwise `^x` / `^(...)` fallback.
///
/// Word-like atoms take the fallback even when fully mappable: labels such as
/// `p_{\text{torso}}` or `x_{max}` would otherwise become long modifier-letter
/// runs (`pₜₒᵣₛₒ`) that are hard to read and render with visible gaps in
/// terminal fonts lacking those glyphs. Index-like atoms (`x_{ij}`,
/// `T_{i+1}`, `n^{th}`) keep the compact Unicode form.
fn render_script(
cursor: &mut Cursor<'_>,
out: &mut MathBox,
depth: usize,
mode: Mode,
kind: Script,
) {
let Some(atom) = cursor.read_atom() else {
out.push(match kind {
Script::Super => '^',
Script::Sub => '_',
});
return;
};
let rendered = render_atom(atom, depth, mode);
let mapped: Option<String> = if script_atom_is_wordlike(atom, &rendered) {
None
} else {
rendered
.chars()
.map(|c| match kind {
Script::Super => to_superscript(c),
Script::Sub => to_subscript(c),
})
.collect()
};
match mapped {
Some(s) if !s.is_empty() => out.push_str(&s),
_ => {
out.push(match kind {
Script::Super => '^',
Script::Sub => '_',
});
if rendered.chars().count() > 1 {
let _ = write!(out, "({rendered})");
} else {
out.push_str(&rendered);
}
}
}
}
/// `true` if a script atom is a word-like label rather than indices.
///
/// Two signals, checked on the atom *source* and its rendered form:
///
/// - the source routes through a text-family command (`\text{…}`, `\mathrm{…}`,
/// `\operatorname{…}`, …): the author explicitly marked the content as a
/// word;
/// - the rendered form contains a run of 3+ ASCII letters: multi-letter runs
/// read as words (`max`, `torso`), while 12 letter runs are index
/// juxtapositions (`ij`, `th`) that stay compact.
fn script_atom_is_wordlike(atom: &str, rendered: &str) -> bool {
// `\text` also catches `\textrm`/`\textbf`/`\textit`/`\textsf`/`\texttt`/
// `\textnormal` by prefix; `\math…` variants and box commands likewise.
const TEXT_MARKERS: [&str; 8] = [
"\\text",
"\\mathrm",
"\\mathsf",
"\\mathtt",
"\\mathit",
"\\operatorname",
"\\mbox",
"\\hbox",
];
if TEXT_MARKERS.iter().any(|m| atom.contains(m)) {
return true;
}
let mut run = 0usize;
for c in rendered.chars() {
if c.is_ascii_alphabetic() {
run += 1;
if run >= 3 {
return true;
}
} else {
run = 0;
}
}
false
}
/// Render a `\command` whose backslash was already consumed.
fn render_command(cursor: &mut Cursor<'_>, out: &mut MathBox, depth: usize, mode: Mode) {
let name = cursor.read_command_name();
match name {
// ── Structure ────────────────────────────────────────────────────
"" => out.push('\\'),
"\\" => out.push('\n'),
"begin" => render_environment(cursor, out, depth, mode),
"end" => {
// Stray \end without matching \begin: drop its argument.
let _ = take_brace_arg(cursor);
}
"left" | "right" => {
// Keep the delimiter that follows; `.` means "no delimiter".
cursor.skip_ws();
match cursor.peek() {
Some('.') => {
cursor.bump();
}
Some('\\') => {
cursor.bump();
render_command(cursor, out, depth, mode);
}
Some(c) => {
cursor.bump();
out.push(c);
}
None => {}
}
}
// ── Fractions / binomials / roots ────────────────────────────────
"frac" | "dfrac" | "tfrac" | "cfrac" => {
let num = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
let den = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
match (num, den) {
(Some(n), Some(d)) => out.push_str(&format_fraction(&n, &d)),
(Some(n), None) => out.push_str(&n),
_ => {}
}
}
"binom" | "tbinom" | "dbinom" => {
let n = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
let k = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
if let (Some(n), Some(k)) = (n, k) {
let _ = write!(out, "C({n}, {k})");
}
}
"sqrt" => {
cursor.skip_ws();
let index = if cursor.peek() == Some('[') {
cursor.bump();
let start = cursor.pos;
while let Some(c) = cursor.peek() {
if c == ']' {
break;
}
cursor.bump();
}
let idx = &cursor.src[start..cursor.pos];
cursor.bump(); // consume `]`
Some(render_atom(idx, depth, mode))
} else {
None
};
let radical = match index.as_deref() {
None | Some("2") => "",
Some("3") => "",
Some("4") => "",
Some(other) => {
// ⁿ√ style prefix for other indices.
let sup: Option<String> = other.chars().map(to_superscript).collect();
out.push_str(&sup.unwrap_or_else(|| format!("({other})")));
""
}
};
out.push_str(radical);
if let Some(arg) = cursor.read_atom() {
let rendered = render_atom(arg, depth, mode);
// Parenthesize any multi-char radicand: `√ab` would read as
// `(√a)b`.
if rendered.chars().count() > 1 {
let _ = write!(out, "({rendered})");
} else {
out.push_str(&rendered);
}
}
}
// ── Boxes (frame dropped; content preserved) ─────────────────────
"boxed" => {
if let Some(arg) = take_brace_arg(cursor) {
out.push_str(&render_atom(arg, depth, mode));
}
}
"fbox" | "framebox" => {
if let Some(arg) = take_brace_arg(cursor) {
out.push_str(&render_atom(arg, depth, Mode::Text));
}
}
// ── Text / alphabets ─────────────────────────────────────────────
"text" | "textrm" | "textit" | "textbf" | "textsf" | "texttt" | "textnormal" | "mbox"
| "hbox" => {
if let Some(arg) = take_brace_arg(cursor) {
out.push_str(&render_atom(arg, depth, Mode::Text));
}
}
"mathrm" | "operatorname" | "mathit" | "mathsf" | "mathtt" | "mathnormal" => {
if let Some(arg) = take_brace_arg(cursor) {
out.push_str(&render_atom(arg, depth, Mode::Text));
}
}
"mathbb" => render_mapped_alphabet(cursor, out, depth, mode, map_mathbb),
"mathcal" | "mathscr" => render_mapped_alphabet(cursor, out, depth, mode, map_mathcal),
"mathfrak" => render_mapped_alphabet(cursor, out, depth, mode, map_mathfrak),
"mathbf" | "boldsymbol" | "bm" | "bold" => {
render_mapped_alphabet(cursor, out, depth, mode, map_mathbf)
}
// ── Accents (combining marks) ────────────────────────────────────
"hat" | "widehat" => render_accent(cursor, out, depth, mode, '\u{0302}'),
"bar" | "overline" => render_accent(cursor, out, depth, mode, '\u{0304}'),
"tilde" | "widetilde" => render_accent(cursor, out, depth, mode, '\u{0303}'),
"vec" => render_accent(cursor, out, depth, mode, '\u{20D7}'),
"dot" => render_accent(cursor, out, depth, mode, '\u{0307}'),
"ddot" => render_accent(cursor, out, depth, mode, '\u{0308}'),
"check" => render_accent(cursor, out, depth, mode, '\u{030C}'),
"breve" => render_accent(cursor, out, depth, mode, '\u{0306}'),
"acute" => render_accent(cursor, out, depth, mode, '\u{0301}'),
"grave" => render_accent(cursor, out, depth, mode, '\u{0300}'),
"mathring" => render_accent(cursor, out, depth, mode, '\u{030A}'),
"underline" => render_accent(cursor, out, depth, mode, '\u{0332}'),
// ── Negation ─────────────────────────────────────────────────────
"not" => {
if let Some(atom) = cursor.read_atom() {
let rendered = render_atom(atom, depth, mode);
match rendered.as_str() {
"" => out.push('∉'),
"=" => out.push('≠'),
"<" => out.push('≮'),
">" => out.push('≯'),
"" => out.push('≢'),
"" => out.push('⊄'),
"" => out.push('⊈'),
"" => out.push('∄'),
other => {
out.push_str(other);
// Combining long solidus overlay on the last char.
if !other.is_empty() {
out.push('\u{0338}');
}
}
}
}
}
// ── Decorations rendered as base + script ────────────────────────
"overset" | "stackrel" => {
let over = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
let base = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
if let (Some(over), Some(base)) = (over, base) {
out.push_str(&base);
let sup: Option<String> = over.chars().map(to_superscript).collect();
match sup {
Some(s) if !s.is_empty() => out.push_str(&s),
_ => {}
}
}
}
"underset" => {
let under = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
let base = take_brace_arg(cursor).map(|a| render_atom(a, depth, mode));
if let (Some(under), Some(base)) = (under, base) {
out.push_str(&base);
let sub: Option<String> = under.chars().map(to_subscript).collect();
match sub {
Some(s) if !s.is_empty() => out.push_str(&s),
_ => {}
}
}
}
// ── Modular arithmetic ───────────────────────────────────────────
"pmod" => {
if let Some(arg) = take_brace_arg(cursor) {
if !out.at_line_start() && !out.ends_with_space() {
out.push(' ');
}
let _ = write!(out, "(mod {})", render_atom(arg, depth, mode));
}
}
"bmod" => {
if !out.at_line_start() && !out.ends_with_space() {
out.push(' ');
}
out.push_str("mod ");
}
// ── Spacing ──────────────────────────────────────────────────────
"," | ";" | ":" | ">" | " " | "space" | "thinspace" | "medspace" | "thickspace"
| "enspace" => {
if !out.at_line_start() && !out.ends_with_space() {
out.push(' ');
}
}
"quad" => out.push_str(" "),
"qquad" => out.push_str(" "),
"!" | "negthinspace" | "negmedspace" | "negthickspace" => {}
// ── No-ops (sizing/styling/structure hints) ──────────────────────
"limits" | "nolimits" | "displaystyle" | "textstyle" | "scriptstyle"
| "scriptscriptstyle" | "big" | "Big" | "bigg" | "Bigg" | "bigl" | "Bigl" | "biggl"
| "Biggl" | "bigr" | "Bigr" | "biggr" | "Biggr" | "bigm" | "Bigm" | "biggm" | "Biggm"
| "mathstrut" | "strut" | "allowbreak" | "nonumber" | "notag" | "mathopen"
| "mathclose" | "mathbin" | "mathrel" | "mathord" | "mathpunct" | "mathinner"
| "mathop" | "ensuremath" | "label" | "tag" => {
// \label/\tag carry non-visual arguments: drop them.
if matches!(name, "label" | "tag") {
let _ = take_brace_arg(cursor);
}
}
// ── Symbol table ─────────────────────────────────────────────────
_ => {
if let Some(sym) = symbol(name) {
out.push_str(sym);
} else {
// Unknown command: keep its name as plain text.
out.push_str(name);
}
}
}
}
/// Consume `{...}` (after optional whitespace) and return the body source.
pub(super) fn take_brace_arg<'a>(cursor: &mut Cursor<'a>) -> Option<&'a str> {
cursor.skip_ws();
if cursor.peek() == Some('{') {
cursor.bump();
Some(cursor.read_group_body())
} else {
None
}
}
/// `true` if a fraction/root operand needs parentheses for readability.
fn needs_parens(s: &str) -> bool {
s.chars().count() > 1 && s.contains([' ', '+', '', '-', '=', '/'])
}
/// Format `num/den`, mapping common numeric fractions to vulgar fractions.
fn format_fraction(num: &str, den: &str) -> String {
let vulgar = match (num, den) {
("1", "2") => Some('½'),
("1", "3") => Some('⅓'),
("2", "3") => Some('⅔'),
("1", "4") => Some('¼'),
("3", "4") => Some('¾'),
("1", "5") => Some('⅕'),
("2", "5") => Some('⅖'),
("3", "5") => Some('⅗'),
("4", "5") => Some('⅘'),
("1", "6") => Some('⅙'),
("5", "6") => Some('⅚'),
("1", "7") => Some('⅐'),
("1", "8") => Some('⅛'),
("3", "8") => Some('⅜'),
("5", "8") => Some('⅝'),
("7", "8") => Some('⅞'),
("1", "9") => Some('⅑'),
("1", "10") => Some('⅒'),
_ => None,
};
if let Some(v) = vulgar {
return v.to_string();
}
let n = if needs_parens(num) {
format!("({num})")
} else {
num.to_string()
};
let d = if needs_parens(den) {
format!("({den})")
} else {
den.to_string()
};
format!("{n}/{d}")
}
/// Render an alphabet-mapping command (`\mathbb{R}` etc.): map chars that
/// have a styled form, keep the rest as rendered.
fn render_mapped_alphabet(
cursor: &mut Cursor<'_>,
out: &mut MathBox,
depth: usize,
mode: Mode,
map: fn(char) -> Option<char>,
) {
let Some(atom) = cursor.read_atom() else {
return;
};
let rendered = render_atom(atom, depth, mode);
for c in rendered.chars() {
out.push(map(c).unwrap_or(c));
}
}
/// Render an accent command by appending a combining mark to each char of
/// the argument.
fn render_accent(
cursor: &mut Cursor<'_>,
out: &mut MathBox,
depth: usize,
mode: Mode,
combining: char,
) {
let Some(atom) = cursor.read_atom() else {
return;
};
let rendered = render_atom(atom, depth, mode);
for c in rendered.chars() {
out.push(c);
if !c.is_whitespace() {
out.push(combining);
}
}
}
@@ -0,0 +1,99 @@
//! Byte cursor over TeX source.
/// Byte cursor over the TeX source.
pub(super) struct Cursor<'a> {
pub(super) src: &'a str,
pub(super) pos: usize,
}
impl<'a> Cursor<'a> {
pub(super) fn new(src: &'a str) -> Self {
Self { src, pos: 0 }
}
pub(super) fn peek(&self) -> Option<char> {
self.src[self.pos..].chars().next()
}
pub(super) fn bump(&mut self) -> Option<char> {
let ch = self.peek()?;
self.pos += ch.len_utf8();
Some(ch)
}
/// Consume `\command` (alphabetic name) or `\<single char>`; the leading
/// backslash must already be consumed. Returns the command name.
///
/// Unlike TeX we do NOT consume trailing whitespace: the caller's
/// whitespace collapsing keeps `\to 0` rendering as `→ 0`.
pub(super) fn read_command_name(&mut self) -> &'a str {
let start = self.pos;
match self.peek() {
Some(c) if c.is_ascii_alphabetic() => {
while matches!(self.peek(), Some(c) if c.is_ascii_alphabetic()) {
self.bump();
}
&self.src[start..self.pos]
}
Some(_) => {
self.bump();
&self.src[start..self.pos]
}
None => "",
}
}
/// Skip whitespace (TeX collapses it; meaning comes from commands).
pub(super) fn skip_ws(&mut self) {
while matches!(self.peek(), Some(c) if c.is_whitespace()) {
self.bump();
}
}
/// Read a balanced `{...}` group body, assuming `{` was already consumed.
/// Returns the inner source (without braces). Unbalanced input returns
/// the remainder of the source.
pub(super) fn read_group_body(&mut self) -> &'a str {
let start = self.pos;
let mut depth = 1usize;
while let Some(ch) = self.bump() {
match ch {
'\\' => {
// Skip escaped char so `\{`/`\}` don't affect depth.
self.bump();
}
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return &self.src[start..self.pos - 1];
}
}
_ => {}
}
}
&self.src[start..self.pos]
}
/// Read the next "atom": a `{...}` group body, a `\command` (returned
/// with backslash), or a single char. Skips leading whitespace.
pub(super) fn read_atom(&mut self) -> Option<&'a str> {
self.skip_ws();
let start = self.pos;
match self.peek()? {
'{' => {
self.bump();
Some(self.read_group_body())
}
'\\' => {
self.bump();
self.read_command_name();
Some(&self.src[start..self.pos])
}
_ => {
self.bump();
Some(&self.src[start..self.pos])
}
}
}
}
@@ -0,0 +1,325 @@
//! `\\begin{env}...\\end{env}` environments: matrices, cases, alignments.
use crate::buffers::unicode_display_width;
use super::Mode;
use super::commands::{render_atom, take_brace_arg};
use super::cursor::Cursor;
use super::math_box::MathBox;
/// Render `\begin{env}...\end{env}`. The `\begin` name was already consumed.
pub(super) fn render_environment(
cursor: &mut Cursor<'_>,
out: &mut MathBox,
depth: usize,
mode: Mode,
) {
let Some(env_name) = take_brace_arg(cursor) else {
return;
};
let env_name = env_name.trim().trim_end_matches('*');
// Capture body source until the matching `\end{name}`, tracking nesting
// of same-named environments. Scans raw source from the cursor.
let body_start = cursor.pos;
let mut body_end = cursor.src.len();
let mut resume = cursor.src.len();
let mut nest = 0usize;
let mut search = cursor.pos;
while search < cursor.src.len() {
let rest = &cursor.src[search..];
let Some(rel) = rest.find('\\') else {
break;
};
let bs_pos = search + rel;
let after_bs = &cursor.src[bs_pos + 1..];
let kw_len = if command_at(after_bs, "begin") {
"begin".len()
} else if command_at(after_bs, "end") {
"end".len()
} else {
// Not begin/end: skip the backslash and the char after it (so
// `\\` and `\{` never confuse the scan).
let skip = after_bs.chars().next().map_or(0, char::len_utf8);
search = bs_pos + 1 + skip.max(1);
continue;
};
let is_begin = kw_len == "begin".len();
let mut probe = Cursor {
src: cursor.src,
pos: bs_pos + 1 + kw_len,
};
let arg = take_brace_arg(&mut probe).map(|a| a.trim().trim_end_matches('*'));
if arg == Some(env_name) {
if is_begin {
nest += 1;
} else if nest == 0 {
body_end = bs_pos;
resume = probe.pos;
break;
} else {
nest -= 1;
}
}
search = probe.pos.max(bs_pos + 1 + kw_len);
}
cursor.pos = resume;
let mut body = &cursor.src[body_start..body_end.min(cursor.src.len())];
// Optional column spec for array environments: `\begin{array}{ll}`.
if env_name == "array" || env_name == "alignat" {
let mut probe = Cursor::new(body);
probe.skip_ws();
if probe.peek() == Some('{') {
probe.bump();
let _ = probe.read_group_body();
body = &body[probe.pos..];
}
}
let rows = env_rows_to_strings(body, env_name, out.flat, depth, mode);
out.hcat_rows(rows);
}
/// `true` if `rest` starts with command word `word` NOT followed by another
/// ASCII letter (so `\endx` is not mistaken for `\end`).
fn command_at(rest: &str, word: &str) -> bool {
rest.starts_with(word)
&& !rest[word.len()..]
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
}
/// Split an environment body into rows (`\\`) and cells (`&`) at brace and
/// environment depth 0, render each cell, then lay the rows out according to
/// the environment. Returns one string per visual row; the caller attaches
/// them as a box. In `flat` mode, matrix/cases environments render as a
/// single row with `; ` between matrix rows.
fn env_rows_to_strings(
body: &str,
env_name: &str,
flat: bool,
depth: usize,
mode: Mode,
) -> Vec<String> {
let mut rows: Vec<Vec<String>> = Vec::new();
let mut row: Vec<String> = Vec::new();
let mut cell_start = 0usize;
let mut brace_depth = 0usize;
let mut env_depth = 0usize;
let bytes = body.as_bytes();
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'\\' => {
if bytes.get(i + 1) == Some(&b'\\') {
if brace_depth == 0 && env_depth == 0 {
row.push(body[cell_start..i].to_string());
rows.push(std::mem::take(&mut row));
i += 2;
cell_start = i;
continue;
}
i += 2;
continue;
}
let rest = &body[i + 1..];
if command_at(rest, "begin") {
env_depth += 1;
} else if command_at(rest, "end") {
env_depth = env_depth.saturating_sub(1);
}
// Skip the backslash plus the char after it so escaped
// delimiters (`\&`, `\{`, `\}`) never affect depth/splits.
let skip = rest.chars().next().map_or(0, char::len_utf8);
i += 1 + skip.max(1);
continue;
}
b'{' => brace_depth += 1,
b'}' => brace_depth = brace_depth.saturating_sub(1),
b'&' if brace_depth == 0 && env_depth == 0 => {
row.push(body[cell_start..i].to_string());
cell_start = i + 1;
}
_ => {}
}
i += 1;
}
row.push(body[cell_start.min(bytes.len())..].to_string());
rows.push(row);
// Render each cell, drop fully-empty rows.
let mut rendered_rows: Vec<Vec<String>> = rows
.into_iter()
.map(|cells| {
cells
.into_iter()
.map(|c| render_atom(c.trim(), depth, mode).trim().to_string())
.collect::<Vec<_>>()
})
.collect();
rendered_rows.retain(|cells| cells.iter().any(|c| !c.is_empty()));
if rendered_rows.is_empty() {
return Vec::new();
}
let is_matrix = matches!(
env_name,
"matrix"
| "pmatrix"
| "bmatrix"
| "Bmatrix"
| "vmatrix"
| "Vmatrix"
| "smallmatrix"
| "array"
);
let n_rows = rendered_rows.len();
if is_matrix {
// Flat (inline) mode: one row, single delimiter pair, rows joined
// with `; ` — `(1 2; 3 4)`.
if flat {
let inner = rendered_rows
.iter()
.map(|cells| cells.join(" "))
.collect::<Vec<_>>()
.join("; ");
// Single-row delimiter pair; plain `matrix` has none (' ').
let (l, r) = matrix_delims(env_name, 0, 1);
let mut s = String::new();
if l != ' ' {
s.push(l);
}
s.push_str(&inner);
if r != ' ' {
s.push(r);
}
return vec![s];
}
// Pad columns to equal width so rows align.
let n_cols = rendered_rows.iter().map(Vec::len).max().unwrap_or(0);
let mut widths = vec![0usize; n_cols];
for cells in &rendered_rows {
for (i, cell) in cells.iter().enumerate() {
widths[i] = widths[i].max(unicode_display_width(cell));
}
}
rendered_rows
.iter()
.enumerate()
.map(|(row_idx, cells)| {
let mut content = String::new();
for (i, cell) in cells.iter().enumerate() {
if i > 0 {
content.push_str(" ");
}
content.push_str(cell);
if i + 1 < cells.len() {
let pad = widths[i].saturating_sub(unicode_display_width(cell));
content.push_str(&" ".repeat(pad));
}
}
let (l, r) = matrix_delims(env_name, row_idx, n_rows);
format!("{l}{content}{r}")
})
.collect()
} else if env_name == "cases" {
if flat {
let inner = rendered_rows
.iter()
.map(|cells| cells.join(" "))
.collect::<Vec<_>>()
.join("; ");
return vec![format!("{{{inner}}}")];
}
rendered_rows
.iter()
.enumerate()
.map(|(row_idx, cells)| {
let brace = cases_brace(row_idx, n_rows);
format!("{brace} {}", cells.join(" "))
})
.collect()
} else {
// aligned/align/gather/split/equation/…: `&` is an invisible
// alignment marker; rejoin cells with a single space. One string per
// row; the caller's box attachment (or flat `; ` join) handles the
// rest.
rendered_rows
.iter()
.map(|cells| {
let mut s = cells
.iter()
.filter(|c| !c.is_empty())
.cloned()
.collect::<Vec<_>>()
.join(" ");
// Collapse any double spaces introduced around markers.
while s.contains(" ") {
s = s.replace(" ", " ");
}
s
})
.collect()
}
}
/// Per-row delimiters for matrix-family environments.
fn matrix_delims(env: &str, row: usize, n_rows: usize) -> (char, char) {
let single = n_rows == 1;
let first = row == 0;
let last = row + 1 == n_rows;
match env {
"pmatrix" => {
if single {
('(', ')')
} else if first {
('⎛', '⎞')
} else if last {
('⎝', '⎠')
} else {
('⎜', '⎟')
}
}
"bmatrix" | "array" => {
if single {
('[', ']')
} else if first {
('⎡', '⎤')
} else if last {
('⎣', '⎦')
} else {
('⎢', '⎥')
}
}
"Bmatrix" => {
if single {
('{', '}')
} else if first {
('⎧', '⎫')
} else if last {
('⎩', '⎭')
} else {
('⎨', '⎬')
}
}
"vmatrix" | "Vmatrix" => ('│', '│'),
_ => (' ', ' '),
}
}
/// Left-brace column char for `cases` rows.
fn cases_brace(row: usize, n_rows: usize) -> char {
if n_rows == 1 {
'{'
} else if row == 0 {
'⎧'
} else if row + 1 == n_rows {
'⎩'
} else if row == n_rows / 2 {
'⎨'
} else {
'⎪'
}
}
@@ -0,0 +1,156 @@
//! Two-dimensional math layout box.
use crate::buffers::unicode_display_width;
/// Two-dimensional text box with an anchor row where horizontal flow
/// attaches.
///
/// Multi-row content (matrix-family environments) extends above/below the
/// anchor row; subsequent output continues on the anchor row. This keeps a
/// prefix, a matrix, and a suffix aligned:
///
/// ```text
/// A = ⎛1 2⎞, det(A) = 2
/// ⎝3 4⎠
/// ```
pub(super) struct MathBox {
lines: Vec<String>,
/// Row index that horizontal flow currently appends to.
anchor: usize,
/// First row belonging to the current visual line. Rows before `floor`
/// are completed lines from earlier `\\` breaks and must never be
/// touched by box attachment.
floor: usize,
/// Flat mode (inline math): vertical layout is impossible, so row breaks
/// render as `; ` and environments render single-row.
pub(super) flat: bool,
}
impl MathBox {
pub(super) fn new(flat: bool) -> Self {
Self {
lines: vec![String::new()],
anchor: 0,
floor: 0,
flat,
}
}
fn cur(&mut self) -> &mut String {
&mut self.lines[self.anchor]
}
/// `true` when nothing has been emitted on the current flow row yet.
pub(super) fn at_line_start(&self) -> bool {
self.lines[self.anchor].is_empty()
}
pub(super) fn ends_with_space(&self) -> bool {
self.lines[self.anchor].ends_with(' ')
}
pub(super) fn push(&mut self, c: char) {
if c == '\n' {
self.vbreak();
} else {
self.cur().push(c);
}
}
pub(super) fn push_str(&mut self, s: &str) {
if s.contains('\n') {
self.hcat_rows(s.split('\n').map(str::to_string).collect());
} else {
self.cur().push_str(s);
}
}
/// End the current visual line; flow continues on a fresh row below all
/// existing rows. Flat mode renders the break as `; `.
fn vbreak(&mut self) {
if self.flat {
if !self.at_line_start() {
let cur = self.cur();
while cur.ends_with(' ') {
cur.pop();
}
cur.push_str("; ");
}
} else {
self.lines.push(String::new());
self.anchor = self.lines.len() - 1;
self.floor = self.anchor;
}
}
/// Attach `rows` as a box at the current flow position, anchored at the
/// box's upper-middle row. All box rows start at the same column; flow
/// resumes on the anchor row past the box's widest row.
pub(super) fn hcat_rows(&mut self, rows: Vec<String>) {
if rows.is_empty() {
return;
}
if self.flat || rows.len() == 1 {
for (i, row) in rows.iter().enumerate() {
if i > 0 {
self.vbreak();
}
self.cur().push_str(row);
}
return;
}
let box_anchor = (rows.len() - 1) / 2;
let attach_col = unicode_display_width(&self.lines[self.anchor]);
let box_width = rows
.iter()
.map(|r| unicode_display_width(r))
.max()
.unwrap_or(0);
// Ensure enough rows above the anchor within the current visual line.
let have_above = self.anchor - self.floor;
if box_anchor > have_above {
let add = box_anchor - have_above;
for _ in 0..add {
self.lines.insert(self.floor, String::new());
}
self.anchor += add;
}
// Ensure enough rows below the anchor.
let below = rows.len() - box_anchor - 1;
let have_below = self.lines.len() - self.anchor - 1;
if below > have_below {
for _ in 0..(below - have_below) {
self.lines.push(String::new());
}
}
// Place the box rows, left-padded to the attach column.
for (i, row) in rows.iter().enumerate() {
let target = self.anchor - box_anchor + i;
let line = &mut self.lines[target];
let cur_w = unicode_display_width(line);
if cur_w < attach_col {
line.push_str(&" ".repeat(attach_col - cur_w));
}
line.push_str(row);
}
// Flow resumes past the box's widest row.
let frontier = attach_col + box_width;
let cur_w = unicode_display_width(&self.lines[self.anchor]);
if cur_w < frontier {
let pad = frontier - cur_w;
self.lines[self.anchor].push_str(&" ".repeat(pad));
}
}
pub(super) fn into_lines(self) -> Vec<String> {
self.lines
}
}
impl std::fmt::Write for MathBox {
fn write_str(&mut self, s: &str) -> std::fmt::Result {
self.push_str(s);
Ok(())
}
}
@@ -0,0 +1,96 @@
//! Best-effort LaTeX math → Unicode plain-text conversion.
//!
//! Converts TeX math source (the content of `$...$`, `$$...$$`, `\(...\)`,
//! `\[...\]`) into a readable Unicode approximation for terminal display:
//!
//! - Greek letters and symbol commands (`\alpha` → `α`, `\le` → `≤`, …)
//! - Superscripts/subscripts via Unicode script characters (`x^2` → `x²`,
//! `a_1` → `a₁`) with `^(...)`/`_(...)` fallback when a char has no
//! Unicode script form
//! - Fractions (`\frac{1}{2}` → `½`, `\frac{a+b}{c}` → `(a+b)/c`)
//! - Roots (`\sqrt{x}` → `√x`, `\sqrt[3]{x}` → `∛x`)
//! - Alphabets (`\mathbb{R}` → ``, `\mathcal{L}` → ``, `\mathbf{v}` → `𝐯`)
//! - Accents via combining marks (`\hat{x}` → `x̂`, `\vec{v}` → `v⃗`)
//! - Environments (`aligned`, `cases`, `pmatrix`, …) → multi-line layout
//!
//! The converter is total: it never panics and always produces *some* output
//! (unknown commands degrade to their bare name). Callers decide whether to
//! use the conversion or fall back to raw TeX source.
mod commands;
mod cursor;
mod environments;
mod math_box;
mod symbols;
#[cfg(test)]
mod tests;
use commands::render_sequence;
use cursor::Cursor;
use math_box::MathBox;
/// Inputs larger than this are not converted (callers fall back to raw
/// display). Guards the streaming hot path: the tail is re-rendered on every
/// chunk, so conversion cost must stay trivially small.
pub(crate) const MAX_MATH_SOURCE_LEN: usize = 4096;
/// Hard cap on group-nesting recursion. Inputs deeper than this render their
/// remaining content flatly rather than recursing further.
const MAX_DEPTH: usize = 32;
/// Convert inline math to a single-line Unicode string.
///
/// Row separators (`\\`) collapse to `; ` and multi-row environments render
/// single-row, so inline math never introduces a line break mid-paragraph.
/// Returns `None` when the source is too large to convert (see
/// [`MAX_MATH_SOURCE_LEN`]).
pub(crate) fn latex_to_unicode_inline(src: &str) -> Option<String> {
if src.len() > MAX_MATH_SOURCE_LEN {
return None;
}
let lines = convert(src, true);
let joined = lines
.iter()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect::<Vec<_>>()
.join("; ");
Some(joined)
}
/// Convert display math to one or more Unicode lines.
///
/// Lines come from `\\` row separators and multi-row environments, which lay
/// out as 2D boxes anchored to the surrounding flow (see [`MathBox`]).
/// Leading whitespace is structural (box alignment) and preserved; only line
/// ends are trimmed. Returns `None` when the source is too large to convert,
/// and an empty `Vec` when the math has no visible content (callers should
/// fall back in both cases).
pub(crate) fn latex_to_unicode_display(src: &str) -> Option<Vec<String>> {
if src.len() > MAX_MATH_SOURCE_LEN {
return None;
}
let lines: Vec<String> = convert(src, false)
.into_iter()
.map(|l| l.trim_end().to_string())
.filter(|l| !l.is_empty())
.collect();
Some(lines)
}
/// Run the converter and return the output lines.
fn convert(src: &str, flat: bool) -> Vec<String> {
let mut cursor = Cursor::new(src);
let mut out = MathBox::new(flat);
render_sequence(&mut cursor, &mut out, 0, Mode::Math, None);
out.into_lines()
}
/// Rendering mode: math mode applies typographic substitutions (`-` → ``,
/// `'` → ``) that text fragments (`\text{...}`) must not receive.
#[derive(Copy, Clone, PartialEq, Eq)]
enum Mode {
Math,
Text,
}
@@ -0,0 +1,412 @@
//! Character and symbol mapping tables.
pub(super) fn to_superscript(c: char) -> Option<char> {
Some(match c {
'0' => '⁰',
'1' => '¹',
'2' => '²',
'3' => '³',
'4' => '⁴',
'5' => '⁵',
'6' => '⁶',
'7' => '⁷',
'8' => '⁸',
'9' => '⁹',
'+' => '⁺',
'-' | '' => '⁻',
'=' => '⁼',
'(' => '⁽',
')' => '⁾',
'a' => 'ᵃ',
'b' => 'ᵇ',
'c' => 'ᶜ',
'd' => 'ᵈ',
'e' => 'ᵉ',
'f' => 'ᶠ',
'g' => 'ᵍ',
'h' => 'ʰ',
'i' => 'ⁱ',
'j' => 'ʲ',
'k' => 'ᵏ',
'l' => 'ˡ',
'm' => 'ᵐ',
'n' => 'ⁿ',
'o' => 'ᵒ',
'p' => 'ᵖ',
'r' => 'ʳ',
's' => 'ˢ',
't' => 'ᵗ',
'u' => 'ᵘ',
'v' => 'ᵛ',
'w' => 'ʷ',
'x' => 'ˣ',
'y' => 'ʸ',
'z' => 'ᶻ',
'T' => 'ᵀ',
'' | '*' => '*',
'' | '\'' => '',
' ' => ' ',
_ => return None,
})
}
pub(super) fn to_subscript(c: char) -> Option<char> {
Some(match c {
'0' => '₀',
'1' => '₁',
'2' => '₂',
'3' => '₃',
'4' => '₄',
'5' => '₅',
'6' => '₆',
'7' => '₇',
'8' => '₈',
'9' => '₉',
'+' => '₊',
'-' | '' => '₋',
'=' => '₌',
'(' => '₍',
')' => '₎',
'a' => 'ₐ',
'e' => 'ₑ',
'h' => 'ₕ',
'i' => 'ᵢ',
'j' => 'ⱼ',
'k' => 'ₖ',
'l' => 'ₗ',
'm' => 'ₘ',
'n' => 'ₙ',
'o' => 'ₒ',
'p' => 'ₚ',
'r' => 'ᵣ',
's' => 'ₛ',
't' => 'ₜ',
'u' => 'ᵤ',
'v' => 'ᵥ',
'x' => 'ₓ',
' ' => ' ',
_ => return None,
})
}
pub(super) fn map_mathbb(c: char) -> Option<char> {
Some(match c {
'C' => '',
'H' => '',
'N' => '',
'P' => '',
'Q' => '',
'R' => '',
'Z' => '',
'A'..='Z' => char::from_u32(0x1D538 + (c as u32 - 'A' as u32))?,
'a'..='z' => char::from_u32(0x1D552 + (c as u32 - 'a' as u32))?,
'0'..='9' => char::from_u32(0x1D7D8 + (c as u32 - '0' as u32))?,
_ => return None,
})
}
pub(super) fn map_mathcal(c: char) -> Option<char> {
Some(match c {
'B' => '',
'E' => '',
'F' => '',
'H' => '',
'I' => '',
'L' => '',
'M' => '',
'R' => '',
'e' => '',
'g' => '',
'o' => '',
'A'..='Z' => char::from_u32(0x1D49C + (c as u32 - 'A' as u32))?,
'a'..='z' => char::from_u32(0x1D4B6 + (c as u32 - 'a' as u32))?,
_ => return None,
})
}
pub(super) fn map_mathfrak(c: char) -> Option<char> {
Some(match c {
'C' => '',
'H' => '',
'I' => '',
'R' => '',
'Z' => '',
'A'..='Z' => char::from_u32(0x1D504 + (c as u32 - 'A' as u32))?,
'a'..='z' => char::from_u32(0x1D51E + (c as u32 - 'a' as u32))?,
_ => return None,
})
}
pub(super) fn map_mathbf(c: char) -> Option<char> {
Some(match c {
'A'..='Z' => char::from_u32(0x1D400 + (c as u32 - 'A' as u32))?,
'a'..='z' => char::from_u32(0x1D41A + (c as u32 - 'a' as u32))?,
'0'..='9' => char::from_u32(0x1D7CE + (c as u32 - '0' as u32))?,
_ => return None,
})
}
/// Symbol command table (commands with no arguments).
pub(super) fn symbol(name: &str) -> Option<&'static str> {
Some(match name {
// Greek lowercase
"alpha" => "α",
"beta" => "β",
"gamma" => "γ",
"delta" => "δ",
"epsilon" => "ϵ",
"varepsilon" => "ε",
"zeta" => "ζ",
"eta" => "η",
"theta" => "θ",
"vartheta" => "ϑ",
"iota" => "ι",
"kappa" => "κ",
"lambda" => "λ",
"mu" => "μ",
"nu" => "ν",
"xi" => "ξ",
"omicron" => "ο",
"pi" => "π",
"varpi" => "ϖ",
"rho" => "ρ",
"varrho" => "ϱ",
"sigma" => "σ",
"varsigma" => "ς",
"tau" => "τ",
"upsilon" => "υ",
"phi" => "ϕ",
"varphi" => "φ",
"chi" => "χ",
"psi" => "ψ",
"omega" => "ω",
// Greek uppercase
"Gamma" => "Γ",
"Delta" => "Δ",
"Theta" => "Θ",
"Lambda" => "Λ",
"Xi" => "Ξ",
"Pi" => "Π",
"Sigma" => "Σ",
"Upsilon" => "Υ",
"Phi" => "Φ",
"Psi" => "Ψ",
"Omega" => "Ω",
// Big operators
"sum" => "",
"prod" => "",
"coprod" => "",
"int" => "",
"iint" => "",
"iiint" => "",
"oint" => "",
"bigcup" => "",
"bigcap" => "",
"bigvee" => "",
"bigwedge" => "",
"bigoplus" => "",
"bigotimes" => "",
"bigodot" => "",
"biguplus" => "",
// Named operators (render as plain words)
"lim" => "lim",
"limsup" => "lim sup",
"liminf" => "lim inf",
"sin" => "sin",
"cos" => "cos",
"tan" => "tan",
"cot" => "cot",
"sec" => "sec",
"csc" => "csc",
"arcsin" => "arcsin",
"arccos" => "arccos",
"arctan" => "arctan",
"sinh" => "sinh",
"cosh" => "cosh",
"tanh" => "tanh",
"coth" => "coth",
"log" => "log",
"ln" => "ln",
"lg" => "lg",
"exp" => "exp",
"max" => "max",
"min" => "min",
"sup" => "sup",
"inf" => "inf",
"det" => "det",
"dim" => "dim",
"ker" => "ker",
"deg" => "deg",
"arg" => "arg",
"gcd" => "gcd",
"hom" => "hom",
"Pr" => "Pr",
// Binary operators
"times" => "×",
"cdot" => "",
"div" => "÷",
"pm" => "±",
"mp" => "",
"ast" => "",
"star" => "",
"circ" => "",
"bullet" => "",
"oplus" => "",
"ominus" => "",
"otimes" => "",
"oslash" => "",
"odot" => "",
"wedge" | "land" => "",
"vee" | "lor" => "",
"cap" => "",
"cup" => "",
"setminus" => "",
"smallsetminus" => "",
"uplus" => "",
"sqcap" => "",
"sqcup" => "",
"triangleleft" => "",
"triangleright" => "",
"wr" => "",
"diamond" => "",
"dagger" => "",
"ddagger" => "",
"amalg" => "⨿",
// Relations
"le" | "leq" | "leqslant" => "",
"ge" | "geq" | "geqslant" => "",
"ne" | "neq" => "",
"ll" => "",
"gg" => "",
"approx" => "",
"sim" => "",
"simeq" => "",
"cong" => "",
"equiv" => "",
"doteq" => "",
"propto" => "",
"prec" => "",
"succ" => "",
"preceq" => "",
"succeq" => "",
"asymp" => "",
"in" => "",
"ni" | "owns" => "",
"notin" => "",
"subset" => "",
"supset" => "",
"subseteq" => "",
"supseteq" => "",
"subsetneq" => "",
"supsetneq" => "",
"sqsubseteq" => "",
"sqsupseteq" => "",
"vdash" => "",
"dashv" => "",
"models" | "vDash" => "",
"perp" => "",
"parallel" => "",
"nparallel" => "",
"mid" => "",
"nmid" => "",
"smile" => "",
"frown" => "",
"bowtie" => "",
// Arrows
"to" | "rightarrow" => "",
"leftarrow" | "gets" => "",
"leftrightarrow" => "",
"Rightarrow" => "",
"Leftarrow" => "",
"Leftrightarrow" => "",
"implies" => "",
"impliedby" => "",
"iff" => "",
"longrightarrow" => "",
"longleftarrow" => "",
"longmapsto" => "",
"mapsto" => "",
"uparrow" => "",
"downarrow" => "",
"updownarrow" => "",
"Uparrow" => "",
"Downarrow" => "",
"nearrow" => "",
"searrow" => "",
"swarrow" => "",
"nwarrow" => "",
"hookrightarrow" => "",
"hookleftarrow" => "",
"rightharpoonup" => "",
"leftharpoonup" => "",
"rightleftharpoons" => "",
// Logic / sets / misc letters
"forall" => "",
"exists" => "",
"nexists" => "",
"neg" | "lnot" => "¬",
"emptyset" | "varnothing" => "",
"infty" => "",
"nabla" => "",
"partial" => "",
"hbar" => "",
"ell" => "",
"Re" => "",
"Im" => "",
"aleph" => "",
"beth" => "",
"wp" => "",
"imath" => "ı",
"jmath" => "ȷ",
"top" => "",
"bot" => "",
"angle" => "",
"measuredangle" => "",
"triangle" => "",
"square" | "Box" => "",
"blacksquare" => "",
"diamondsuit" => "",
"heartsuit" => "",
"clubsuit" => "",
"spadesuit" => "",
"flat" => "",
"natural" => "",
"sharp" => "",
"checkmark" => "",
"degree" => "°",
"prime" => "",
"dprime" => "",
"therefore" => "",
"because" => "",
"dots" | "ldots" | "dotsc" | "dotso" | "dotsb" | "dotsm" => "",
"cdots" => "",
"vdots" => "",
"ddots" => "",
"surd" => "",
"AA" => "Å",
// Delimiters
"langle" => "",
"rangle" => "",
"lceil" => "",
"rceil" => "",
"lfloor" => "",
"rfloor" => "",
"lbrace" => "{",
"rbrace" => "}",
"lbrack" => "[",
"rbrack" => "]",
"vert" => "|",
"Vert" | "|" => "",
"backslash" => "\\",
"setbslash" => "",
// Escaped literals
"{" => "{",
"}" => "}",
"%" => "%",
"$" => "$",
"&" => "&",
"#" => "#",
"_" => "_",
_ => return None,
})
}
@@ -0,0 +1,370 @@
use super::*;
fn inline(src: &str) -> String {
latex_to_unicode_inline(src).expect("within size limit")
}
fn display(src: &str) -> Vec<String> {
latex_to_unicode_display(src).expect("within size limit")
}
#[test]
fn plain_expression_passes_through() {
assert_eq!(inline("E = mc"), "E = mc");
}
#[test]
fn superscripts_map_to_unicode() {
assert_eq!(inline("E = mc^2"), "E = mc²");
assert_eq!(inline("x^{10}"), "x¹⁰");
assert_eq!(inline("e^{-x}"), "e⁻ˣ");
assert_eq!(inline("x^T"), "xᵀ");
}
#[test]
fn subscripts_map_to_unicode() {
assert_eq!(inline("a_1 + a_2"), "a₁ + a₂");
assert_eq!(inline("x_{ij}"), "xᵢⱼ");
}
#[test]
fn script_fallback_uses_parens() {
// φ has no superscript form → fall back to ^(...)
assert_eq!(inline("x^{\\alpha\\beta}"), "x^(αβ)");
assert_eq!(inline("x^\\alpha"), "x^α");
// Single unmappable subscript char.
assert_eq!(inline("a_q"), "a_q");
}
#[test]
fn wordlike_scripts_fall_back_to_parens() {
// Text-family commands mark the atom as a word → no modifier-letter runs
// (`pₜₒᵣₛₒ` is unreadable and gappy in many terminal fonts).
assert_eq!(inline("p_{\\text{torso}}"), "p_(torso)");
assert_eq!(inline("z_{\\mathrm{draft}}"), "z_(draft)");
assert_eq!(inline("x^{\\text{opt}}"), "x^(opt)");
// 3+ letter runs read as words even without \text.
assert_eq!(inline("x_{max}"), "x_(max)");
assert_eq!(inline("z_{torso}"), "z_(torso)");
}
#[test]
fn indexlike_scripts_keep_unicode_forms() {
// 12 letter runs are index juxtapositions, not words.
assert_eq!(inline("x_{ij}"), "xᵢⱼ");
assert_eq!(inline("T_{i+1}"), "Tᵢ₊₁");
assert_eq!(inline("n^{th}"), "nᵗʰ");
assert_eq!(inline("\\sum_{i=0}^{2} \\gamma^{i}"), "∑ᵢ₌₀² γⁱ");
}
#[test]
fn boxed_renders_content_without_frame() {
assert_eq!(inline("\\boxed{x = 1}"), "x = 1");
assert_eq!(inline("\\boxed{\\mathcal{L}}"), "");
assert_eq!(inline("\\fbox{done}"), "done");
// Math typography applies inside \boxed (math mode) …
assert_eq!(inline("\\boxed{a - b}"), "a b");
// … but not inside \fbox (text mode).
assert_eq!(inline("\\fbox{a-b}"), "a-b");
}
#[test]
fn mtp_loss_equation_converts_fully() {
// A complex real-world equation: every command must
// convert — no literal command names in the output.
let src = "\\boxed{\n\\mathcal{L}_{\\text{MTP}}\n=\n\\sum_{i=0}^{2}\n\\gamma^{i}\\,\n\\mathbb{E}_{\\text{positions, mask}}\n\\Big[\n\\mathrm{KL}\\big(\n \\mathrm{softmax}(z_{\\text{torso}}^{(s_i)})\n \\;\\big\\|\\;\n \\mathrm{softmax}(z_{\\text{draft}}^{(i)})\n\\big)\n\\Big]\n}";
let joined = inline(src);
assert!(joined.contains("_(MTP)"), "got: {joined}");
assert!(joined.contains("∑ᵢ₌₀²"), "got: {joined}");
assert!(joined.contains("𝔼_(positions, mask)"), "got: {joined}");
assert!(joined.contains("softmax(z_(torso)"), "got: {joined}");
assert!(joined.contains(""), "got: {joined}");
assert!(!joined.contains("boxed"), "got: {joined}");
assert!(!joined.contains('\\'), "got: {joined}");
}
#[test]
fn greek_letters() {
assert_eq!(inline("\\alpha + \\beta = \\Gamma"), "α + β = Γ");
assert_eq!(inline("\\varepsilon \\varphi"), "ε φ");
}
#[test]
fn relations_and_operators() {
assert_eq!(inline("a \\le b \\ne c \\times d"), "a ≤ b ≠ c × d");
assert_eq!(inline("x \\in A \\cup B"), "x ∈ A B");
assert_eq!(inline("p \\implies q"), "p ⟹ q");
assert_eq!(inline("f: A \\to B"), "f: A → B");
}
#[test]
fn vulgar_and_general_fractions() {
assert_eq!(inline("\\frac{1}{2}"), "½");
assert_eq!(inline("\\frac{3}{4}"), "¾");
assert_eq!(inline("\\frac{dy}{dx}"), "dy/dx");
assert_eq!(inline("\\frac{a+b}{c}"), "(a+b)/c");
assert_eq!(inline("\\frac{x}{y - z}"), "x/(y z)");
}
#[test]
fn roots() {
assert_eq!(inline("\\sqrt{x}"), "√x");
assert_eq!(inline("\\sqrt{a + b}"), "√(a + b)");
assert_eq!(inline("\\sqrt[3]{x}"), "∛x");
assert_eq!(inline("\\sqrt[4]{x}"), "∜x");
assert_eq!(inline("\\sqrt[n]{x}"), "ⁿ√x");
}
#[test]
fn text_commands_pass_content_through() {
assert_eq!(inline("\\text{if } x > 0"), "if x > 0");
assert_eq!(inline("\\mathrm{d}x"), "dx");
assert_eq!(inline("\\operatorname{softmax}(z)"), "softmax(z)");
// Text mode must not map `-` to minus.
assert_eq!(inline("\\text{x-ray}"), "x-ray");
}
#[test]
fn alphabets() {
assert_eq!(inline("\\mathbb{R}^n"), "ℝⁿ");
assert_eq!(inline("\\mathbb{N} \\mathbb{Z} \\mathbb{Q}"), " ");
assert_eq!(inline("\\mathcal{L}"), "");
assert_eq!(inline("\\mathcal{O}(n)"), "𝒪(n)");
assert_eq!(inline("\\mathfrak{g}"), "𝔤");
assert_eq!(inline("\\mathbf{v}"), "𝐯");
}
#[test]
fn accents_use_combining_marks() {
assert_eq!(inline("\\hat{x}"), "x\u{0302}");
assert_eq!(inline("\\bar{y}"), "y\u{0304}");
assert_eq!(inline("\\vec{v}"), "v\u{20D7}");
assert_eq!(inline("\\dot{q}"), "q\u{0307}");
assert_eq!(inline("\\tilde\\theta"), "θ\u{0303}");
}
#[test]
fn left_right_and_spacing() {
assert_eq!(inline("\\left( \\frac{1}{2} \\right)"), "( ½ )".to_string());
assert_eq!(inline("\\left. x \\right|_0^1"), "x |₀¹");
assert_eq!(inline("\\int f(x)\\,dx"), "∫ f(x) dx");
assert_eq!(inline("a\\!b"), "ab");
assert_eq!(inline("a \\quad b"), "a b");
}
#[test]
fn named_function_operators() {
assert_eq!(inline("\\sin(x) + \\cos(y)"), "sin(x) + cos(y)");
assert_eq!(inline("\\lim_{x \\to 0} f(x)"), "lim_(x → 0) f(x)");
assert_eq!(inline("\\log n"), "log n");
}
#[test]
fn integrals_and_sums_with_bounds() {
assert_eq!(inline("\\int_0^\\infty e^{-x} dx"), "∫₀^∞ e⁻ˣ dx");
assert_eq!(inline("\\sum_{i=1}^{n} a_i"), "∑ᵢ₌₁ⁿ aᵢ");
}
#[test]
fn minus_and_prime_typography() {
assert_eq!(inline("a - b"), "a b");
assert_eq!(inline("f'(x)"), "f(x)");
}
#[test]
fn not_negates_known_relations() {
assert_eq!(inline("a \\not= b"), "a ≠ b");
assert_eq!(inline("x \\not\\in S"), "x ∉ S");
assert_eq!(inline("a \\not\\sim b"), "a \u{0338} b");
}
#[test]
fn binomials_and_mod() {
assert_eq!(inline("\\binom{n}{k}"), "C(n, k)");
assert_eq!(inline("a \\equiv b \\pmod{m}"), "a ≡ b (mod m)");
assert_eq!(inline("a \\bmod b"), "a mod b");
}
#[test]
fn row_breaks_join_inline_and_split_display() {
assert_eq!(inline("a \\\\ b"), "a; b");
assert_eq!(display("a \\\\ b"), vec!["a", "b"]);
}
#[test]
fn aligned_environment_strips_markers() {
let lines = display("\\begin{aligned} x &= y + 1 \\\\ y &= 2 \\end{aligned}");
assert_eq!(lines, vec!["x = y + 1", "y = 2"]);
}
#[test]
fn cases_environment_renders_brace_column() {
let lines = display("f(x) = \\begin{cases} x & x > 0 \\\\ 0 & \\text{otherwise} \\end{cases}");
assert_eq!(lines.len(), 2);
assert!(lines[0].starts_with("f(x) = ⎧ x"), "got {lines:?}");
assert!(lines[1].trim_start().starts_with("⎩ 0"), "got {lines:?}");
}
#[test]
fn pmatrix_pads_columns() {
let lines = display("\\begin{pmatrix} 1 & 22 \\\\ 333 & 4 \\end{pmatrix}");
assert_eq!(lines, vec!["⎛1 22⎞", "⎝333 4⎠"]);
}
#[test]
fn bmatrix_single_row_uses_flat_brackets() {
assert_eq!(
display("\\begin{bmatrix} a & b \\end{bmatrix}"),
vec!["[a b]"]
);
}
#[test]
fn vmatrix_uses_bars() {
let lines = display("\\begin{vmatrix} a & b \\\\ c & d \\end{vmatrix}");
assert_eq!(lines, vec!["│a b│", "│c d│"]);
}
#[test]
fn matrix_with_prefix_aligns_as_box() {
// The prefix must stay on the anchor row with the matrix body
// aligned beneath — not glued to the first row only.
let lines = display("A = \\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}");
assert_eq!(lines, vec!["A = ⎛1 2⎞", " ⎝3 4⎠"]);
}
#[test]
fn matrix_with_prefix_and_suffix_flows_on_anchor_row() {
let lines =
display("A = \\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}, \\quad \\det(A) = -2");
assert_eq!(lines, vec!["A = ⎛1 2⎞, det(A) = 2", " ⎝3 4⎠"]);
}
#[test]
fn three_row_matrix_anchors_on_middle_row() {
let lines = display("v = \\begin{pmatrix} 1 \\\\ 2 \\\\ 3 \\end{pmatrix} x");
assert_eq!(lines, vec![" ⎛1⎞", "v = ⎜2⎟ x", " ⎝3⎠"]);
}
#[test]
fn cases_with_prefix_aligns_as_box() {
let lines = display("f(x) = \\begin{cases} x & x > 0 \\\\ 0 & e \\end{cases}");
assert_eq!(lines, vec!["f(x) = ⎧ x x > 0", " ⎩ 0 e"]);
}
#[test]
fn inline_matrix_renders_flat() {
assert_eq!(
inline("\\begin{pmatrix} 1 & 2 \\\\ 3 & 4 \\end{pmatrix}"),
"(1 2; 3 4)"
);
assert_eq!(inline("\\begin{bmatrix} a \\\\ b \\end{bmatrix}"), "[a; b]");
}
#[test]
fn inline_cases_renders_flat() {
assert_eq!(
inline("\\begin{cases} x & x > 0 \\\\ 0 & e \\end{cases}"),
"{x x > 0; 0 e}"
);
}
#[test]
fn two_matrices_on_one_line_share_rows() {
let lines = display(
"\\begin{pmatrix} 1 \\\\ 2 \\end{pmatrix} + \\begin{pmatrix} 3 \\\\ 4 \\end{pmatrix}",
);
assert_eq!(lines, vec!["⎛1⎞ + ⎛3⎞", "⎝2⎠ ⎝4⎠"]);
}
#[test]
fn row_break_then_matrix_does_not_disturb_previous_line() {
let lines = display("a \\\\ B = \\begin{pmatrix} 1 \\\\ 2 \\end{pmatrix}");
assert_eq!(lines, vec!["a", "B = ⎛1⎞", " ⎝2⎠"]);
}
#[test]
fn unknown_environment_renders_rows() {
let lines = display("\\begin{foo} a \\\\ b \\end{foo}");
assert_eq!(lines, vec!["a", "b"]);
}
#[test]
fn nested_environment_resolves_matching_end() {
let lines = display(
"\\begin{aligned} A &= \\begin{pmatrix} 1 \\end{pmatrix} \\\\ B &= 2 \\end{aligned}",
);
assert_eq!(lines, vec!["A = (1)", "B = 2"]);
}
#[test]
fn unknown_commands_keep_their_name() {
assert_eq!(inline("\\foobar x"), "foobar x");
}
#[test]
fn overset_and_stackrel() {
assert_eq!(inline("a \\overset{!}{=} b"), "a = b");
assert_eq!(inline("a \\overset{n}{=} b"), "a =ⁿ b");
}
#[test]
fn malformed_input_does_not_panic() {
for src in [
"",
"{",
"}",
"\\",
"\\frac{a}",
"\\frac",
"\\sqrt[",
"\\begin{aligned} x",
"\\begin",
"\\end{x}",
"^",
"_",
"^{",
"a^",
"{{{{{{",
"\\left",
"\\not",
"$$$",
"\\\\\\",
"&&&&",
] {
let _ = latex_to_unicode_inline(src);
let _ = latex_to_unicode_display(src);
}
}
#[test]
fn deeply_nested_input_is_bounded() {
let mut src = String::new();
for _ in 0..200 {
src.push('{');
}
src.push('x');
for _ in 0..200 {
src.push('}');
}
let _ = latex_to_unicode_inline(&src);
}
#[test]
fn oversized_input_is_rejected() {
let big = "x".repeat(MAX_MATH_SOURCE_LEN + 1);
assert!(latex_to_unicode_inline(&big).is_none());
assert!(latex_to_unicode_display(&big).is_none());
}
#[test]
fn whitespace_only_display_is_empty() {
assert!(display(" \n ").is_empty());
}
#[test]
fn escaped_literals() {
assert_eq!(inline("100\\%"), "100%");
assert_eq!(inline("\\{a, b\\}"), "{a, b}");
assert_eq!(inline("\\$5"), "$5");
}