Skip to main content

ratex_parser/
parse_node.rs

1use ratex_lexer::token::SourceLocation;
2use serde::{Deserialize, Serialize};
3
4/// Mode of the parser: math or text. Matches KaTeX's Mode type.
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
6#[serde(rename_all = "lowercase")]
7pub enum Mode {
8    Math,
9    Text,
10}
11
12/// Style string for \displaystyle, \textstyle etc.
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
14#[serde(rename_all = "lowercase")]
15pub enum StyleStr {
16    Display,
17    Text,
18    Script,
19    Scriptscript,
20}
21
22/// Atom family: determines spacing behavior. Matches KaTeX's Atom type.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "lowercase")]
25pub enum AtomFamily {
26    Bin,
27    Close,
28    Inner,
29    Open,
30    Punct,
31    Rel,
32}
33
34/// A measurement with number and unit (e.g., "3pt", "1em").
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct Measurement {
37    pub number: f64,
38    pub unit: String,
39}
40
41/// Column alignment spec for array environments.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct AlignSpec {
44    #[serde(rename = "type")]
45    pub align_type: AlignType,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub align: Option<String>,
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub pregap: Option<f64>,
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub postgap: Option<f64>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55#[serde(rename_all = "lowercase")]
56pub enum AlignType {
57    Align,
58    Separator,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub enum ProofLineStyle {
64    Solid,
65    Dashed,
66    None,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct ProofBranch {
71    pub conclusion: Vec<ParseNode>,
72    pub premises: Vec<ProofBranch>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    #[serde(rename = "leftLabel")]
75    pub left_label: Option<Vec<ParseNode>>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    #[serde(rename = "rightLabel")]
78    pub right_label: Option<Vec<ParseNode>>,
79    #[serde(rename = "lineStyle")]
80    pub line_style: ProofLineStyle,
81    #[serde(rename = "rootAtTop", skip_serializing_if = "is_false")]
82    pub root_at_top: bool,
83}
84
85fn is_false(b: &bool) -> bool {
86    !*b
87}
88
89/// The main AST node type. Each variant corresponds to a KaTeX ParseNode type.
90///
91/// Serializes to JSON with `"type": "variant_name"` to match KaTeX's format,
92/// enabling direct structural comparison.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94#[serde(tag = "type")]
95pub enum ParseNode {
96    // =========================================================================
97    // Symbol-based nodes (from symbols.js lookup in Parser.parseSymbol)
98    // =========================================================================
99    #[serde(rename = "atom")]
100    Atom {
101        mode: Mode,
102        family: AtomFamily,
103        text: String,
104        #[serde(skip_serializing_if = "Option::is_none")]
105        loc: Option<SourceLocation>,
106    },
107
108    #[serde(rename = "mathord")]
109    MathOrd {
110        mode: Mode,
111        text: String,
112        #[serde(skip_serializing_if = "Option::is_none")]
113        loc: Option<SourceLocation>,
114    },
115
116    #[serde(rename = "textord")]
117    TextOrd {
118        mode: Mode,
119        text: String,
120        #[serde(skip_serializing_if = "Option::is_none")]
121        loc: Option<SourceLocation>,
122    },
123
124    #[serde(rename = "op-token")]
125    OpToken {
126        mode: Mode,
127        text: String,
128        #[serde(skip_serializing_if = "Option::is_none")]
129        loc: Option<SourceLocation>,
130    },
131
132    #[serde(rename = "accent-token")]
133    AccentToken {
134        mode: Mode,
135        text: String,
136        #[serde(skip_serializing_if = "Option::is_none")]
137        loc: Option<SourceLocation>,
138    },
139
140    #[serde(rename = "spacing")]
141    SpacingNode {
142        mode: Mode,
143        text: String,
144        #[serde(skip_serializing_if = "Option::is_none")]
145        loc: Option<SourceLocation>,
146    },
147
148    // =========================================================================
149    // Structural nodes
150    // =========================================================================
151    #[serde(rename = "ordgroup")]
152    OrdGroup {
153        mode: Mode,
154        body: Vec<ParseNode>,
155        #[serde(skip_serializing_if = "Option::is_none")]
156        semisimple: Option<bool>,
157        #[serde(skip_serializing_if = "Option::is_none")]
158        loc: Option<SourceLocation>,
159    },
160
161    #[serde(rename = "supsub")]
162    SupSub {
163        mode: Mode,
164        #[serde(skip_serializing_if = "Option::is_none")]
165        base: Option<Box<ParseNode>>,
166        #[serde(skip_serializing_if = "Option::is_none")]
167        sup: Option<Box<ParseNode>>,
168        #[serde(skip_serializing_if = "Option::is_none")]
169        sub: Option<Box<ParseNode>>,
170        #[serde(skip_serializing_if = "Option::is_none")]
171        loc: Option<SourceLocation>,
172    },
173
174    // =========================================================================
175    // Function-generated nodes
176    // =========================================================================
177    #[serde(rename = "genfrac")]
178    GenFrac {
179        mode: Mode,
180        continued: bool,
181        numer: Box<ParseNode>,
182        denom: Box<ParseNode>,
183        #[serde(rename = "hasBarLine")]
184        has_bar_line: bool,
185        #[serde(rename = "leftDelim")]
186        left_delim: Option<String>,
187        #[serde(rename = "rightDelim")]
188        right_delim: Option<String>,
189        #[serde(rename = "barSize")]
190        bar_size: Option<Measurement>,
191        #[serde(skip_serializing_if = "Option::is_none")]
192        loc: Option<SourceLocation>,
193    },
194
195    #[serde(rename = "sqrt")]
196    Sqrt {
197        mode: Mode,
198        body: Box<ParseNode>,
199        #[serde(skip_serializing_if = "Option::is_none")]
200        index: Option<Box<ParseNode>>,
201        #[serde(skip_serializing_if = "Option::is_none")]
202        loc: Option<SourceLocation>,
203    },
204
205    #[serde(rename = "accent")]
206    Accent {
207        mode: Mode,
208        label: String,
209        #[serde(rename = "isStretchy")]
210        #[serde(skip_serializing_if = "Option::is_none")]
211        is_stretchy: Option<bool>,
212        #[serde(rename = "isShifty")]
213        #[serde(skip_serializing_if = "Option::is_none")]
214        is_shifty: Option<bool>,
215        base: Box<ParseNode>,
216        #[serde(skip_serializing_if = "Option::is_none")]
217        loc: Option<SourceLocation>,
218    },
219
220    #[serde(rename = "accentUnder")]
221    AccentUnder {
222        mode: Mode,
223        label: String,
224        #[serde(rename = "isStretchy")]
225        #[serde(skip_serializing_if = "Option::is_none")]
226        is_stretchy: Option<bool>,
227        #[serde(rename = "isShifty")]
228        #[serde(skip_serializing_if = "Option::is_none")]
229        is_shifty: Option<bool>,
230        base: Box<ParseNode>,
231        #[serde(skip_serializing_if = "Option::is_none")]
232        loc: Option<SourceLocation>,
233    },
234
235    #[serde(rename = "op")]
236    Op {
237        mode: Mode,
238        limits: bool,
239        #[serde(rename = "alwaysHandleSupSub")]
240        #[serde(skip_serializing_if = "Option::is_none")]
241        always_handle_sup_sub: Option<bool>,
242        #[serde(rename = "suppressBaseShift")]
243        #[serde(skip_serializing_if = "Option::is_none")]
244        suppress_base_shift: Option<bool>,
245        #[serde(rename = "parentIsSupSub")]
246        parent_is_sup_sub: bool,
247        symbol: bool,
248        #[serde(skip_serializing_if = "Option::is_none")]
249        name: Option<String>,
250        #[serde(skip_serializing_if = "Option::is_none")]
251        body: Option<Vec<ParseNode>>,
252        #[serde(skip_serializing_if = "Option::is_none")]
253        loc: Option<SourceLocation>,
254    },
255
256    #[serde(rename = "operatorname")]
257    OperatorName {
258        mode: Mode,
259        body: Vec<ParseNode>,
260        #[serde(rename = "alwaysHandleSupSub")]
261        always_handle_sup_sub: bool,
262        limits: bool,
263        #[serde(rename = "parentIsSupSub")]
264        parent_is_sup_sub: bool,
265        #[serde(skip_serializing_if = "Option::is_none")]
266        loc: Option<SourceLocation>,
267    },
268
269    #[serde(rename = "font")]
270    Font {
271        mode: Mode,
272        font: String,
273        body: Box<ParseNode>,
274        #[serde(skip_serializing_if = "Option::is_none")]
275        loc: Option<SourceLocation>,
276    },
277
278    #[serde(rename = "text")]
279    Text {
280        mode: Mode,
281        body: Vec<ParseNode>,
282        #[serde(skip_serializing_if = "Option::is_none")]
283        font: Option<String>,
284        #[serde(skip_serializing_if = "Option::is_none")]
285        loc: Option<SourceLocation>,
286    },
287
288    #[serde(rename = "color")]
289    Color {
290        mode: Mode,
291        color: String,
292        body: Vec<ParseNode>,
293        #[serde(skip_serializing_if = "Option::is_none")]
294        loc: Option<SourceLocation>,
295    },
296
297    #[serde(rename = "color-token")]
298    ColorToken {
299        mode: Mode,
300        color: String,
301        #[serde(skip_serializing_if = "Option::is_none")]
302        loc: Option<SourceLocation>,
303    },
304
305    #[serde(rename = "size")]
306    Size {
307        mode: Mode,
308        value: Measurement,
309        #[serde(rename = "isBlank")]
310        is_blank: bool,
311        #[serde(skip_serializing_if = "Option::is_none")]
312        loc: Option<SourceLocation>,
313    },
314
315    #[serde(rename = "styling")]
316    Styling {
317        mode: Mode,
318        style: StyleStr,
319        body: Vec<ParseNode>,
320        #[serde(skip_serializing_if = "Option::is_none")]
321        loc: Option<SourceLocation>,
322    },
323
324    #[serde(rename = "sizing")]
325    Sizing {
326        mode: Mode,
327        size: u8,
328        body: Vec<ParseNode>,
329        #[serde(skip_serializing_if = "Option::is_none")]
330        loc: Option<SourceLocation>,
331    },
332
333    #[serde(rename = "delimsizing")]
334    DelimSizing {
335        mode: Mode,
336        size: u8,
337        mclass: String,
338        delim: String,
339        #[serde(skip_serializing_if = "Option::is_none")]
340        loc: Option<SourceLocation>,
341    },
342
343    #[serde(rename = "leftright")]
344    LeftRight {
345        mode: Mode,
346        body: Vec<ParseNode>,
347        left: String,
348        right: String,
349        #[serde(rename = "rightColor")]
350        #[serde(skip_serializing_if = "Option::is_none")]
351        right_color: Option<String>,
352        #[serde(skip_serializing_if = "Option::is_none")]
353        loc: Option<SourceLocation>,
354    },
355
356    #[serde(rename = "leftright-right")]
357    LeftRightRight {
358        mode: Mode,
359        delim: String,
360        #[serde(skip_serializing_if = "Option::is_none")]
361        color: Option<String>,
362        #[serde(skip_serializing_if = "Option::is_none")]
363        loc: Option<SourceLocation>,
364    },
365
366    #[serde(rename = "middle")]
367    Middle {
368        mode: Mode,
369        delim: String,
370        #[serde(skip_serializing_if = "Option::is_none")]
371        loc: Option<SourceLocation>,
372    },
373
374    #[serde(rename = "overline")]
375    Overline {
376        mode: Mode,
377        body: Box<ParseNode>,
378        #[serde(skip_serializing_if = "Option::is_none")]
379        loc: Option<SourceLocation>,
380    },
381
382    #[serde(rename = "underline")]
383    Underline {
384        mode: Mode,
385        body: Box<ParseNode>,
386        #[serde(skip_serializing_if = "Option::is_none")]
387        loc: Option<SourceLocation>,
388    },
389
390    #[serde(rename = "rule")]
391    Rule {
392        mode: Mode,
393        #[serde(skip_serializing_if = "Option::is_none")]
394        shift: Option<Measurement>,
395        width: Measurement,
396        height: Measurement,
397        #[serde(skip_serializing_if = "Option::is_none")]
398        loc: Option<SourceLocation>,
399    },
400
401    #[serde(rename = "kern")]
402    Kern {
403        mode: Mode,
404        dimension: Measurement,
405        #[serde(skip_serializing_if = "Option::is_none")]
406        loc: Option<SourceLocation>,
407    },
408
409    #[serde(rename = "phantom")]
410    Phantom {
411        mode: Mode,
412        body: Vec<ParseNode>,
413        #[serde(skip_serializing_if = "Option::is_none")]
414        loc: Option<SourceLocation>,
415    },
416
417    #[serde(rename = "vphantom")]
418    VPhantom {
419        mode: Mode,
420        body: Box<ParseNode>,
421        #[serde(skip_serializing_if = "Option::is_none")]
422        loc: Option<SourceLocation>,
423    },
424
425    #[serde(rename = "smash")]
426    Smash {
427        mode: Mode,
428        body: Box<ParseNode>,
429        #[serde(rename = "smashHeight")]
430        smash_height: bool,
431        #[serde(rename = "smashDepth")]
432        smash_depth: bool,
433        #[serde(skip_serializing_if = "Option::is_none")]
434        loc: Option<SourceLocation>,
435    },
436
437    #[serde(rename = "mclass")]
438    MClass {
439        mode: Mode,
440        mclass: String,
441        body: Vec<ParseNode>,
442        #[serde(rename = "isCharacterBox")]
443        is_character_box: bool,
444        #[serde(skip_serializing_if = "Option::is_none")]
445        loc: Option<SourceLocation>,
446    },
447
448    #[serde(rename = "array")]
449    Array {
450        mode: Mode,
451        body: Vec<Vec<ParseNode>>,
452        #[serde(rename = "rowGaps")]
453        row_gaps: Vec<Option<Measurement>>,
454        #[serde(rename = "hLinesBeforeRow")]
455        hlines_before_row: Vec<Vec<bool>>,
456        #[serde(skip_serializing_if = "Option::is_none")]
457        cols: Option<Vec<AlignSpec>>,
458        #[serde(skip_serializing_if = "Option::is_none")]
459        #[serde(rename = "colSeparationType")]
460        col_separation_type: Option<String>,
461        #[serde(skip_serializing_if = "Option::is_none")]
462        #[serde(rename = "hskipBeforeAndAfter")]
463        hskip_before_and_after: Option<bool>,
464        #[serde(skip_serializing_if = "Option::is_none")]
465        #[serde(rename = "addJot")]
466        add_jot: Option<bool>,
467        #[serde(default = "default_arraystretch")]
468        arraystretch: f64,
469        #[serde(skip_serializing_if = "Option::is_none")]
470        tags: Option<Vec<ArrayTag>>,
471        #[serde(skip_serializing_if = "Option::is_none")]
472        leqno: Option<bool>,
473        #[serde(skip_serializing_if = "Option::is_none")]
474        #[serde(rename = "isCD")]
475        is_cd: Option<bool>,
476        #[serde(skip_serializing_if = "Option::is_none")]
477        loc: Option<SourceLocation>,
478    },
479
480    #[serde(rename = "environment")]
481    Environment {
482        mode: Mode,
483        name: String,
484        #[serde(rename = "nameGroup")]
485        name_group: Box<ParseNode>,
486        #[serde(skip_serializing_if = "Option::is_none")]
487        loc: Option<SourceLocation>,
488    },
489
490    #[serde(rename = "cr")]
491    Cr {
492        mode: Mode,
493        #[serde(rename = "newLine")]
494        new_line: bool,
495        #[serde(skip_serializing_if = "Option::is_none")]
496        size: Option<Measurement>,
497        #[serde(skip_serializing_if = "Option::is_none")]
498        loc: Option<SourceLocation>,
499    },
500
501    #[serde(rename = "infix")]
502    Infix {
503        mode: Mode,
504        #[serde(rename = "replaceWith")]
505        replace_with: String,
506        #[serde(skip_serializing_if = "Option::is_none")]
507        size: Option<Measurement>,
508        #[serde(skip_serializing_if = "Option::is_none")]
509        loc: Option<SourceLocation>,
510    },
511
512    #[serde(rename = "internal")]
513    Internal {
514        mode: Mode,
515        #[serde(skip_serializing_if = "Option::is_none")]
516        loc: Option<SourceLocation>,
517    },
518
519    #[serde(rename = "verb")]
520    Verb {
521        mode: Mode,
522        body: String,
523        star: bool,
524        #[serde(skip_serializing_if = "Option::is_none")]
525        loc: Option<SourceLocation>,
526    },
527
528    #[serde(rename = "href")]
529    Href {
530        mode: Mode,
531        href: String,
532        body: Vec<ParseNode>,
533        #[serde(skip_serializing_if = "Option::is_none")]
534        loc: Option<SourceLocation>,
535    },
536
537    #[serde(rename = "url")]
538    Url {
539        mode: Mode,
540        url: String,
541        #[serde(skip_serializing_if = "Option::is_none")]
542        loc: Option<SourceLocation>,
543    },
544
545    #[serde(rename = "raw")]
546    Raw {
547        mode: Mode,
548        string: String,
549        #[serde(skip_serializing_if = "Option::is_none")]
550        loc: Option<SourceLocation>,
551    },
552
553    #[serde(rename = "hbox")]
554    HBox {
555        mode: Mode,
556        body: Vec<ParseNode>,
557        #[serde(skip_serializing_if = "Option::is_none")]
558        loc: Option<SourceLocation>,
559    },
560
561    #[serde(rename = "horizBrace")]
562    HorizBrace {
563        mode: Mode,
564        label: String,
565        #[serde(rename = "isOver")]
566        is_over: bool,
567        base: Box<ParseNode>,
568        #[serde(skip_serializing_if = "Option::is_none")]
569        loc: Option<SourceLocation>,
570    },
571
572    #[serde(rename = "enclose")]
573    Enclose {
574        mode: Mode,
575        label: String,
576        #[serde(rename = "backgroundColor")]
577        #[serde(skip_serializing_if = "Option::is_none")]
578        background_color: Option<String>,
579        #[serde(rename = "borderColor")]
580        #[serde(skip_serializing_if = "Option::is_none")]
581        border_color: Option<String>,
582        body: Box<ParseNode>,
583        #[serde(skip_serializing_if = "Option::is_none")]
584        loc: Option<SourceLocation>,
585    },
586
587    #[serde(rename = "lap")]
588    Lap {
589        mode: Mode,
590        alignment: String,
591        body: Box<ParseNode>,
592        #[serde(skip_serializing_if = "Option::is_none")]
593        loc: Option<SourceLocation>,
594    },
595
596    #[serde(rename = "mathchoice")]
597    MathChoice {
598        mode: Mode,
599        display: Vec<ParseNode>,
600        text: Vec<ParseNode>,
601        script: Vec<ParseNode>,
602        scriptscript: Vec<ParseNode>,
603        #[serde(skip_serializing_if = "Option::is_none")]
604        loc: Option<SourceLocation>,
605    },
606
607    #[serde(rename = "raisebox")]
608    RaiseBox {
609        mode: Mode,
610        dy: Measurement,
611        body: Box<ParseNode>,
612        #[serde(skip_serializing_if = "Option::is_none")]
613        loc: Option<SourceLocation>,
614    },
615
616    #[serde(rename = "vcenter")]
617    VCenter {
618        mode: Mode,
619        body: Box<ParseNode>,
620        #[serde(skip_serializing_if = "Option::is_none")]
621        loc: Option<SourceLocation>,
622    },
623
624    #[serde(rename = "xArrow")]
625    XArrow {
626        mode: Mode,
627        label: String,
628        body: Box<ParseNode>,
629        #[serde(skip_serializing_if = "Option::is_none")]
630        below: Option<Box<ParseNode>>,
631        #[serde(skip_serializing_if = "Option::is_none")]
632        loc: Option<SourceLocation>,
633    },
634
635    #[serde(rename = "pmb")]
636    Pmb {
637        mode: Mode,
638        mclass: String,
639        body: Vec<ParseNode>,
640        #[serde(skip_serializing_if = "Option::is_none")]
641        loc: Option<SourceLocation>,
642    },
643
644    #[serde(rename = "tag")]
645    Tag {
646        mode: Mode,
647        body: Vec<ParseNode>,
648        tag: Vec<ParseNode>,
649        #[serde(skip_serializing_if = "Option::is_none")]
650        loc: Option<SourceLocation>,
651    },
652
653    #[serde(rename = "nonumber")]
654    NoNumber {
655        mode: Mode,
656        #[serde(skip_serializing_if = "Option::is_none")]
657        loc: Option<SourceLocation>,
658    },
659
660    #[serde(rename = "html")]
661    Html {
662        mode: Mode,
663        attributes: std::collections::HashMap<String, String>,
664        body: Vec<ParseNode>,
665        #[serde(skip_serializing_if = "Option::is_none")]
666        loc: Option<SourceLocation>,
667    },
668
669    #[serde(rename = "htmlmathml")]
670    HtmlMathMl {
671        mode: Mode,
672        html: Vec<ParseNode>,
673        mathml: Vec<ParseNode>,
674        #[serde(skip_serializing_if = "Option::is_none")]
675        loc: Option<SourceLocation>,
676    },
677
678    #[serde(rename = "includegraphics")]
679    IncludeGraphics {
680        mode: Mode,
681        alt: String,
682        width: Measurement,
683        height: Measurement,
684        totalheight: Measurement,
685        src: String,
686        #[serde(skip_serializing_if = "Option::is_none")]
687        loc: Option<SourceLocation>,
688    },
689
690    #[serde(rename = "cdlabel")]
691    CdLabel {
692        mode: Mode,
693        side: String,
694        label: Box<ParseNode>,
695        #[serde(skip_serializing_if = "Option::is_none")]
696        loc: Option<SourceLocation>,
697    },
698
699    #[serde(rename = "cdlabelparent")]
700    CdLabelParent {
701        mode: Mode,
702        fragment: Box<ParseNode>,
703        #[serde(skip_serializing_if = "Option::is_none")]
704        loc: Option<SourceLocation>,
705    },
706
707    #[serde(rename = "cdArrow")]
708    CdArrow {
709        mode: Mode,
710        /// "right", "left", "up", "down", "horiz_eq", "vert_eq", "none"
711        direction: String,
712        /// For right/left arrows: label above the shaft.
713        /// For up/down arrows: label to the left of the shaft.
714        #[serde(skip_serializing_if = "Option::is_none")]
715        label_above: Option<Box<ParseNode>>,
716        /// For right/left arrows: label below the shaft.
717        /// For up/down arrows: label to the right of the shaft.
718        #[serde(skip_serializing_if = "Option::is_none")]
719        label_below: Option<Box<ParseNode>>,
720        #[serde(skip_serializing_if = "Option::is_none")]
721        loc: Option<SourceLocation>,
722    },
723
724    #[serde(rename = "proofTree")]
725    ProofTree {
726        mode: Mode,
727        tree: ProofBranch,
728        #[serde(skip_serializing_if = "Option::is_none")]
729        loc: Option<SourceLocation>,
730    },
731}
732
733fn default_arraystretch() -> f64 {
734    1.0
735}
736
737/// Tag variant for array rows: either auto-numbered (bool) or explicit tag.
738#[derive(Debug, Clone, Serialize, Deserialize)]
739#[serde(untagged)]
740pub enum ArrayTag {
741    Auto(bool),
742    Explicit(Vec<ParseNode>),
743}
744
745// ── Helper methods ──────────────────────────────────────────────────────────
746
747impl ParseNode {
748    pub fn mode(&self) -> Mode {
749        match self {
750            Self::Atom { mode, .. }
751            | Self::MathOrd { mode, .. }
752            | Self::TextOrd { mode, .. }
753            | Self::OpToken { mode, .. }
754            | Self::AccentToken { mode, .. }
755            | Self::SpacingNode { mode, .. }
756            | Self::OrdGroup { mode, .. }
757            | Self::SupSub { mode, .. }
758            | Self::GenFrac { mode, .. }
759            | Self::Sqrt { mode, .. }
760            | Self::Accent { mode, .. }
761            | Self::AccentUnder { mode, .. }
762            | Self::Op { mode, .. }
763            | Self::OperatorName { mode, .. }
764            | Self::Font { mode, .. }
765            | Self::Text { mode, .. }
766            | Self::Color { mode, .. }
767            | Self::ColorToken { mode, .. }
768            | Self::Size { mode, .. }
769            | Self::Styling { mode, .. }
770            | Self::Sizing { mode, .. }
771            | Self::DelimSizing { mode, .. }
772            | Self::LeftRight { mode, .. }
773            | Self::LeftRightRight { mode, .. }
774            | Self::Middle { mode, .. }
775            | Self::Overline { mode, .. }
776            | Self::Underline { mode, .. }
777            | Self::Rule { mode, .. }
778            | Self::Kern { mode, .. }
779            | Self::Phantom { mode, .. }
780            | Self::VPhantom { mode, .. }
781            | Self::Smash { mode, .. }
782            | Self::MClass { mode, .. }
783            | Self::Array { mode, .. }
784            | Self::Environment { mode, .. }
785            | Self::Cr { mode, .. }
786            | Self::Infix { mode, .. }
787            | Self::Internal { mode, .. }
788            | Self::Verb { mode, .. }
789            | Self::Href { mode, .. }
790            | Self::Url { mode, .. }
791            | Self::Raw { mode, .. }
792            | Self::HBox { mode, .. }
793            | Self::HorizBrace { mode, .. }
794            | Self::Enclose { mode, .. }
795            | Self::Lap { mode, .. }
796            | Self::MathChoice { mode, .. }
797            | Self::RaiseBox { mode, .. }
798            | Self::VCenter { mode, .. }
799            | Self::XArrow { mode, .. }
800            | Self::Pmb { mode, .. }
801            | Self::Tag { mode, .. }
802            | Self::NoNumber { mode, .. }
803            | Self::Html { mode, .. }
804            | Self::HtmlMathMl { mode, .. }
805            | Self::IncludeGraphics { mode, .. }
806            | Self::CdLabel { mode, .. }
807            | Self::CdLabelParent { mode, .. }
808            | Self::CdArrow { mode, .. }
809            | Self::ProofTree { mode, .. } => *mode,
810        }
811    }
812
813    pub fn type_name(&self) -> &'static str {
814        match self {
815            Self::Atom { .. } => "atom",
816            Self::MathOrd { .. } => "mathord",
817            Self::TextOrd { .. } => "textord",
818            Self::OpToken { .. } => "op-token",
819            Self::AccentToken { .. } => "accent-token",
820            Self::SpacingNode { .. } => "spacing",
821            Self::OrdGroup { .. } => "ordgroup",
822            Self::SupSub { .. } => "supsub",
823            Self::GenFrac { .. } => "genfrac",
824            Self::Sqrt { .. } => "sqrt",
825            Self::Accent { .. } => "accent",
826            Self::AccentUnder { .. } => "accentUnder",
827            Self::Op { .. } => "op",
828            Self::OperatorName { .. } => "operatorname",
829            Self::Font { .. } => "font",
830            Self::Text { .. } => "text",
831            Self::Color { .. } => "color",
832            Self::ColorToken { .. } => "color-token",
833            Self::Size { .. } => "size",
834            Self::Styling { .. } => "styling",
835            Self::Sizing { .. } => "sizing",
836            Self::DelimSizing { .. } => "delimsizing",
837            Self::LeftRight { .. } => "leftright",
838            Self::LeftRightRight { .. } => "leftright-right",
839            Self::Middle { .. } => "middle",
840            Self::Overline { .. } => "overline",
841            Self::Underline { .. } => "underline",
842            Self::Rule { .. } => "rule",
843            Self::Kern { .. } => "kern",
844            Self::Phantom { .. } => "phantom",
845            Self::VPhantom { .. } => "vphantom",
846            Self::Smash { .. } => "smash",
847            Self::MClass { .. } => "mclass",
848            Self::Array { .. } => "array",
849            Self::Environment { .. } => "environment",
850            Self::Cr { .. } => "cr",
851            Self::Infix { .. } => "infix",
852            Self::Internal { .. } => "internal",
853            Self::Verb { .. } => "verb",
854            Self::Href { .. } => "href",
855            Self::Url { .. } => "url",
856            Self::Raw { .. } => "raw",
857            Self::HBox { .. } => "hbox",
858            Self::HorizBrace { .. } => "horizBrace",
859            Self::Enclose { .. } => "enclose",
860            Self::Lap { .. } => "lap",
861            Self::MathChoice { .. } => "mathchoice",
862            Self::RaiseBox { .. } => "raisebox",
863            Self::VCenter { .. } => "vcenter",
864            Self::XArrow { .. } => "xArrow",
865            Self::Pmb { .. } => "pmb",
866            Self::Tag { .. } => "tag",
867            Self::NoNumber { .. } => "nonumber",
868            Self::Html { .. } => "html",
869            Self::HtmlMathMl { .. } => "htmlmathml",
870            Self::IncludeGraphics { .. } => "includegraphics",
871            Self::CdLabel { .. } => "cdlabel",
872            Self::CdLabelParent { .. } => "cdlabelparent",
873            Self::CdArrow { .. } => "cdArrow",
874            Self::ProofTree { .. } => "proofTree",
875        }
876    }
877
878    /// Check if this node is a symbol node (atom or non-atom symbol).
879    pub fn is_symbol_node(&self) -> bool {
880        matches!(
881            self,
882            Self::Atom { .. }
883                | Self::MathOrd { .. }
884                | Self::TextOrd { .. }
885                | Self::OpToken { .. }
886                | Self::AccentToken { .. }
887                | Self::SpacingNode { .. }
888        )
889    }
890
891    /// Get the text of a symbol node.
892    pub fn symbol_text(&self) -> Option<&str> {
893        match self {
894            Self::Atom { text, .. }
895            | Self::MathOrd { text, .. }
896            | Self::TextOrd { text, .. }
897            | Self::OpToken { text, .. }
898            | Self::AccentToken { text, .. }
899            | Self::SpacingNode { text, .. } => Some(text),
900            _ => None,
901        }
902    }
903
904    /// Normalize an argument: if it's an ordgroup with a single element, unwrap it.
905    pub fn normalize_argument(arg: ParseNode) -> ParseNode {
906        if let ParseNode::OrdGroup { body, .. } = &arg {
907            if body.len() == 1 {
908                return body[0].clone();
909            }
910        }
911        arg
912    }
913
914    /// Convert an argument to a list: if ordgroup, return body; otherwise wrap in vec.
915    pub fn ord_argument(arg: ParseNode) -> Vec<ParseNode> {
916        if let ParseNode::OrdGroup { body, .. } = arg {
917            body
918        } else {
919            vec![arg]
920        }
921    }
922}
923
924#[cfg(test)]
925mod tests {
926    use super::*;
927
928    #[test]
929    fn test_serialize_mathord() {
930        let node = ParseNode::MathOrd {
931            mode: Mode::Math,
932            text: "x".to_string(),
933            loc: None,
934        };
935        let json = serde_json::to_string(&node).unwrap();
936        assert!(json.contains(r#""type":"mathord""#));
937        assert!(json.contains(r#""mode":"math""#));
938        assert!(json.contains(r#""text":"x""#));
939    }
940
941    #[test]
942    fn test_serialize_ordgroup() {
943        let node = ParseNode::OrdGroup {
944            mode: Mode::Math,
945            body: vec![
946                ParseNode::MathOrd {
947                    mode: Mode::Math,
948                    text: "a".to_string(),
949                    loc: None,
950                },
951            ],
952            semisimple: None,
953            loc: None,
954        };
955        let json = serde_json::to_string(&node).unwrap();
956        assert!(json.contains(r#""type":"ordgroup""#));
957    }
958
959    #[test]
960    fn test_serialize_supsub() {
961        let node = ParseNode::SupSub {
962            mode: Mode::Math,
963            base: Some(Box::new(ParseNode::MathOrd {
964                mode: Mode::Math,
965                text: "x".to_string(),
966                loc: None,
967            })),
968            sup: Some(Box::new(ParseNode::TextOrd {
969                mode: Mode::Math,
970                text: "2".to_string(),
971                loc: None,
972            })),
973            sub: None,
974            loc: None,
975        };
976        let json = serde_json::to_string(&node).unwrap();
977        assert!(json.contains(r#""type":"supsub""#));
978    }
979
980    #[test]
981    fn test_serialize_genfrac() {
982        let node = ParseNode::GenFrac {
983            mode: Mode::Math,
984            continued: false,
985            numer: Box::new(ParseNode::MathOrd {
986                mode: Mode::Math,
987                text: "a".to_string(),
988                loc: None,
989            }),
990            denom: Box::new(ParseNode::MathOrd {
991                mode: Mode::Math,
992                text: "b".to_string(),
993                loc: None,
994            }),
995            has_bar_line: true,
996            left_delim: None,
997            right_delim: None,
998            bar_size: None,
999            loc: None,
1000        };
1001        let json = serde_json::to_string(&node).unwrap();
1002        assert!(json.contains(r#""type":"genfrac""#));
1003        assert!(json.contains(r#""hasBarLine":true"#));
1004    }
1005
1006    #[test]
1007    fn test_serialize_atom() {
1008        let node = ParseNode::Atom {
1009            mode: Mode::Math,
1010            family: AtomFamily::Bin,
1011            text: "+".to_string(),
1012            loc: None,
1013        };
1014        let json = serde_json::to_string(&node).unwrap();
1015        assert!(json.contains(r#""type":"atom""#));
1016        assert!(json.contains(r#""family":"bin""#));
1017    }
1018
1019    #[test]
1020    fn test_roundtrip() {
1021        let node = ParseNode::MathOrd {
1022            mode: Mode::Math,
1023            text: "x".to_string(),
1024            loc: Some(SourceLocation { start: 0, end: 1 }),
1025        };
1026        let json = serde_json::to_string(&node).unwrap();
1027        let parsed: ParseNode = serde_json::from_str(&json).unwrap();
1028        assert_eq!(parsed.type_name(), "mathord");
1029        assert_eq!(parsed.symbol_text(), Some("x"));
1030    }
1031
1032    #[test]
1033    fn test_mode_accessor() {
1034        let node = ParseNode::Atom {
1035            mode: Mode::Math,
1036            family: AtomFamily::Rel,
1037            text: "=".to_string(),
1038            loc: None,
1039        };
1040        assert_eq!(node.mode(), Mode::Math);
1041    }
1042
1043    #[test]
1044    fn test_normalize_argument() {
1045        let group = ParseNode::OrdGroup {
1046            mode: Mode::Math,
1047            body: vec![ParseNode::MathOrd {
1048                mode: Mode::Math,
1049                text: "x".to_string(),
1050                loc: None,
1051            }],
1052            semisimple: None,
1053            loc: None,
1054        };
1055        let normalized = ParseNode::normalize_argument(group);
1056        assert_eq!(normalized.type_name(), "mathord");
1057    }
1058}