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.
764#[inline(always)]
765pub(crate) fn ding_char(code: u32) -> Option<char> {
766    match code {
767        51 => Some('✓'),
768        _ => None,
769    }
770}
771
772/// Look up an environment name.
773#[inline(always)]
774pub(crate) fn lookup_env(name: &str) -> Option<EnvDef> {
775    match name {
776        "array" => Some(EnvDef { has_spec: true }),
777        "align" | "align*" | "aligned" | "cases" | "matrix" | "pmatrix" | "bmatrix" | "Bmatrix"
778        | "vmatrix" | "Vmatrix" | "smallmatrix" | "substack" => Some(EnvDef { has_spec: false }),
779        _ => None,
780    }
781}
782
783/// The line-alignment environments (`flushleft`, `center`, `flushright`):
784/// text-mode blocks aligning their `\\`-split lines within the widest
785/// line's width.
786#[inline(always)]
787pub(crate) fn line_align_env(name: &str) -> Option<LineAlign> {
788    match name {
789        "flushleft" => Some(LineAlign::Left),
790        "center" => Some(LineAlign::Center),
791        "flushright" => Some(LineAlign::Right),
792        _ => None,
793    }
794}
795
796/// True when the control *symbol* is known tier-2 vocabulary. Empty since
797/// the text accents `\'` and `\"` graduated (fm-j5t); kept so the next
798/// tier-2 control symbol has its slot back.
799#[inline(always)]
800pub(crate) fn control_symbol_is_t2(ch: char) -> bool {
801    let _ = ch;
802    false
803}
804
805/// Compose a text accent over its base letter, to the precomposed Unicode
806/// character the bundled CM faces carry (probe-verified: the full Latin-1
807/// acute and dieresis rows exist in CM regular and italic). `None` is a
808/// precise malformed-argument error at the parse site, never silence.
809pub(crate) fn compose_text_accent(accent: char, base: char) -> Option<char> {
810    Some(match (accent, base) {
811        ('\'', 'a') => 'á',
812        ('\'', 'e') => 'é',
813        ('\'', 'i') => 'í',
814        ('\'', 'o') => 'ó',
815        ('\'', 'u') => 'ú',
816        ('\'', 'y') => 'ý',
817        ('\'', 'A') => 'Á',
818        ('\'', 'E') => 'É',
819        ('\'', 'I') => 'Í',
820        ('\'', 'O') => 'Ó',
821        ('\'', 'U') => 'Ú',
822        ('"', 'a') => 'ä',
823        ('"', 'e') => 'ë',
824        ('"', 'i') => 'ï',
825        ('"', 'o') => 'ö',
826        ('"', 'u') => 'ü',
827        ('"', 'y') => 'ÿ',
828        ('"', 'A') => 'Ä',
829        ('"', 'E') => 'Ë',
830        ('"', 'I') => 'Ï',
831        ('"', 'O') => 'Ö',
832        ('"', 'U') => 'Ü',
833        _ => return None,
834    })
835}
836
837/// The characters that may follow `\left`, `\right`, or a `\big`-class
838/// command directly (`.` is the null delimiter, valid there too).
839pub(crate) fn char_is_delim(ch: char) -> bool {
840    matches!(ch, '(' | ')' | '[' | ']' | '|' | '/' | '<' | '>' | '.')
841}
842
843/// Parse-level support status for a construct named in the G0-4 construct
844/// table's scheme: `\frac` (control word), `\\,` -- i.e. a backslash plus
845/// one non-letter char (control symbol), `env:name`, `script:sup`,
846/// `script:sub`, `prime`, `tie`, `alignment-tab`, `math-island`, or
847/// `char:U+XXXX`.
848#[must_use]
849pub fn construct_status(construct: &str) -> ConstructStatus {
850    // Structural constructs and character coverage are parse-supported by
851    // construction.
852    match construct {
853        "script:sup" | "script:sub" | "prime" | "tie" | "alignment-tab" | "math-island" => {
854            return ConstructStatus::Supported;
855        }
856        _ => {}
857    }
858    if construct.starts_with("char:U+") {
859        return ConstructStatus::Supported;
860    }
861    if let Some(env) = construct.strip_prefix("env:") {
862        if lookup_env(env).is_some() || line_align_env(env).is_some() {
863            return ConstructStatus::Supported;
864        }
865        return ConstructStatus::Unknown;
866    }
867    if let Some(name) = construct.strip_prefix('\\') {
868        // Control symbols: a single non-letter character.
869        let mut chars = name.chars();
870        if let (Some(c), None) = (chars.next(), chars.next()) {
871            if !c.is_ascii_alphabetic() {
872                if control_symbol_is_t2(c) {
873                    return ConstructStatus::UnsupportedT2;
874                }
875                // The supported control-symbol set: escapes, spacing,
876                // the line break, the `\|` delimiter, and the graduated
877                // text accents.
878                return if matches!(
879                    c,
880                    '\\' | ','
881                        | ':'
882                        | ';'
883                        | '!'
884                        | ' '
885                        | '{'
886                        | '}'
887                        | '%'
888                        | '$'
889                        | '&'
890                        | '#'
891                        | '_'
892                        | '|'
893                        | '\''
894                        | '"'
895                ) {
896                    ConstructStatus::Supported
897                } else {
898                    ConstructStatus::Unknown
899                };
900            }
901        }
902        // The macro-definition commands are consumed by the token-level
903        // expansion pass before the grammar ever runs.
904        if matches!(name, "newcommand" | "renewcommand") {
905            return ConstructStatus::Supported;
906        }
907        return match lookup(name) {
908            Some(Cmd::UnsupportedT2) => ConstructStatus::UnsupportedT2,
909            Some(_) => ConstructStatus::Supported,
910            None => ConstructStatus::Unknown,
911        };
912    }
913    ConstructStatus::Unknown
914}
915
916#[cfg(test)]
917mod tests {
918    use super::*;
919
920    #[test]
921    fn t1_headliners_are_supported() {
922        for c in [
923            r"\frac",
924            r"\over",
925            r"\text",
926            r"\pi",
927            r"\cdot",
928            r"\left",
929            r"\right",
930            r"\sqrt",
931            r"\sum",
932            r"\mathds",
933            r"\minus",
934            r"\checkmark",
935            r"\textbf",
936            r"\emph",
937            r"\big",
938            r"\substack",
939            r"\,",
940            r"\\",
941            r"\{",
942            "env:array",
943            "env:substack",
944            "env:align*",
945            "env:cases",
946            "env:aligned",
947            "script:sup",
948            "math-island",
949            "prime",
950            "alignment-tab",
951            "tie",
952        ] {
953            assert_eq!(
954                construct_status(c),
955                ConstructStatus::Supported,
956                "expected {c} supported"
957            );
958        }
959    }
960
961    #[test]
962    fn t2_vocabulary_is_tiered() {
963        // Still pending. (The symbol shelf and the text accents graduated
964        // in the fm-j5t tranches.)
965        assert_eq!(
966            construct_status(r"\dx"),
967            ConstructStatus::UnsupportedT2,
968            r"expected \dx tier-2 (the last pending construct)"
969        );
970        for c in [
971            r"\nmid",
972            r"\oiint",
973            r"\i",
974            r"\j",
975            r"\circlearrowright",
976            r"\dddot",
977            r"\ddddot",
978            r"\xrightarrow",
979            r"\xmapsto",
980            r"\doublespacing",
981            r"\'",
982            "\\\"",
983        ] {
984            assert_eq!(
985                construct_status(c),
986                ConstructStatus::Supported,
987                "expected {c} graduated"
988            );
989        }
990    }
991
992    #[test]
993    fn astronomy_and_misc_symbols_are_supported() {
994        // fm-j5t.4: the astronomy/biology symbols, \copyright, and the
995        // pifont \ding escape left the tier-2 vocabulary.
996        for c in [
997            r"\female",
998            r"\male",
999            r"\mars",
1000            r"\earth",
1001            r"\copyright",
1002            r"\ding",
1003        ] {
1004            assert_eq!(
1005                construct_status(c),
1006                ConstructStatus::Supported,
1007                "expected {c} supported"
1008            );
1009        }
1010    }
1011
1012    #[test]
1013    fn line_alignment_constructs_are_supported() {
1014        for c in [
1015            r"\centering",
1016            "env:flushleft",
1017            "env:center",
1018            "env:flushright",
1019        ] {
1020            assert_eq!(
1021                construct_status(c),
1022                ConstructStatus::Supported,
1023                "expected {c} supported"
1024            );
1025        }
1026    }
1027
1028    #[test]
1029    fn size_declarations_are_supported() {
1030        for c in [
1031            r"\tiny",
1032            r"\footnotesize",
1033            r"\small",
1034            r"\large",
1035            r"\Large",
1036            r"\huge",
1037        ] {
1038            assert_eq!(
1039                construct_status(c),
1040                ConstructStatus::Supported,
1041                "expected {c} supported"
1042            );
1043        }
1044    }
1045
1046    #[test]
1047    fn unknown_vocabulary_is_unknown() {
1048        assert_eq!(construct_status(r"\notacommand"), ConstructStatus::Unknown);
1049        assert_eq!(construct_status("env:mystery"), ConstructStatus::Unknown);
1050    }
1051
1052    #[test]
1053    fn char_constructs_are_parse_supported() {
1054        assert_eq!(construct_status("char:U+00F6"), ConstructStatus::Supported);
1055    }
1056}