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
673fn default_arraystretch() -> f64 {
674    1.0
675}
676
677/// Tag variant for array rows: either auto-numbered (bool) or explicit tag.
678#[derive(Debug, Clone, Serialize, Deserialize)]
679#[serde(untagged)]
680pub enum ArrayTag {
681    Auto(bool),
682    Explicit(Vec<ParseNode>),
683}
684
685// ── Helper methods ──────────────────────────────────────────────────────────
686
687impl ParseNode {
688    pub fn mode(&self) -> Mode {
689        match self {
690            Self::Atom { mode, .. }
691            | Self::MathOrd { mode, .. }
692            | Self::TextOrd { mode, .. }
693            | Self::OpToken { mode, .. }
694            | Self::AccentToken { mode, .. }
695            | Self::SpacingNode { mode, .. }
696            | Self::OrdGroup { mode, .. }
697            | Self::SupSub { mode, .. }
698            | Self::GenFrac { mode, .. }
699            | Self::Sqrt { mode, .. }
700            | Self::Accent { mode, .. }
701            | Self::AccentUnder { mode, .. }
702            | Self::Op { mode, .. }
703            | Self::OperatorName { mode, .. }
704            | Self::Font { mode, .. }
705            | Self::Text { mode, .. }
706            | Self::Color { mode, .. }
707            | Self::ColorToken { mode, .. }
708            | Self::Size { mode, .. }
709            | Self::Styling { mode, .. }
710            | Self::Sizing { mode, .. }
711            | Self::DelimSizing { mode, .. }
712            | Self::LeftRight { mode, .. }
713            | Self::LeftRightRight { mode, .. }
714            | Self::Middle { mode, .. }
715            | Self::Overline { mode, .. }
716            | Self::Underline { mode, .. }
717            | Self::Rule { mode, .. }
718            | Self::Kern { mode, .. }
719            | Self::Phantom { mode, .. }
720            | Self::VPhantom { mode, .. }
721            | Self::Smash { mode, .. }
722            | Self::MClass { mode, .. }
723            | Self::Array { mode, .. }
724            | Self::Environment { mode, .. }
725            | Self::Cr { mode, .. }
726            | Self::Infix { mode, .. }
727            | Self::Internal { mode, .. }
728            | Self::Verb { mode, .. }
729            | Self::Href { mode, .. }
730            | Self::Url { mode, .. }
731            | Self::Raw { mode, .. }
732            | Self::HBox { mode, .. }
733            | Self::HorizBrace { mode, .. }
734            | Self::Enclose { mode, .. }
735            | Self::Lap { mode, .. }
736            | Self::MathChoice { mode, .. }
737            | Self::RaiseBox { mode, .. }
738            | Self::VCenter { mode, .. }
739            | Self::XArrow { mode, .. }
740            | Self::Pmb { mode, .. }
741            | Self::Tag { mode, .. }
742            | Self::Html { mode, .. }
743            | Self::HtmlMathMl { mode, .. }
744            | Self::IncludeGraphics { mode, .. }
745            | Self::CdLabel { mode, .. }
746            | Self::CdLabelParent { mode, .. } => *mode,
747        }
748    }
749
750    pub fn type_name(&self) -> &'static str {
751        match self {
752            Self::Atom { .. } => "atom",
753            Self::MathOrd { .. } => "mathord",
754            Self::TextOrd { .. } => "textord",
755            Self::OpToken { .. } => "op-token",
756            Self::AccentToken { .. } => "accent-token",
757            Self::SpacingNode { .. } => "spacing",
758            Self::OrdGroup { .. } => "ordgroup",
759            Self::SupSub { .. } => "supsub",
760            Self::GenFrac { .. } => "genfrac",
761            Self::Sqrt { .. } => "sqrt",
762            Self::Accent { .. } => "accent",
763            Self::AccentUnder { .. } => "accentUnder",
764            Self::Op { .. } => "op",
765            Self::OperatorName { .. } => "operatorname",
766            Self::Font { .. } => "font",
767            Self::Text { .. } => "text",
768            Self::Color { .. } => "color",
769            Self::ColorToken { .. } => "color-token",
770            Self::Size { .. } => "size",
771            Self::Styling { .. } => "styling",
772            Self::Sizing { .. } => "sizing",
773            Self::DelimSizing { .. } => "delimsizing",
774            Self::LeftRight { .. } => "leftright",
775            Self::LeftRightRight { .. } => "leftright-right",
776            Self::Middle { .. } => "middle",
777            Self::Overline { .. } => "overline",
778            Self::Underline { .. } => "underline",
779            Self::Rule { .. } => "rule",
780            Self::Kern { .. } => "kern",
781            Self::Phantom { .. } => "phantom",
782            Self::VPhantom { .. } => "vphantom",
783            Self::Smash { .. } => "smash",
784            Self::MClass { .. } => "mclass",
785            Self::Array { .. } => "array",
786            Self::Environment { .. } => "environment",
787            Self::Cr { .. } => "cr",
788            Self::Infix { .. } => "infix",
789            Self::Internal { .. } => "internal",
790            Self::Verb { .. } => "verb",
791            Self::Href { .. } => "href",
792            Self::Url { .. } => "url",
793            Self::Raw { .. } => "raw",
794            Self::HBox { .. } => "hbox",
795            Self::HorizBrace { .. } => "horizBrace",
796            Self::Enclose { .. } => "enclose",
797            Self::Lap { .. } => "lap",
798            Self::MathChoice { .. } => "mathchoice",
799            Self::RaiseBox { .. } => "raisebox",
800            Self::VCenter { .. } => "vcenter",
801            Self::XArrow { .. } => "xArrow",
802            Self::Pmb { .. } => "pmb",
803            Self::Tag { .. } => "tag",
804            Self::Html { .. } => "html",
805            Self::HtmlMathMl { .. } => "htmlmathml",
806            Self::IncludeGraphics { .. } => "includegraphics",
807            Self::CdLabel { .. } => "cdlabel",
808            Self::CdLabelParent { .. } => "cdlabelparent",
809        }
810    }
811
812    /// Check if this node is a symbol node (atom or non-atom symbol).
813    pub fn is_symbol_node(&self) -> bool {
814        matches!(
815            self,
816            Self::Atom { .. }
817                | Self::MathOrd { .. }
818                | Self::TextOrd { .. }
819                | Self::OpToken { .. }
820                | Self::AccentToken { .. }
821                | Self::SpacingNode { .. }
822        )
823    }
824
825    /// Get the text of a symbol node.
826    pub fn symbol_text(&self) -> Option<&str> {
827        match self {
828            Self::Atom { text, .. }
829            | Self::MathOrd { text, .. }
830            | Self::TextOrd { text, .. }
831            | Self::OpToken { text, .. }
832            | Self::AccentToken { text, .. }
833            | Self::SpacingNode { text, .. } => Some(text),
834            _ => None,
835        }
836    }
837
838    /// Normalize an argument: if it's an ordgroup with a single element, unwrap it.
839    pub fn normalize_argument(arg: ParseNode) -> ParseNode {
840        if let ParseNode::OrdGroup { body, .. } = &arg {
841            if body.len() == 1 {
842                return body[0].clone();
843            }
844        }
845        arg
846    }
847
848    /// Convert an argument to a list: if ordgroup, return body; otherwise wrap in vec.
849    pub fn ord_argument(arg: ParseNode) -> Vec<ParseNode> {
850        if let ParseNode::OrdGroup { body, .. } = arg {
851            body
852        } else {
853            vec![arg]
854        }
855    }
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861
862    #[test]
863    fn test_serialize_mathord() {
864        let node = ParseNode::MathOrd {
865            mode: Mode::Math,
866            text: "x".to_string(),
867            loc: None,
868        };
869        let json = serde_json::to_string(&node).unwrap();
870        assert!(json.contains(r#""type":"mathord""#));
871        assert!(json.contains(r#""mode":"math""#));
872        assert!(json.contains(r#""text":"x""#));
873    }
874
875    #[test]
876    fn test_serialize_ordgroup() {
877        let node = ParseNode::OrdGroup {
878            mode: Mode::Math,
879            body: vec![
880                ParseNode::MathOrd {
881                    mode: Mode::Math,
882                    text: "a".to_string(),
883                    loc: None,
884                },
885            ],
886            semisimple: None,
887            loc: None,
888        };
889        let json = serde_json::to_string(&node).unwrap();
890        assert!(json.contains(r#""type":"ordgroup""#));
891    }
892
893    #[test]
894    fn test_serialize_supsub() {
895        let node = ParseNode::SupSub {
896            mode: Mode::Math,
897            base: Some(Box::new(ParseNode::MathOrd {
898                mode: Mode::Math,
899                text: "x".to_string(),
900                loc: None,
901            })),
902            sup: Some(Box::new(ParseNode::TextOrd {
903                mode: Mode::Math,
904                text: "2".to_string(),
905                loc: None,
906            })),
907            sub: None,
908            loc: None,
909        };
910        let json = serde_json::to_string(&node).unwrap();
911        assert!(json.contains(r#""type":"supsub""#));
912    }
913
914    #[test]
915    fn test_serialize_genfrac() {
916        let node = ParseNode::GenFrac {
917            mode: Mode::Math,
918            continued: false,
919            numer: Box::new(ParseNode::MathOrd {
920                mode: Mode::Math,
921                text: "a".to_string(),
922                loc: None,
923            }),
924            denom: Box::new(ParseNode::MathOrd {
925                mode: Mode::Math,
926                text: "b".to_string(),
927                loc: None,
928            }),
929            has_bar_line: true,
930            left_delim: None,
931            right_delim: None,
932            bar_size: None,
933            loc: None,
934        };
935        let json = serde_json::to_string(&node).unwrap();
936        assert!(json.contains(r#""type":"genfrac""#));
937        assert!(json.contains(r#""hasBarLine":true"#));
938    }
939
940    #[test]
941    fn test_serialize_atom() {
942        let node = ParseNode::Atom {
943            mode: Mode::Math,
944            family: AtomFamily::Bin,
945            text: "+".to_string(),
946            loc: None,
947        };
948        let json = serde_json::to_string(&node).unwrap();
949        assert!(json.contains(r#""type":"atom""#));
950        assert!(json.contains(r#""family":"bin""#));
951    }
952
953    #[test]
954    fn test_roundtrip() {
955        let node = ParseNode::MathOrd {
956            mode: Mode::Math,
957            text: "x".to_string(),
958            loc: Some(SourceLocation { start: 0, end: 1 }),
959        };
960        let json = serde_json::to_string(&node).unwrap();
961        let parsed: ParseNode = serde_json::from_str(&json).unwrap();
962        assert_eq!(parsed.type_name(), "mathord");
963        assert_eq!(parsed.symbol_text(), Some("x"));
964    }
965
966    #[test]
967    fn test_mode_accessor() {
968        let node = ParseNode::Atom {
969            mode: Mode::Math,
970            family: AtomFamily::Rel,
971            text: "=".to_string(),
972            loc: None,
973        };
974        assert_eq!(node.mode(), Mode::Math);
975    }
976
977    #[test]
978    fn test_normalize_argument() {
979        let group = ParseNode::OrdGroup {
980            mode: Mode::Math,
981            body: vec![ParseNode::MathOrd {
982                mode: Mode::Math,
983                text: "x".to_string(),
984                loc: None,
985            }],
986            semisimple: None,
987            loc: None,
988        };
989        let normalized = ParseNode::normalize_argument(group);
990        assert_eq!(normalized.type_name(), "mathord");
991    }
992}