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