Skip to main content

fmd_math/
commands.rs

1//! The command registry: every control word and environment the tier-1
2//! surface knows, each with its parse behavior, plus the *known tier-2*
3//! vocabulary so unsupported constructs fail as precise, named, tier-tagged
4//! errors (the coverage ratchet's unit — G0-4's normative counting rules).
5//!
6//! The tier-1 surface is the §11.4 seed vocabulary united with the G0-4
7//! rank extension (`construct_table.tsv` in franken_manim); the two
8//! T1-by-rank preamble commands the Reference's default template defines —
9//! `\minus` and `\mathds` — are built in here as the seed of the default
10//! preamble pack (generalized macro packs land with their own bead).
11
12use crate::atom::AtomClass;
13use crate::node::{
14    AccentKind, DelimSize, FracSpec, LineAlign, MathFont, PhantomKind, SpaceKind, StackKind,
15    TextStyle,
16};
17use crate::style::Style;
18
19/// Where unsupported-but-known constructs are tracked.
20pub const TIER2_TRACKING: &str = "franken_manim fm-j5t (the tier-2 construct program)";
21
22/// Where unknown (untiered) constructs should be reported.
23pub const UNTIERED_TRACKING: &str = "https://github.com/Dicklesworthstone/franken_manim/issues";
24
25/// Where parse-supported constructs whose layout has not landed yet are
26/// tracked (environments, drawn/stretchy constructions, macro packs).
27pub const LAYOUT_PENDING_TRACKING: &str = "franken_manim fm-kg9 (the fmd-math extensions bead)";
28
29/// Construct tiers, per the G0-4 harvest.
30#[derive(Clone, Copy, Debug, PartialEq, Eq)]
31pub enum Tier {
32    /// Tier 1: the shipped surface (§11.4 seed ∪ rank extension).
33    T1,
34    /// Tier 2: the observed long tail, scheduled by corpus rank.
35    T2,
36}
37
38/// Parse-level support status of a construct, queryable in the construct
39/// table's own naming scheme (`\frac`, `env:array`, `script:sup`,
40/// `char:U+00F6`, …). This is what the coverage ratchet and the corpus
41/// goldens consume.
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
43pub enum ConstructStatus {
44    /// The parser understands it end-to-end.
45    Supported,
46    /// Known tier-2 vocabulary: parsing fails with a precise, tier-tagged
47    /// error.
48    UnsupportedT2,
49    /// Not in any tier: parsing fails with a precise, named error pointing
50    /// at the issue tracker.
51    Unknown,
52}
53
54/// What the parser should do with a control word.
55#[derive(Clone, Debug, PartialEq)]
56pub(crate) enum Cmd {
57    /// A character symbol with an intrinsic class (and whether it may
58    /// follow `\left`/`\right`/`\big`).
59    Sym {
60        ch: char,
61        class: AtomClass,
62        delim: bool,
63    },
64    /// A big operator.
65    BigOp { ch: char, integral: bool },
66    /// A predefined roman operator name (`limits` marks the `\lim` class).
67    OpName {
68        rendered: &'static str,
69        limits: bool,
70    },
71    /// `\operatorname{…}`.
72    OperatorName,
73    /// An accent command.
74    Accent(AccentKind),
75    /// A two-argument fraction command.
76    Frac(FracSpec),
77    /// An infix fraction command (`\over`, `\choose`) splitting the
78    /// enclosing group.
79    OverInfix(FracSpec),
80    /// `\sqrt`.
81    Radical,
82    /// `\text`.
83    Text,
84    /// `\textbf`/`\emph`/`\underline`-in-text.
85    TextStyled(TextStyle),
86    /// A math-alphabet command.
87    Alphabet(MathFont),
88    /// A style primitive (`\displaystyle` …).
89    StyleSwitch(Style),
90    /// A line-alignment declaration (`\centering`), text mode only.
91    AlignDecl(LineAlign),
92    /// A line-spacing declaration (`\doublespacing`), text mode only.
93    LineSpacing(f64),
94    /// A size declaration (`\small` …): sets the current size factor for
95    /// the rest of the enclosing group.
96    SizeChange(f64),
97    /// A named spacing command (`\quad` …).
98    Spacing(SpaceKind),
99    /// A phantom command.
100    Phantom(PhantomKind),
101    /// `\stackrel`/`\overset`/`\underset`.
102    Stack(StackKind),
103    /// `\substack{…}` — amsmath's multi-line subscript: one braced
104    /// argument of `\\`-separated lines, stacked centered in script
105    /// style (the same grid the `substack` environment produces).
106    Substack,
107    /// `\color{…}`.
108    Color,
109    /// A `\big`-family fixed-size delimiter command; `class` is imposed by
110    /// the l/r/m variant, `None` (⇒ Ord) for the plain form.
111    SizedDelim {
112        size: DelimSize,
113        class: Option<AtomClass>,
114    },
115    /// `\left`.
116    Left,
117    /// `\right`.
118    Right,
119    /// `\limits`.
120    Limits,
121    /// `\nolimits`.
122    NoLimits,
123    /// `\begin`.
124    Begin,
125    /// `\end`.
126    End,
127    /// `\ding{NN}` — the pifont escape (fm-j5t.4); the implemented codes
128    /// live in [`ding_char`].
129    Ding,
130    /// `\xrightarrow`/`\xmapsto`: optional `[below]` then `{above}`.
131    XArrow {
132        /// True for `\xmapsto`.
133        mapsto: bool,
134    },
135    /// Known tier-2 vocabulary: fail precisely.
136    UnsupportedT2,
137}
138
139/// An environment the grammar knows.
140#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141pub(crate) struct EnvDef {
142    /// Takes a braced column-spec argument (`array`).
143    pub(crate) has_spec: bool,
144}
145
146const fn sym(ch: char, class: AtomClass) -> Cmd {
147    Cmd::Sym {
148        ch,
149        class,
150        delim: false,
151    }
152}
153
154const fn delim_sym(ch: char, class: AtomClass) -> Cmd {
155    Cmd::Sym {
156        ch,
157        class,
158        delim: true,
159    }
160}
161
162/// Look up a control word (name without the backslash).
163#[allow(clippy::too_many_lines)]
164pub(crate) fn lookup(name: &str) -> Option<Cmd> {
165    use AtomClass::{Bin, Close, Inner, Open, Ord, Punct, Rel};
166    Some(match name {
167        // ── Greek, lowercase ────────────────────────────────────────────
168        "alpha" => sym('α', Ord),
169        "beta" => sym('β', Ord),
170        "gamma" => sym('γ', Ord),
171        "delta" => sym('δ', Ord),
172        "epsilon" => sym('ϵ', Ord),
173        "varepsilon" => sym('ε', Ord),
174        "zeta" => sym('ζ', Ord),
175        "eta" => sym('η', Ord),
176        "theta" => sym('θ', Ord),
177        "vartheta" => sym('ϑ', Ord),
178        "iota" => sym('ι', Ord),
179        "kappa" => sym('κ', Ord),
180        "lambda" => sym('λ', Ord),
181        "mu" => sym('μ', Ord),
182        "nu" => sym('ν', Ord),
183        "xi" => sym('ξ', Ord),
184        "pi" => sym('π', Ord),
185        "varpi" => sym('ϖ', Ord),
186        "rho" => sym('ρ', Ord),
187        "varrho" => sym('ϱ', Ord),
188        "sigma" => sym('σ', Ord),
189        "varsigma" => sym('ς', Ord),
190        "tau" => sym('τ', Ord),
191        "upsilon" => sym('υ', Ord),
192        "phi" => sym('ϕ', Ord),
193        "varphi" => sym('φ', Ord),
194        "chi" => sym('χ', Ord),
195        "psi" => sym('ψ', Ord),
196        "omega" => sym('ω', Ord),
197        // ── Greek, uppercase ────────────────────────────────────────────
198        "Gamma" => sym('Γ', Ord),
199        "Delta" => sym('Δ', Ord),
200        "Theta" => sym('Θ', Ord),
201        "Lambda" => sym('Λ', Ord),
202        "Xi" => sym('Ξ', Ord),
203        "Pi" => sym('Π', Ord),
204        "Sigma" => sym('Σ', Ord),
205        "Upsilon" => sym('ϒ', Ord),
206        "Phi" => sym('Φ', Ord),
207        "Psi" => sym('Ψ', Ord),
208        "Omega" => sym('Ω', Ord),
209        // ── Ordinary symbols ────────────────────────────────────────────
210        "infty" => sym('∞', Ord),
211        "partial" => sym('∂', Ord),
212        "nabla" => sym('∇', Ord),
213        "hbar" => sym('ℏ', Ord),
214        "ell" => sym('ℓ', Ord),
215        "wp" => sym('℘', Ord),
216        "Re" => sym('ℜ', Ord),
217        "Im" => sym('ℑ', Ord),
218        "aleph" => sym('ℵ', Ord),
219        "imath" | "i" => sym('ı', Ord),
220        "jmath" | "j" => sym('ȷ', Ord),
221        "emptyset" => sym('∅', Ord),
222        "varnothing" => sym('⌀', Ord),
223        "exists" => sym('∃', Ord),
224        "forall" => sym('∀', Ord),
225        "neg" | "lnot" => sym('¬', Ord),
226        "prime" => sym('′', Ord),
227        "angle" => sym('∠', Ord),
228        "triangle" => sym('△', Ord),
229        "checkmark" => sym('✓', Ord),
230        // ── Astronomy and misc symbols (fm-j5t.4) ───────────────────────
231        // © is an authored CM glyph; ♀ ♂ ♁ are unmapped in every bundled
232        // face and draw through the drawn-symbol fallback in layout (the
233        // Checkmark/Exmark drawn-mark precedent) — never a silent
234        // substitute glyph.
235        "copyright" => sym('©', Ord),
236        "female" => sym('\u{2640}', Ord),
237        "male" | "mars" => sym('\u{2642}', Ord),
238        "earth" => sym('\u{2641}', Ord),
239        "ding" => Cmd::Ding,
240        "xrightarrow" => Cmd::XArrow { mapsto: false },
241        "xmapsto" => Cmd::XArrow { mapsto: true },
242        "top" => sym('⊤', Ord),
243        "bot" => sym('⊥', Ord),
244        "flat" => sym('♭', Ord),
245        "natural" => sym('♮', Ord),
246        "sharp" => sym('♯', Ord),
247        "clubsuit" => sym('♣', Ord),
248        "diamondsuit" => sym('♢', Ord),
249        "heartsuit" => sym('♡', Ord),
250        "spadesuit" => sym('♠', Ord),
251        "backslash" => delim_sym('\\', Ord),
252        "vert" => delim_sym('|', Ord),
253        "Vert" => delim_sym('‖', Ord),
254        // Dots. `\ldots`-class dots are Inner atoms in TeX (\mathinner);
255        // `\vdots` is a plain Ord box.
256        "ldots" | "dots" | "dotsc" | "dotso" => sym('…', Inner),
257        "cdots" | "dotsb" | "hdots" => sym('⋯', Inner),
258        "vdots" => sym('⋮', Ord),
259        "ddots" => sym('⋱', Inner),
260        // ── Binary operations ───────────────────────────────────────────
261        "pm" => sym('±', Bin),
262        "mp" => sym('∓', Bin),
263        "cdot" => sym('⋅', Bin),
264        "times" => sym('×', Bin),
265        "div" => sym('÷', Bin),
266        "ast" => sym('∗', Bin),
267        "star" => sym('⋆', Bin),
268        "circ" => sym('∘', Bin),
269        "bullet" => sym('∙', Bin),
270        "oplus" => sym('⊕', Bin),
271        "ominus" => sym('⊖', Bin),
272        "otimes" => sym('⊗', Bin),
273        "oslash" => sym('⊘', Bin),
274        "odot" => sym('⊙', Bin),
275        "cup" => sym('∪', Bin),
276        "cap" => sym('∩', Bin),
277        "sqcup" => sym('⊔', Bin),
278        "sqcap" => sym('⊓', Bin),
279        "vee" | "lor" => sym('∨', Bin),
280        "wedge" | "land" => sym('∧', Bin),
281        "setminus" | "smallsetminus" => sym('∖', Bin),
282        "wr" => sym('≀', Bin),
283        "diamond" => sym('⋄', Bin),
284        "triangleleft" => sym('◁', Bin),
285        "triangleright" => sym('▷', Bin),
286        "uplus" => sym('⊎', Bin),
287        "amalg" => sym('⨿', Bin),
288        "dagger" => sym('†', Bin),
289        "ddagger" => sym('‡', Bin),
290        // The Reference's default template defines `\minus` (T1 by rank);
291        // the built-in default pack maps it to a binary minus sign.
292        "minus" => sym('−', Bin),
293        // ── Relations ───────────────────────────────────────────────────
294        "le" | "leq" => sym('≤', Rel),
295        "ge" | "geq" => sym('≥', Rel),
296        "ne" | "neq" => sym('≠', Rel),
297        "equiv" => sym('≡', Rel),
298        "approx" => sym('≈', Rel),
299        "sim" => sym('∼', Rel),
300        "simeq" => sym('≃', Rel),
301        "cong" => sym('≅', Rel),
302        "doteq" => sym('≐', Rel),
303        "propto" => sym('∝', Rel),
304        "perp" => sym('⊥', Rel),
305        "mid" => sym('∣', Rel),
306        "nmid" => sym('∤', Rel),
307        "parallel" => sym('∥', Rel),
308        "in" => sym('∈', Rel),
309        "ni" => sym('∋', Rel),
310        "notin" => sym('∉', Rel),
311        "subset" => sym('⊂', Rel),
312        "supset" => sym('⊃', Rel),
313        "subseteq" => sym('⊆', Rel),
314        "supseteq" => sym('⊇', Rel),
315        "sqsubseteq" => sym('⊑', Rel),
316        "sqsupseteq" => sym('⊒', Rel),
317        "ll" => sym('≪', Rel),
318        "gg" => sym('≫', Rel),
319        "prec" => sym('≺', Rel),
320        "succ" => sym('≻', Rel),
321        "preceq" => sym('⪯', Rel),
322        "succeq" => sym('⪰', Rel),
323        "asymp" => sym('≍', Rel),
324        "bowtie" => sym('⋈', Rel),
325        "models" => sym('⊨', Rel),
326        "vdash" => sym('⊢', Rel),
327        "dashv" => sym('⊣', Rel),
328        "smile" => sym('⌣', Rel),
329        "frown" => sym('⌢', Rel),
330        "colon" => sym(':', Punct),
331        // ── Arrows (relations; the vertical ones are delimiters too) ────
332        "rightarrow" | "to" => sym('→', Rel),
333        "leftarrow" | "gets" => sym('←', Rel),
334        "leftrightarrow" => sym('↔', Rel),
335        "Rightarrow" => sym('⇒', Rel),
336        "Leftarrow" => sym('⇐', Rel),
337        "Leftrightarrow" => sym('⇔', Rel),
338        "longrightarrow" => sym('⟶', Rel),
339        "longleftarrow" => sym('⟵', Rel),
340        "longleftrightarrow" => sym('⟷', Rel),
341        "Longrightarrow" | "implies" => sym('⟹', Rel),
342        "Longleftarrow" | "impliedby" => sym('⟸', Rel),
343        "Longleftrightarrow" | "iff" => sym('⟺', Rel),
344        "mapsto" => sym('↦', Rel),
345        "longmapsto" => sym('⟼', Rel),
346        "uparrow" => delim_sym('↑', Rel),
347        "downarrow" => delim_sym('↓', Rel),
348        "updownarrow" => delim_sym('↕', Rel),
349        "Uparrow" => delim_sym('⇑', Rel),
350        "Downarrow" => delim_sym('⇓', Rel),
351        "Updownarrow" => delim_sym('⇕', Rel),
352        "nearrow" => sym('↗', Rel),
353        "searrow" => sym('↘', Rel),
354        "swarrow" => sym('↙', Rel),
355        "nwarrow" => sym('↖', Rel),
356        "hookrightarrow" => sym('↪', Rel),
357        "circlearrowleft" => sym('↺', Rel),
358        "circlearrowright" => sym('↻', Rel),
359        "hookleftarrow" => sym('↩', Rel),
360        "rightharpoonup" => sym('⇀', Rel),
361        "rightharpoondown" => sym('⇁', Rel),
362        "leftharpoonup" => sym('↼', Rel),
363        "leftharpoondown" => sym('↽', Rel),
364        "rightleftharpoons" => sym('⇌', Rel),
365        // ── Delimiter symbols ───────────────────────────────────────────
366        "langle" => delim_sym('⟨', Open),
367        "rangle" => delim_sym('⟩', Close),
368        "lceil" => delim_sym('⌈', Open),
369        "rceil" => delim_sym('⌉', Close),
370        "lfloor" => delim_sym('⌊', Open),
371        "rfloor" => delim_sym('⌋', Close),
372        "lbrace" => delim_sym('{', Open),
373        "rbrace" => delim_sym('}', Close),
374        "lbrack" => delim_sym('[', Open),
375        "rbrack" => delim_sym(']', Close),
376        // ── Big operators ───────────────────────────────────────────────
377        "sum" => Cmd::BigOp {
378            ch: '∑',
379            integral: false,
380        },
381        "prod" => Cmd::BigOp {
382            ch: '∏',
383            integral: false,
384        },
385        "coprod" => Cmd::BigOp {
386            ch: '∐',
387            integral: false,
388        },
389        "int" => Cmd::BigOp {
390            ch: '∫',
391            integral: true,
392        },
393        "oint" => Cmd::BigOp {
394            ch: '∮',
395            integral: true,
396        },
397        "iint" => Cmd::BigOp {
398            ch: '∬',
399            integral: true,
400        },
401        "oiint" => Cmd::BigOp {
402            ch: '∯',
403            integral: true,
404        },
405        "iiint" => Cmd::BigOp {
406            ch: '∭',
407            integral: true,
408        },
409        "bigcup" => Cmd::BigOp {
410            ch: '⋃',
411            integral: false,
412        },
413        "bigcap" => Cmd::BigOp {
414            ch: '⋂',
415            integral: false,
416        },
417        "bigvee" => Cmd::BigOp {
418            ch: '⋁',
419            integral: false,
420        },
421        "bigwedge" => Cmd::BigOp {
422            ch: '⋀',
423            integral: false,
424        },
425        "bigoplus" => Cmd::BigOp {
426            ch: '⨁',
427            integral: false,
428        },
429        "bigotimes" => Cmd::BigOp {
430            ch: '⨂',
431            integral: false,
432        },
433        "bigodot" => Cmd::BigOp {
434            ch: '⨀',
435            integral: false,
436        },
437        "biguplus" => Cmd::BigOp {
438            ch: '⨄',
439            integral: false,
440        },
441        "bigsqcup" => Cmd::BigOp {
442            ch: '⨆',
443            integral: false,
444        },
445        // ── Operator names ──────────────────────────────────────────────
446        "sin" => Cmd::OpName {
447            rendered: "sin",
448            limits: false,
449        },
450        "cos" => Cmd::OpName {
451            rendered: "cos",
452            limits: false,
453        },
454        "tan" => Cmd::OpName {
455            rendered: "tan",
456            limits: false,
457        },
458        "cot" => Cmd::OpName {
459            rendered: "cot",
460            limits: false,
461        },
462        "sec" => Cmd::OpName {
463            rendered: "sec",
464            limits: false,
465        },
466        "csc" => Cmd::OpName {
467            rendered: "csc",
468            limits: false,
469        },
470        "arcsin" => Cmd::OpName {
471            rendered: "arcsin",
472            limits: false,
473        },
474        "arccos" => Cmd::OpName {
475            rendered: "arccos",
476            limits: false,
477        },
478        "arctan" => Cmd::OpName {
479            rendered: "arctan",
480            limits: false,
481        },
482        "sinh" => Cmd::OpName {
483            rendered: "sinh",
484            limits: false,
485        },
486        "cosh" => Cmd::OpName {
487            rendered: "cosh",
488            limits: false,
489        },
490        "tanh" => Cmd::OpName {
491            rendered: "tanh",
492            limits: false,
493        },
494        "coth" => Cmd::OpName {
495            rendered: "coth",
496            limits: false,
497        },
498        "exp" => Cmd::OpName {
499            rendered: "exp",
500            limits: false,
501        },
502        "log" => Cmd::OpName {
503            rendered: "log",
504            limits: false,
505        },
506        "lg" => Cmd::OpName {
507            rendered: "lg",
508            limits: false,
509        },
510        "ln" => Cmd::OpName {
511            rendered: "ln",
512            limits: false,
513        },
514        "arg" => Cmd::OpName {
515            rendered: "arg",
516            limits: false,
517        },
518        "deg" => Cmd::OpName {
519            rendered: "deg",
520            limits: false,
521        },
522        "dim" => Cmd::OpName {
523            rendered: "dim",
524            limits: false,
525        },
526        "hom" => Cmd::OpName {
527            rendered: "hom",
528            limits: false,
529        },
530        "ker" => Cmd::OpName {
531            rendered: "ker",
532            limits: false,
533        },
534        // `\mod`/`\bmod` carry their own leading-space quirk; the layout
535        // bead refines the spacing, the parse surface is an operator name.
536        "mod" | "bmod" => Cmd::OpName {
537            rendered: "mod",
538            limits: false,
539        },
540        "lim" => Cmd::OpName {
541            rendered: "lim",
542            limits: true,
543        },
544        "limsup" => Cmd::OpName {
545            rendered: "lim sup",
546            limits: true,
547        },
548        "liminf" => Cmd::OpName {
549            rendered: "lim inf",
550            limits: true,
551        },
552        "max" => Cmd::OpName {
553            rendered: "max",
554            limits: true,
555        },
556        "min" => Cmd::OpName {
557            rendered: "min",
558            limits: true,
559        },
560        "sup" => Cmd::OpName {
561            rendered: "sup",
562            limits: true,
563        },
564        "inf" => Cmd::OpName {
565            rendered: "inf",
566            limits: true,
567        },
568        "det" => Cmd::OpName {
569            rendered: "det",
570            limits: true,
571        },
572        "gcd" => Cmd::OpName {
573            rendered: "gcd",
574            limits: true,
575        },
576        "Pr" => Cmd::OpName {
577            rendered: "Pr",
578            limits: true,
579        },
580        "operatorname" => Cmd::OperatorName,
581        // ── Accents ─────────────────────────────────────────────────────
582        "hat" => Cmd::Accent(AccentKind::Hat),
583        "check" => Cmd::Accent(AccentKind::Check),
584        "tilde" => Cmd::Accent(AccentKind::Tilde),
585        "acute" => Cmd::Accent(AccentKind::Acute),
586        "grave" => Cmd::Accent(AccentKind::Grave),
587        "dot" => Cmd::Accent(AccentKind::Dot),
588        "ddot" => Cmd::Accent(AccentKind::Ddot),
589        "dddot" => Cmd::Accent(AccentKind::Dddot),
590        "ddddot" => Cmd::Accent(AccentKind::Ddddot),
591        "breve" => Cmd::Accent(AccentKind::Breve),
592        "bar" => Cmd::Accent(AccentKind::Bar),
593        "vec" => Cmd::Accent(AccentKind::Vec),
594        "mathring" => Cmd::Accent(AccentKind::Ring),
595        "widehat" => Cmd::Accent(AccentKind::WideHat),
596        "widetilde" => Cmd::Accent(AccentKind::WideTilde),
597        "overline" => Cmd::Accent(AccentKind::OverLine),
598        "overbrace" => Cmd::Accent(AccentKind::OverBrace),
599        "underbrace" => Cmd::Accent(AccentKind::UnderBrace),
600        "overrightarrow" => Cmd::Accent(AccentKind::OverRightArrow),
601        "overleftarrow" => Cmd::Accent(AccentKind::OverLeftArrow),
602        // `\underline` is mode-dependent: a math accent here, a text
603        // styling island in text mode (the parser dispatches by mode).
604        "underline" => Cmd::Accent(AccentKind::UnderLine),
605        // ── Fractions ───────────────────────────────────────────────────
606        "frac" => Cmd::Frac(FracSpec {
607            bar: true,
608            delims: None,
609            forced_style: None,
610        }),
611        "dfrac" => Cmd::Frac(FracSpec {
612            bar: true,
613            delims: None,
614            forced_style: Some(Style::Display),
615        }),
616        "tfrac" => Cmd::Frac(FracSpec {
617            bar: true,
618            delims: None,
619            forced_style: Some(Style::Text),
620        }),
621        "binom" => Cmd::Frac(FracSpec {
622            bar: false,
623            delims: Some(('(', ')')),
624            forced_style: None,
625        }),
626        "over" => Cmd::OverInfix(FracSpec {
627            bar: true,
628            delims: None,
629            forced_style: None,
630        }),
631        "choose" => Cmd::OverInfix(FracSpec {
632            bar: false,
633            delims: Some(('(', ')')),
634            forced_style: None,
635        }),
636        "sqrt" => Cmd::Radical,
637        // ── Text islands and math alphabets ─────────────────────────────
638        "text" => Cmd::Text,
639        "textbf" => Cmd::TextStyled(TextStyle::Bold),
640        "emph" => Cmd::TextStyled(TextStyle::Emph),
641        "mathbb" => Cmd::Alphabet(MathFont::Blackboard),
642        // The default preamble pack maps the Reference template's dsfont
643        // `\mathds` to blackboard (T1 by rank, G0-4).
644        "mathds" => Cmd::Alphabet(MathFont::Blackboard),
645        "mathcal" => Cmd::Alphabet(MathFont::Calligraphic),
646        "mathrm" => Cmd::Alphabet(MathFont::Roman),
647        "mathbf" => Cmd::Alphabet(MathFont::Bold),
648        "boldsymbol" => Cmd::Alphabet(MathFont::BoldItalic),
649        "mathsf" => Cmd::Alphabet(MathFont::SansSerif),
650        "mathtt" => Cmd::Alphabet(MathFont::Typewriter),
651        "mathit" => Cmd::Alphabet(MathFont::Italic),
652        // ── Styles, spacing, phantoms, stacks, color ────────────────────
653        "displaystyle" => Cmd::StyleSwitch(Style::Display),
654        "textstyle" => Cmd::StyleSwitch(Style::Text),
655        "scriptstyle" => Cmd::StyleSwitch(Style::Script),
656        "scriptscriptstyle" => Cmd::StyleSwitch(Style::ScriptScript),
657        // Size declarations: group-scoped factors of the current size,
658        // LaTeX's 10 pt ladder (`size10.clo`: tiny 5, footnotesize 8,
659        // small 9, large 12, Large 14.4, huge 20.74 pt).
660        "tiny" => Cmd::SizeChange(0.5),
661        "footnotesize" => Cmd::SizeChange(0.8),
662        "small" => Cmd::SizeChange(0.9),
663        "large" => Cmd::SizeChange(1.2),
664        "Large" => Cmd::SizeChange(1.44),
665        "huge" => Cmd::SizeChange(2.074),
666        "quad" => Cmd::Spacing(SpaceKind::Quad),
667        "qquad" => Cmd::Spacing(SpaceKind::Qquad),
668        "phantom" => Cmd::Phantom(PhantomKind::Full),
669        "hphantom" => Cmd::Phantom(PhantomKind::Horizontal),
670        "vphantom" => Cmd::Phantom(PhantomKind::Vertical),
671        "stackrel" => Cmd::Stack(StackKind::Stackrel),
672        "overset" => Cmd::Stack(StackKind::Overset),
673        "underset" => Cmd::Stack(StackKind::Underset),
674        "substack" => Cmd::Substack,
675        "color" => Cmd::Color,
676        // ── Sized delimiters ────────────────────────────────────────────
677        "big" => Cmd::SizedDelim {
678            size: DelimSize::Big,
679            class: None,
680        },
681        "Big" => Cmd::SizedDelim {
682            size: DelimSize::BBig,
683            class: None,
684        },
685        "bigg" => Cmd::SizedDelim {
686            size: DelimSize::Bigg,
687            class: None,
688        },
689        "Bigg" => Cmd::SizedDelim {
690            size: DelimSize::BBigg,
691            class: None,
692        },
693        "bigl" => Cmd::SizedDelim {
694            size: DelimSize::Big,
695            class: Some(AtomClass::Open),
696        },
697        "Bigl" => Cmd::SizedDelim {
698            size: DelimSize::BBig,
699            class: Some(AtomClass::Open),
700        },
701        "biggl" => Cmd::SizedDelim {
702            size: DelimSize::Bigg,
703            class: Some(AtomClass::Open),
704        },
705        "Biggl" => Cmd::SizedDelim {
706            size: DelimSize::BBigg,
707            class: Some(AtomClass::Open),
708        },
709        "bigr" => Cmd::SizedDelim {
710            size: DelimSize::Big,
711            class: Some(AtomClass::Close),
712        },
713        "Bigr" => Cmd::SizedDelim {
714            size: DelimSize::BBig,
715            class: Some(AtomClass::Close),
716        },
717        "biggr" => Cmd::SizedDelim {
718            size: DelimSize::Bigg,
719            class: Some(AtomClass::Close),
720        },
721        "Biggr" => Cmd::SizedDelim {
722            size: DelimSize::BBigg,
723            class: Some(AtomClass::Close),
724        },
725        "bigm" => Cmd::SizedDelim {
726            size: DelimSize::Big,
727            class: Some(AtomClass::Rel),
728        },
729        "Bigm" => Cmd::SizedDelim {
730            size: DelimSize::BBig,
731            class: Some(AtomClass::Rel),
732        },
733        "biggm" => Cmd::SizedDelim {
734            size: DelimSize::Bigg,
735            class: Some(AtomClass::Rel),
736        },
737        "Biggm" => Cmd::SizedDelim {
738            size: DelimSize::BBigg,
739            class: Some(AtomClass::Rel),
740        },
741        // ── Structure ───────────────────────────────────────────────────
742        "left" => Cmd::Left,
743        "right" => Cmd::Right,
744        "limits" => Cmd::Limits,
745        "nolimits" => Cmd::NoLimits,
746        "begin" => Cmd::Begin,
747        "end" => Cmd::End,
748        // Line-alignment declarations (text mode; the declaration form of
749        // the `center` environment).
750        "centering" => Cmd::AlignDecl(LineAlign::Center),
751        // setspace's declaration form; 1.667 is its 10 pt ladder value.
752        "doublespacing" => Cmd::LineSpacing(1.667),
753        // ── Known tier-2 vocabulary (G0-4 `construct_table.tsv`) ────────
754        // (`\centering` graduated to a real AlignDecl above.)
755        "dx" => Cmd::UnsupportedT2,
756        _ => return None,
757    })
758}
759
760/// The implemented slice of the pifont `\ding` table (fm-j5t.4). The
761/// G0-4 corpus uses only code 51 — the checkmark, whose glyph resolves
762/// through the symbol faces as U+2713 (the `\checkmark` codepoint, which
763/// those faces carry). Any other code fails precisely, by name.
764pub(crate) fn ding_char(code: u32) -> Option<char> {
765    match code {
766        51 => Some('✓'),
767        _ => None,
768    }
769}
770
771/// Look up an environment name.
772pub(crate) fn lookup_env(name: &str) -> Option<EnvDef> {
773    match name {
774        "array" => Some(EnvDef { has_spec: true }),
775        "align" | "align*" | "aligned" | "cases" | "matrix" | "pmatrix" | "bmatrix" | "Bmatrix"
776        | "vmatrix" | "Vmatrix" | "smallmatrix" | "substack" => Some(EnvDef { has_spec: false }),
777        _ => None,
778    }
779}
780
781/// The line-alignment environments (`flushleft`, `center`, `flushright`):
782/// text-mode blocks aligning their `\\`-split lines within the widest
783/// line's width.
784pub(crate) fn line_align_env(name: &str) -> Option<LineAlign> {
785    match name {
786        "flushleft" => Some(LineAlign::Left),
787        "center" => Some(LineAlign::Center),
788        "flushright" => Some(LineAlign::Right),
789        _ => None,
790    }
791}
792
793/// True when the control *symbol* is known tier-2 vocabulary. Empty since
794/// the text accents `\'` and `\"` graduated (fm-j5t); kept so the next
795/// tier-2 control symbol has its slot back.
796pub(crate) fn control_symbol_is_t2(ch: char) -> bool {
797    let _ = ch;
798    false
799}
800
801/// Compose a text accent over its base letter, to the precomposed Unicode
802/// character the bundled CM faces carry (probe-verified: the full Latin-1
803/// acute and dieresis rows exist in CM regular and italic). `None` is a
804/// precise malformed-argument error at the parse site, never silence.
805pub(crate) fn compose_text_accent(accent: char, base: char) -> Option<char> {
806    Some(match (accent, base) {
807        ('\'', 'a') => 'á',
808        ('\'', 'e') => 'é',
809        ('\'', 'i') => 'í',
810        ('\'', 'o') => 'ó',
811        ('\'', 'u') => 'ú',
812        ('\'', 'y') => 'ý',
813        ('\'', 'A') => 'Á',
814        ('\'', 'E') => 'É',
815        ('\'', 'I') => 'Í',
816        ('\'', 'O') => 'Ó',
817        ('\'', 'U') => 'Ú',
818        ('"', 'a') => 'ä',
819        ('"', 'e') => 'ë',
820        ('"', 'i') => 'ï',
821        ('"', 'o') => 'ö',
822        ('"', 'u') => 'ü',
823        ('"', 'y') => 'ÿ',
824        ('"', 'A') => 'Ä',
825        ('"', 'E') => 'Ë',
826        ('"', 'I') => 'Ï',
827        ('"', 'O') => 'Ö',
828        ('"', 'U') => 'Ü',
829        _ => return None,
830    })
831}
832
833/// The characters that may follow `\left`, `\right`, or a `\big`-class
834/// command directly (`.` is the null delimiter, valid there too).
835pub(crate) fn char_is_delim(ch: char) -> bool {
836    matches!(ch, '(' | ')' | '[' | ']' | '|' | '/' | '<' | '>' | '.')
837}
838
839/// Parse-level support status for a construct named in the G0-4 construct
840/// table's scheme: `\frac` (control word), `\\,` -- i.e. a backslash plus
841/// one non-letter char (control symbol), `env:name`, `script:sup`,
842/// `script:sub`, `prime`, `tie`, `alignment-tab`, `math-island`, or
843/// `char:U+XXXX`.
844#[must_use]
845pub fn construct_status(construct: &str) -> ConstructStatus {
846    // Structural constructs and character coverage are parse-supported by
847    // construction.
848    match construct {
849        "script:sup" | "script:sub" | "prime" | "tie" | "alignment-tab" | "math-island" => {
850            return ConstructStatus::Supported;
851        }
852        _ => {}
853    }
854    if construct.starts_with("char:U+") {
855        return ConstructStatus::Supported;
856    }
857    if let Some(env) = construct.strip_prefix("env:") {
858        if lookup_env(env).is_some() || line_align_env(env).is_some() {
859            return ConstructStatus::Supported;
860        }
861        return ConstructStatus::Unknown;
862    }
863    if let Some(name) = construct.strip_prefix('\\') {
864        // Control symbols: a single non-letter character.
865        let mut chars = name.chars();
866        if let (Some(c), None) = (chars.next(), chars.next()) {
867            if !c.is_ascii_alphabetic() {
868                if control_symbol_is_t2(c) {
869                    return ConstructStatus::UnsupportedT2;
870                }
871                // The supported control-symbol set: escapes, spacing,
872                // the line break, the `\|` delimiter, and the graduated
873                // text accents.
874                return if matches!(
875                    c,
876                    '\\' | ','
877                        | ':'
878                        | ';'
879                        | '!'
880                        | ' '
881                        | '{'
882                        | '}'
883                        | '%'
884                        | '$'
885                        | '&'
886                        | '#'
887                        | '_'
888                        | '|'
889                        | '\''
890                        | '"'
891                ) {
892                    ConstructStatus::Supported
893                } else {
894                    ConstructStatus::Unknown
895                };
896            }
897        }
898        // The macro-definition commands are consumed by the token-level
899        // expansion pass before the grammar ever runs.
900        if matches!(name, "newcommand" | "renewcommand") {
901            return ConstructStatus::Supported;
902        }
903        return match lookup(name) {
904            Some(Cmd::UnsupportedT2) => ConstructStatus::UnsupportedT2,
905            Some(_) => ConstructStatus::Supported,
906            None => ConstructStatus::Unknown,
907        };
908    }
909    ConstructStatus::Unknown
910}
911
912#[cfg(test)]
913mod tests {
914    use super::*;
915
916    #[test]
917    fn t1_headliners_are_supported() {
918        for c in [
919            r"\frac",
920            r"\over",
921            r"\text",
922            r"\pi",
923            r"\cdot",
924            r"\left",
925            r"\right",
926            r"\sqrt",
927            r"\sum",
928            r"\mathds",
929            r"\minus",
930            r"\checkmark",
931            r"\textbf",
932            r"\emph",
933            r"\big",
934            r"\substack",
935            r"\,",
936            r"\\",
937            r"\{",
938            "env:array",
939            "env:substack",
940            "env:align*",
941            "env:cases",
942            "env:aligned",
943            "script:sup",
944            "math-island",
945            "prime",
946            "alignment-tab",
947            "tie",
948        ] {
949            assert_eq!(
950                construct_status(c),
951                ConstructStatus::Supported,
952                "expected {c} supported"
953            );
954        }
955    }
956
957    #[test]
958    fn t2_vocabulary_is_tiered() {
959        // Still pending. (The symbol shelf and the text accents graduated
960        // in the fm-j5t tranches.)
961        assert_eq!(
962            construct_status(r"\dx"),
963            ConstructStatus::UnsupportedT2,
964            r"expected \dx tier-2 (the last pending construct)"
965        );
966        for c in [
967            r"\nmid",
968            r"\oiint",
969            r"\i",
970            r"\j",
971            r"\circlearrowright",
972            r"\dddot",
973            r"\ddddot",
974            r"\xrightarrow",
975            r"\xmapsto",
976            r"\doublespacing",
977            r"\'",
978            "\\\"",
979        ] {
980            assert_eq!(
981                construct_status(c),
982                ConstructStatus::Supported,
983                "expected {c} graduated"
984            );
985        }
986    }
987
988    #[test]
989    fn astronomy_and_misc_symbols_are_supported() {
990        // fm-j5t.4: the astronomy/biology symbols, \copyright, and the
991        // pifont \ding escape left the tier-2 vocabulary.
992        for c in [
993            r"\female",
994            r"\male",
995            r"\mars",
996            r"\earth",
997            r"\copyright",
998            r"\ding",
999        ] {
1000            assert_eq!(
1001                construct_status(c),
1002                ConstructStatus::Supported,
1003                "expected {c} supported"
1004            );
1005        }
1006    }
1007
1008    #[test]
1009    fn line_alignment_constructs_are_supported() {
1010        for c in [
1011            r"\centering",
1012            "env:flushleft",
1013            "env:center",
1014            "env:flushright",
1015        ] {
1016            assert_eq!(
1017                construct_status(c),
1018                ConstructStatus::Supported,
1019                "expected {c} supported"
1020            );
1021        }
1022    }
1023
1024    #[test]
1025    fn size_declarations_are_supported() {
1026        for c in [
1027            r"\tiny",
1028            r"\footnotesize",
1029            r"\small",
1030            r"\large",
1031            r"\Large",
1032            r"\huge",
1033        ] {
1034            assert_eq!(
1035                construct_status(c),
1036                ConstructStatus::Supported,
1037                "expected {c} supported"
1038            );
1039        }
1040    }
1041
1042    #[test]
1043    fn unknown_vocabulary_is_unknown() {
1044        assert_eq!(construct_status(r"\notacommand"), ConstructStatus::Unknown);
1045        assert_eq!(construct_status("env:mystery"), ConstructStatus::Unknown);
1046    }
1047
1048    #[test]
1049    fn char_constructs_are_parse_supported() {
1050        assert_eq!(construct_status("char:U+00F6"), ConstructStatus::Supported);
1051    }
1052}