Skip to main content

ratex_parser/
environments.rs

1use std::collections::HashMap;
2
3use ratex_lexer::token::Token;
4
5use crate::error::{ParseError, ParseResult};
6use crate::macro_expander::MacroDefinition;
7use crate::parse_node::{
8    AlignSpec, AlignType, ArrayTag, Measurement, Mode, ParseNode, ProofBranch, ProofLineStyle,
9    StyleStr,
10};
11use crate::parser::Parser;
12
13// ── Environment registry ─────────────────────────────────────────────────
14
15pub struct EnvContext<'a, 'b> {
16    pub mode: Mode,
17    pub env_name: String,
18    pub parser: &'a mut Parser<'b>,
19}
20
21pub type EnvHandler = fn(
22    ctx: &mut EnvContext,
23    args: Vec<ParseNode>,
24    opt_args: Vec<Option<ParseNode>>,
25) -> ParseResult<ParseNode>;
26
27pub struct EnvSpec {
28    pub num_args: usize,
29    pub num_optional_args: usize,
30    pub handler: EnvHandler,
31}
32
33pub static ENVIRONMENTS: std::sync::LazyLock<HashMap<&'static str, EnvSpec>> =
34    std::sync::LazyLock::new(|| {
35        let mut map = HashMap::new();
36        register_array(&mut map);
37        register_matrix(&mut map);
38        register_cases(&mut map);
39        register_align(&mut map);
40        register_gathered(&mut map);
41        register_equation(&mut map);
42        register_smallmatrix(&mut map);
43        register_alignat(&mut map);
44        register_subarray(&mut map);
45        register_cd(&mut map);
46        register_prooftree(&mut map);
47        map
48    });
49
50// ── ArrayConfig ──────────────────────────────────────────────────────────
51
52#[derive(Default)]
53pub struct ArrayConfig {
54    pub hskip_before_and_after: Option<bool>,
55    pub add_jot: Option<bool>,
56    pub cols: Option<Vec<AlignSpec>>,
57    pub arraystretch: Option<f64>,
58    pub col_separation_type: Option<String>,
59    pub single_row: bool,
60    pub empty_single_row: bool,
61    pub max_num_cols: Option<usize>,
62    pub leqno: Option<bool>,
63    pub auto_number: bool,
64}
65
66// ── parseArray ───────────────────────────────────────────────────────────
67
68/// Pull a trailing `\\tag{…}` or `\\nonumber`/`\\notag` off the last cell of a row.
69/// Returns `Auto(true)` when the row is eligible for auto-numbering.
70/// The `auto_number` parameter controls the default when no marker is found.
71fn extract_trailing_tag_from_last_cell(
72    row: &mut [ParseNode],
73    auto_number: bool,
74) -> ParseResult<ArrayTag> {
75    let default_tag = if auto_number {
76        ArrayTag::Auto(true)
77    } else {
78        ArrayTag::Auto(false)
79    };
80    let Some(last) = row.last_mut() else {
81        return Ok(default_tag);
82    };
83
84    let inner: &mut ParseNode = match last {
85        ParseNode::Styling { body, .. } => {
86            if body.len() != 1 {
87                return Ok(default_tag);
88            }
89            &mut body[0]
90        }
91        _ => last,
92    };
93
94    let obody = match inner {
95        ParseNode::OrdGroup { body, .. } => body,
96        _ => return Ok(default_tag),
97    };
98
99    // Look for \\tag
100    let tag_indices: Vec<usize> = obody
101        .iter()
102        .enumerate()
103        .filter(|(_, n)| matches!(n, ParseNode::Tag { .. }))
104        .map(|(i, _)| i)
105        .collect();
106
107    // Look for \\nonumber / \\notag
108    let nonumber_indices: Vec<usize> = obody
109        .iter()
110        .enumerate()
111        .filter(|(_, n)| matches!(n, ParseNode::NoNumber { .. }))
112        .map(|(i, _)| i)
113        .collect();
114
115    // Can't have both \\tag and \\nonumber in the same row
116    if !tag_indices.is_empty() && !nonumber_indices.is_empty() {
117        return Err(ParseError::msg(
118            "Cannot use both \\tag and \\nonumber in the same row",
119        ));
120    }
121
122    // Handle \\tag
123    if !tag_indices.is_empty() {
124        if tag_indices.len() > 1 {
125            return Err(ParseError::msg("Multiple \\tag in a row"));
126        }
127        let idx = tag_indices[0];
128        if idx != obody.len() - 1 {
129            return Err(ParseError::msg(
130                "\\tag must appear at the end of the row after the equation body",
131            ));
132        }
133        match obody.pop() {
134            Some(ParseNode::Tag { tag, .. }) => {
135                if tag.is_empty() {
136                    Ok(ArrayTag::Auto(false))
137                } else {
138                    Ok(ArrayTag::Explicit(tag))
139                }
140            }
141            _ => Ok(default_tag),
142        }
143    } else if !nonumber_indices.is_empty() {
144        // Handle \\nonumber / \\notag
145        if nonumber_indices.len() > 1 {
146            return Err(ParseError::msg("Multiple \\nonumber in a row"));
147        }
148        let idx = nonumber_indices[0];
149        if idx != obody.len() - 1 {
150            return Err(ParseError::msg(
151                "\\nonumber must appear at the end of the row",
152            ));
153        }
154        obody.pop(); // discard the NoNumber node
155        Ok(ArrayTag::Auto(false))
156    } else {
157        // Neither \\tag nor \\nonumber
158        Ok(default_tag)
159    }
160}
161
162fn get_hlines(parser: &mut Parser) -> ParseResult<Vec<bool>> {
163    let mut hline_info = Vec::new();
164    parser.consume_spaces()?;
165
166    let mut nxt = parser.fetch()?.text.clone();
167    if nxt == "\\relax" {
168        parser.consume();
169        parser.consume_spaces()?;
170        nxt = parser.fetch()?.text.clone();
171    }
172    while nxt == "\\hline" || nxt == "\\hdashline" {
173        parser.consume();
174        hline_info.push(nxt == "\\hdashline");
175        parser.consume_spaces()?;
176        nxt = parser.fetch()?.text.clone();
177    }
178    Ok(hline_info)
179}
180
181fn d_cell_style(env_name: &str) -> Option<StyleStr> {
182    if env_name.starts_with('d') {
183        Some(StyleStr::Display)
184    } else {
185        Some(StyleStr::Text)
186    }
187}
188
189pub fn parse_array(
190    parser: &mut Parser,
191    config: ArrayConfig,
192    style: Option<StyleStr>,
193) -> ParseResult<ParseNode> {
194    parser.gullet.begin_group();
195
196    if !config.single_row {
197        parser.gullet.set_text_macro("\\cr", "\\\\\\relax");
198    }
199
200    let arraystretch = config.arraystretch.unwrap_or_else(|| {
201        // Check if \arraystretch is defined as a macro (e.g., via \def\arraystretch{1.5})
202        if let Some(def) = parser.gullet.get_macro("\\arraystretch") {
203            let s = match def {
204                MacroDefinition::Text(s) => s.clone(),
205                MacroDefinition::Tokens { tokens, .. } => {
206                    // Tokens are stored in reverse order (stack convention for expansion)
207                    tokens
208                        .iter()
209                        .rev()
210                        .map(|t| t.text.as_str())
211                        .collect::<String>()
212                }
213                MacroDefinition::Function(_) => String::new(),
214            };
215            s.parse::<f64>().unwrap_or(1.0)
216        } else {
217            1.0
218        }
219    });
220
221    parser.gullet.begin_group();
222
223    let mut row: Vec<ParseNode> = Vec::new();
224    let mut body: Vec<Vec<ParseNode>> = Vec::new();
225    let mut row_tags: Vec<ArrayTag> = Vec::new();
226    let mut row_gaps: Vec<Option<Measurement>> = Vec::new();
227    let mut hlines_before_row: Vec<Vec<bool>> = Vec::new();
228
229    hlines_before_row.push(get_hlines(parser)?);
230
231    loop {
232        let break_token = if config.single_row { "\\end" } else { "\\\\" };
233        let cell_body = parser.parse_expression(false, Some(break_token))?;
234        parser.gullet.end_group();
235        parser.gullet.begin_group();
236
237        let mut cell = ParseNode::OrdGroup {
238            mode: parser.mode,
239            body: cell_body,
240            semisimple: None,
241            loc: None,
242        };
243
244        if let Some(s) = style {
245            cell = ParseNode::Styling {
246                mode: parser.mode,
247                style: s,
248                body: vec![cell],
249                loc: None,
250            };
251        }
252
253        row.push(cell.clone());
254        let next = parser.fetch()?.text.clone();
255
256        if next == "&" {
257            if let Some(max) = config.max_num_cols {
258                if row.len() >= max {
259                    return Err(ParseError::msg("Too many tab characters: &"));
260                }
261            }
262            parser.consume();
263        } else if next == "\\end" {
264            // Check for trailing empty row and remove it
265            let is_empty_trailing = if let Some(s) = style {
266                if s == StyleStr::Text || s == StyleStr::Display {
267                    if let ParseNode::Styling { body: ref sb, .. } = cell {
268                        if let Some(ParseNode::OrdGroup { body: ref ob, .. }) = sb.first() {
269                            ob.is_empty()
270                        } else {
271                            false
272                        }
273                    } else {
274                        false
275                    }
276                } else {
277                    false
278                }
279            } else if let ParseNode::OrdGroup { body: ref ob, .. } = cell {
280                ob.is_empty()
281            } else {
282                false
283            };
284
285            let row_tag = extract_trailing_tag_from_last_cell(&mut row, config.auto_number)?;
286            row_tags.push(row_tag);
287            body.push(row);
288
289            if is_empty_trailing && (body.len() > 1 || !config.empty_single_row) {
290                body.pop();
291                row_tags.pop();
292            }
293
294            if hlines_before_row.len() < body.len() + 1 {
295                hlines_before_row.push(vec![]);
296            }
297            break;
298        } else if next == "\\\\" {
299            parser.consume();
300            let size = if parser.gullet.future().text != " " {
301                parser.parse_size_group(true)?
302            } else {
303                None
304            };
305            let gap = size.and_then(|s| {
306                if let ParseNode::Size { value, .. } = s {
307                    Some(value)
308                } else {
309                    None
310                }
311            });
312            row_gaps.push(gap);
313
314            let row_tag = extract_trailing_tag_from_last_cell(&mut row, config.auto_number)?;
315            row_tags.push(row_tag);
316            body.push(row);
317            hlines_before_row.push(get_hlines(parser)?);
318            row = Vec::new();
319        } else {
320            return Err(ParseError::msg(format!(
321                "Expected & or \\\\ or \\cr or \\end, got '{}'",
322                next
323            )));
324        }
325    }
326
327    parser.gullet.end_group();
328    parser.gullet.end_group();
329
330    // Post-process row tags for auto-numbering
331    let tags = if config.auto_number {
332        let mut processed: Vec<ArrayTag> = Vec::with_capacity(row_tags.len());
333        let mut any_visible = false;
334        for raw_tag in &row_tags {
335            match raw_tag {
336                ArrayTag::Explicit(nodes) if !nodes.is_empty() => {
337                    // Explicit \\tag{...}: step counter, keep tag content as-is
338                    parser.equation_counter += 1;
339                    processed.push(ArrayTag::Explicit(nodes.clone()));
340                    any_visible = true;
341                }
342                ArrayTag::Explicit(_) => {
343                    // Empty \\tag{}: treat as suppressed
344                    processed.push(ArrayTag::Auto(false));
345                }
346                ArrayTag::Auto(true) => {
347                    // Auto-number this row: step counter, generate "(N)"
348                    parser.equation_counter += 1;
349                    let num_str = parser.equation_counter.to_string();
350                    let tag_nodes = vec![
351                        ParseNode::MathOrd {
352                            mode: Mode::Math,
353                            text: "(".to_string(),
354                            loc: None,
355                        },
356                        ParseNode::MathOrd {
357                            mode: Mode::Math,
358                            text: num_str,
359                            loc: None,
360                        },
361                        ParseNode::MathOrd {
362                            mode: Mode::Math,
363                            text: ")".to_string(),
364                            loc: None,
365                        },
366                    ];
367                    processed.push(ArrayTag::Explicit(tag_nodes));
368                    any_visible = true;
369                }
370                ArrayTag::Auto(false) => {
371                    // Suppressed by \\nonumber or empty \\tag{}: no counter step, no tag
372                    processed.push(ArrayTag::Auto(false));
373                }
374            }
375        }
376        if any_visible {
377            Some(processed)
378        } else {
379            None
380        }
381    } else {
382        // Not an auto-numbering environment: keep original behavior
383        if row_tags
384            .iter()
385            .any(|t| matches!(t, ArrayTag::Explicit(nodes) if !nodes.is_empty()))
386        {
387            Some(row_tags)
388        } else {
389            None
390        }
391    };
392
393    Ok(ParseNode::Array {
394        mode: parser.mode,
395        body,
396        row_gaps,
397        hlines_before_row,
398        cols: config.cols,
399        col_separation_type: config.col_separation_type,
400        hskip_before_and_after: config.hskip_before_and_after,
401        add_jot: config.add_jot,
402        arraystretch,
403        tags,
404        leqno: config.leqno,
405        is_cd: None,
406        loc: None,
407    })
408}
409
410// ── array / darray ───────────────────────────────────────────────────────
411
412fn register_array(map: &mut HashMap<&'static str, EnvSpec>) {
413    fn handle_array(
414        ctx: &mut EnvContext,
415        args: Vec<ParseNode>,
416        _opt_args: Vec<Option<ParseNode>>,
417    ) -> ParseResult<ParseNode> {
418        let colalign = match &args[0] {
419            ParseNode::OrdGroup { body, .. } => body.clone(),
420            other if other.is_symbol_node() => vec![other.clone()],
421            _ => return Err(ParseError::msg("Invalid column alignment for array")),
422        };
423
424        let mut cols = Vec::new();
425        for nde in &colalign {
426            let ca = nde
427                .symbol_text()
428                .ok_or_else(|| ParseError::msg("Expected column alignment character"))?;
429            match ca {
430                "l" | "c" | "r" => cols.push(AlignSpec {
431                    align_type: AlignType::Align,
432                    align: Some(ca.to_string()),
433                    pregap: None,
434                    postgap: None,
435                }),
436                "|" => cols.push(AlignSpec {
437                    align_type: AlignType::Separator,
438                    align: Some("|".to_string()),
439                    pregap: None,
440                    postgap: None,
441                }),
442                ":" => cols.push(AlignSpec {
443                    align_type: AlignType::Separator,
444                    align: Some(":".to_string()),
445                    pregap: None,
446                    postgap: None,
447                }),
448                _ => return Err(ParseError::msg(format!("Unknown column alignment: {}", ca))),
449            }
450        }
451
452        let max_num_cols = cols.len();
453        let config = ArrayConfig {
454            cols: Some(cols),
455            hskip_before_and_after: Some(true),
456            max_num_cols: Some(max_num_cols),
457            ..Default::default()
458        };
459        parse_array(ctx.parser, config, d_cell_style(&ctx.env_name))
460    }
461
462    for name in &["array", "darray"] {
463        map.insert(
464            name,
465            EnvSpec {
466                num_args: 1,
467                num_optional_args: 0,
468                handler: handle_array,
469            },
470        );
471    }
472}
473
474// ── matrix variants ──────────────────────────────────────────────────────
475
476fn register_matrix(map: &mut HashMap<&'static str, EnvSpec>) {
477    fn handle_matrix(
478        ctx: &mut EnvContext,
479        _args: Vec<ParseNode>,
480        _opt_args: Vec<Option<ParseNode>>,
481    ) -> ParseResult<ParseNode> {
482        let base_name = ctx.env_name.replace('*', "");
483        let delimiters: Option<(&str, &str)> = match base_name.as_str() {
484            "matrix" => None,
485            "pmatrix" => Some(("(", ")")),
486            "bmatrix" => Some(("[", "]")),
487            "Bmatrix" => Some(("\\{", "\\}")),
488            "vmatrix" => Some(("|", "|")),
489            "Vmatrix" => Some(("\\Vert", "\\Vert")),
490            _ => None,
491        };
492
493        let mut col_align = "c".to_string();
494
495        // mathtools starred matrix: parse optional [l|c|r] alignment
496        if ctx.env_name.ends_with('*') {
497            ctx.parser.gullet.consume_spaces();
498            if ctx.parser.gullet.future().text == "[" {
499                ctx.parser.gullet.pop_token();
500                ctx.parser.gullet.consume_spaces();
501                let align_tok = ctx.parser.gullet.pop_token();
502                if !"lcr".contains(align_tok.text.as_str()) {
503                    return Err(ParseError::new(
504                        "Expected l or c or r".to_string(),
505                        Some(&align_tok),
506                    ));
507                }
508                col_align = align_tok.text.clone();
509                ctx.parser.gullet.consume_spaces();
510                let close = ctx.parser.gullet.pop_token();
511                if close.text != "]" {
512                    return Err(ParseError::new("Expected ]".to_string(), Some(&close)));
513                }
514            }
515        }
516
517        let config = ArrayConfig {
518            hskip_before_and_after: Some(false),
519            cols: Some(vec![AlignSpec {
520                align_type: AlignType::Align,
521                align: Some(col_align.clone()),
522                pregap: None,
523                postgap: None,
524            }]),
525            ..Default::default()
526        };
527
528        let mut res = parse_array(ctx.parser, config, d_cell_style(&ctx.env_name))?;
529
530        // Fix cols to match actual number of columns
531        if let ParseNode::Array {
532            ref body,
533            ref mut cols,
534            ..
535        } = res
536        {
537            let num_cols = body.iter().map(|r| r.len()).max().unwrap_or(0);
538            *cols = Some(
539                (0..num_cols)
540                    .map(|_| AlignSpec {
541                        align_type: AlignType::Align,
542                        align: Some(col_align.to_string()),
543                        pregap: None,
544                        postgap: None,
545                    })
546                    .collect(),
547            );
548        }
549
550        match delimiters {
551            Some((left, right)) => Ok(ParseNode::LeftRight {
552                mode: ctx.mode,
553                body: vec![res],
554                left: left.to_string(),
555                right: right.to_string(),
556                right_color: None,
557                loc: None,
558            }),
559            None => Ok(res),
560        }
561    }
562
563    for name in &[
564        "matrix", "pmatrix", "bmatrix", "Bmatrix", "vmatrix", "Vmatrix", "matrix*", "pmatrix*",
565        "bmatrix*", "Bmatrix*", "vmatrix*", "Vmatrix*",
566    ] {
567        map.insert(
568            name,
569            EnvSpec {
570                num_args: 0,
571                num_optional_args: 0,
572                handler: handle_matrix,
573            },
574        );
575    }
576}
577
578// ── cases / dcases / rcases / drcases ────────────────────────────────────
579
580fn register_cases(map: &mut HashMap<&'static str, EnvSpec>) {
581    fn handle_cases(
582        ctx: &mut EnvContext,
583        _args: Vec<ParseNode>,
584        _opt_args: Vec<Option<ParseNode>>,
585    ) -> ParseResult<ParseNode> {
586        let config = ArrayConfig {
587            arraystretch: Some(1.2),
588            cols: Some(vec![
589                AlignSpec {
590                    align_type: AlignType::Align,
591                    align: Some("l".to_string()),
592                    pregap: Some(0.0),
593                    postgap: Some(1.0),
594                },
595                AlignSpec {
596                    align_type: AlignType::Align,
597                    align: Some("l".to_string()),
598                    pregap: Some(0.0),
599                    postgap: Some(0.0),
600                },
601            ]),
602            ..Default::default()
603        };
604
605        let res = parse_array(ctx.parser, config, d_cell_style(&ctx.env_name))?;
606
607        let (left, right) = if ctx.env_name.contains('r') {
608            (".", "\\}")
609        } else {
610            ("\\{", ".")
611        };
612
613        Ok(ParseNode::LeftRight {
614            mode: ctx.mode,
615            body: vec![res],
616            left: left.to_string(),
617            right: right.to_string(),
618            right_color: None,
619            loc: None,
620        })
621    }
622
623    for name in &["cases", "dcases", "rcases", "drcases"] {
624        map.insert(
625            name,
626            EnvSpec {
627                num_args: 0,
628                num_optional_args: 0,
629                handler: handle_cases,
630            },
631        );
632    }
633}
634
635// ── align / align* / aligned / split / alignat / alignat* / alignedat ────
636
637fn handle_aligned(
638    ctx: &mut EnvContext,
639    args: Vec<ParseNode>,
640    _opt_args: Vec<Option<ParseNode>>,
641) -> ParseResult<ParseNode> {
642    let is_split = ctx.env_name == "split";
643    let is_alignat = ctx.env_name.contains("at");
644    let sep_type = if is_alignat { "alignat" } else { "align" };
645    let auto_number = !ctx.env_name.ends_with('*')
646        && !is_split
647        && ctx.env_name != "aligned"
648        && ctx.env_name != "alignedat";
649
650    let config = ArrayConfig {
651        add_jot: Some(true),
652        empty_single_row: true,
653        col_separation_type: Some(sep_type.to_string()),
654        max_num_cols: if is_split { Some(2) } else { None },
655        auto_number,
656        ..Default::default()
657    };
658
659    let mut res = parse_array(ctx.parser, config, Some(StyleStr::Display))?;
660
661    // Extract explicit column count from first arg (alignat only)
662    let mut num_maths = 0usize;
663    let mut explicit_cols = 0usize;
664    if let Some(ParseNode::OrdGroup { body, .. }) = args.first() {
665        let mut arg_str = String::new();
666        for node in body {
667            if let Some(t) = node.symbol_text() {
668                arg_str.push_str(t);
669            }
670        }
671        if let Ok(n) = arg_str.parse::<usize>() {
672            num_maths = n;
673            explicit_cols = n * 2;
674        }
675    }
676    let is_aligned = explicit_cols == 0;
677
678    // Determine actual number of columns
679    let mut num_cols = if let ParseNode::Array { ref body, .. } = res {
680        body.iter().map(|r| r.len()).max().unwrap_or(0)
681    } else {
682        0
683    };
684
685    if let ParseNode::Array {
686        body: ref mut array_body,
687        ..
688    } = res
689    {
690        for row in array_body.iter_mut() {
691            // Prepend empty group at every even-indexed cell (2nd, 4th, ...)
692            let mut i = 1;
693            while i < row.len() {
694                if let ParseNode::Styling {
695                    body: ref mut styling_body,
696                    ..
697                } = row[i]
698                {
699                    if let Some(ParseNode::OrdGroup {
700                        body: ref mut og_body,
701                        ..
702                    }) = styling_body.first_mut()
703                    {
704                        og_body.insert(
705                            0,
706                            ParseNode::OrdGroup {
707                                mode: ctx.mode,
708                                body: vec![],
709                                semisimple: None,
710                                loc: None,
711                            },
712                        );
713                    }
714                }
715                i += 2;
716            }
717
718            if !is_aligned {
719                let cur_maths = row.len() / 2;
720                if num_maths < cur_maths {
721                    return Err(ParseError::msg(format!(
722                        "Too many math in a row: expected {}, but got {}",
723                        num_maths, cur_maths
724                    )));
725                }
726            } else if num_cols < row.len() {
727                num_cols = row.len();
728            }
729        }
730    }
731
732    if !is_aligned {
733        num_cols = explicit_cols;
734    }
735
736    let mut cols = Vec::new();
737    for i in 0..num_cols {
738        let (align, pregap) = if i % 2 == 1 {
739            ("l", 0.0)
740        } else if i > 0 && is_aligned {
741            ("r", 1.0)
742        } else {
743            ("r", 0.0)
744        };
745        cols.push(AlignSpec {
746            align_type: AlignType::Align,
747            align: Some(align.to_string()),
748            pregap: Some(pregap),
749            postgap: Some(0.0),
750        });
751    }
752
753    if let ParseNode::Array {
754        cols: ref mut array_cols,
755        col_separation_type: ref mut array_sep_type,
756        ..
757    } = res
758    {
759        *array_cols = Some(cols);
760        *array_sep_type = Some(if is_aligned { "align" } else { "alignat" }.to_string());
761    }
762
763    Ok(res)
764}
765
766fn register_align(map: &mut HashMap<&'static str, EnvSpec>) {
767    for name in &["align", "align*", "aligned", "split"] {
768        map.insert(
769            name,
770            EnvSpec {
771                num_args: 0,
772                num_optional_args: 0,
773                handler: handle_aligned,
774            },
775        );
776    }
777}
778
779// ── gathered / gather / gather* ──────────────────────────────────────────
780
781fn register_gathered(map: &mut HashMap<&'static str, EnvSpec>) {
782    fn handle_gathered(
783        ctx: &mut EnvContext,
784        _args: Vec<ParseNode>,
785        _opt_args: Vec<Option<ParseNode>>,
786    ) -> ParseResult<ParseNode> {
787        let auto_number = !ctx.env_name.ends_with('*') && ctx.env_name != "gathered";
788        let config = ArrayConfig {
789            cols: Some(vec![AlignSpec {
790                align_type: AlignType::Align,
791                align: Some("c".to_string()),
792                pregap: None,
793                postgap: None,
794            }]),
795            add_jot: Some(true),
796            col_separation_type: Some("gather".to_string()),
797            empty_single_row: true,
798            auto_number,
799            ..Default::default()
800        };
801        parse_array(ctx.parser, config, Some(StyleStr::Display))
802    }
803
804    for name in &["gathered", "gather", "gather*"] {
805        map.insert(
806            name,
807            EnvSpec {
808                num_args: 0,
809                num_optional_args: 0,
810                handler: handle_gathered,
811            },
812        );
813    }
814}
815
816// ── equation / equation* ─────────────────────────────────────────────────
817
818fn register_equation(map: &mut HashMap<&'static str, EnvSpec>) {
819    fn handle_equation(
820        ctx: &mut EnvContext,
821        _args: Vec<ParseNode>,
822        _opt_args: Vec<Option<ParseNode>>,
823    ) -> ParseResult<ParseNode> {
824        let auto_number = !ctx.env_name.ends_with('*');
825        let config = ArrayConfig {
826            empty_single_row: true,
827            single_row: true,
828            max_num_cols: Some(1),
829            auto_number,
830            ..Default::default()
831        };
832        parse_array(ctx.parser, config, Some(StyleStr::Display))
833    }
834
835    for name in &["equation", "equation*"] {
836        map.insert(
837            name,
838            EnvSpec {
839                num_args: 0,
840                num_optional_args: 0,
841                handler: handle_equation,
842            },
843        );
844    }
845}
846
847// ── smallmatrix ──────────────────────────────────────────────────────────
848
849fn register_smallmatrix(map: &mut HashMap<&'static str, EnvSpec>) {
850    fn handle_smallmatrix(
851        ctx: &mut EnvContext,
852        _args: Vec<ParseNode>,
853        _opt_args: Vec<Option<ParseNode>>,
854    ) -> ParseResult<ParseNode> {
855        let config = ArrayConfig {
856            arraystretch: Some(0.5),
857            ..Default::default()
858        };
859        let mut res = parse_array(ctx.parser, config, Some(StyleStr::Script))?;
860        if let ParseNode::Array {
861            ref mut col_separation_type,
862            ..
863        } = res
864        {
865            *col_separation_type = Some("small".to_string());
866        }
867        Ok(res)
868    }
869
870    map.insert(
871        "smallmatrix",
872        EnvSpec {
873            num_args: 0,
874            num_optional_args: 0,
875            handler: handle_smallmatrix,
876        },
877    );
878}
879
880// ── alignat / alignat* / alignedat ──────────────────────────────────────
881
882fn register_alignat(map: &mut HashMap<&'static str, EnvSpec>) {
883    for name in &["alignat", "alignat*", "alignedat"] {
884        map.insert(
885            name,
886            EnvSpec {
887                num_args: 1,
888                num_optional_args: 0,
889                handler: handle_aligned,
890            },
891        );
892    }
893}
894
895// ── CD (amscd commutative diagrams) ──────────────────────────────────────
896
897fn register_cd(map: &mut HashMap<&'static str, EnvSpec>) {
898    fn handle_cd(
899        ctx: &mut EnvContext,
900        _args: Vec<ParseNode>,
901        _opt_args: Vec<Option<ParseNode>>,
902    ) -> ParseResult<ParseNode> {
903        // Collect all raw tokens until \end
904        let mut raw: Vec<Token> = Vec::new();
905        loop {
906            let tok = ctx.parser.gullet.future().clone();
907            if tok.text == "\\end" || tok.text == "EOF" {
908                break;
909            }
910            ctx.parser.gullet.pop_token();
911            raw.push(tok);
912        }
913
914        // Split into rows at \\ or \cr
915        let rows = cd_split_rows(raw);
916
917        let mut body: Vec<Vec<ParseNode>> = Vec::new();
918        let mut row_gaps: Vec<Option<Measurement>> = Vec::new();
919        let mut hlines_before_row: Vec<Vec<bool>> = Vec::new();
920        hlines_before_row.push(vec![]);
921
922        for row_toks in rows {
923            // Skip purely-whitespace rows
924            if row_toks.iter().all(|t| t.text == " ") {
925                continue;
926            }
927            let cells = cd_parse_row(ctx.parser, row_toks)?;
928            if !cells.is_empty() {
929                body.push(cells);
930                row_gaps.push(None);
931                hlines_before_row.push(vec![]);
932            }
933        }
934
935        if body.is_empty() {
936            body.push(vec![]);
937            hlines_before_row.push(vec![]);
938        }
939
940        Ok(ParseNode::Array {
941            mode: ctx.mode,
942            body,
943            row_gaps,
944            hlines_before_row,
945            cols: None,
946            col_separation_type: Some("CD".to_string()),
947            hskip_before_and_after: Some(false),
948            add_jot: None,
949            arraystretch: 1.0,
950            tags: None,
951            leqno: None,
952            is_cd: Some(true),
953            loc: None,
954        })
955    }
956
957    map.insert(
958        "CD",
959        EnvSpec {
960            num_args: 0,
961            num_optional_args: 0,
962            handler: handle_cd,
963        },
964    );
965}
966
967/// Split a flat token list into rows at `\\` or `\cr` boundaries.
968fn cd_split_rows(tokens: Vec<Token>) -> Vec<Vec<Token>> {
969    let mut rows: Vec<Vec<Token>> = Vec::new();
970    let mut current: Vec<Token> = Vec::new();
971    for tok in tokens {
972        if tok.text == "\\\\" || tok.text == "\\cr" {
973            rows.push(current);
974            current = Vec::new();
975        } else {
976            current.push(tok);
977        }
978    }
979    if !current.is_empty() {
980        rows.push(current);
981    }
982    rows
983}
984
985/// Collect tokens from `tokens[start..]` up to (but not including) the first
986/// token whose text equals `delimiter`.  Returns (collected_tokens, tokens_consumed).
987/// `tokens_consumed` includes the delimiter itself if found.
988fn cd_collect_until(tokens: &[Token], start: usize, delimiter: &str) -> (Vec<Token>, usize) {
989    let mut result = Vec::new();
990    let mut i = start;
991    while i < tokens.len() {
992        if tokens[i].text == delimiter {
993            i += 1; // consume the delimiter
994            break;
995        }
996        result.push(tokens[i].clone());
997        i += 1;
998    }
999    (result, i - start)
1000}
1001
1002/// Collect tokens from `tokens[start..]` up to (but not including) the next `@`.
1003fn cd_collect_until_at(tokens: &[Token], start: usize) -> (Vec<Token>, usize) {
1004    let mut result = Vec::new();
1005    let mut i = start;
1006    while i < tokens.len() && tokens[i].text != "@" {
1007        result.push(tokens[i].clone());
1008        i += 1;
1009    }
1010    (result, i - start)
1011}
1012
1013/// Use the parser to parse a token slice as a math OrdGroup.
1014/// Tokens must be in forward order; this function reverses them internally for subparse().
1015fn cd_parse_tokens(parser: &mut Parser, tokens: Vec<Token>) -> ParseResult<ParseNode> {
1016    // Filter pure whitespace
1017    let has_content = tokens.iter().any(|t| t.text != " ");
1018    if !has_content {
1019        return Ok(ParseNode::OrdGroup {
1020            mode: parser.mode,
1021            body: vec![],
1022            semisimple: None,
1023            loc: None,
1024        });
1025    }
1026    // subparse() expects tokens in reverse order (stack convention)
1027    let mut rev = tokens;
1028    rev.reverse();
1029    let body = parser.subparse(rev)?;
1030    Ok(ParseNode::OrdGroup {
1031        mode: parser.mode,
1032        body,
1033        semisimple: None,
1034        loc: None,
1035    })
1036}
1037
1038/// Parse one row of a CD environment from its raw token list.
1039/// Returns the list of ParseNode cells for the grid row.
1040fn cd_parse_row(parser: &mut Parser, row_tokens: Vec<Token>) -> ParseResult<Vec<ParseNode>> {
1041    let toks = &row_tokens;
1042    let n = toks.len();
1043    let mut cells: Vec<ParseNode> = Vec::new();
1044    let mut i = 0usize;
1045
1046    while i < n {
1047        // Skip spaces at start of each cell
1048        while i < n && toks[i].text == " " {
1049            i += 1;
1050        }
1051        if i >= n {
1052            break;
1053        }
1054
1055        if toks[i].text == "@" {
1056            i += 1; // consume `@`
1057            if i >= n {
1058                return Err(ParseError::msg("Unexpected end of CD row after @"));
1059            }
1060            let dir = toks[i].text.clone();
1061            i += 1; // consume direction char
1062
1063            let mode = parser.mode;
1064            let arrow = match dir.as_str() {
1065                ">" | "<" => {
1066                    let (above_toks, c1) = cd_collect_until(toks, i, &dir);
1067                    i += c1;
1068                    let (below_toks, c2) = cd_collect_until(toks, i, &dir);
1069                    i += c2;
1070                    let label_above = cd_parse_tokens(parser, above_toks)?;
1071                    let label_below = cd_parse_tokens(parser, below_toks)?;
1072                    ParseNode::CdArrow {
1073                        mode,
1074                        direction: if dir == ">" { "right" } else { "left" }.to_string(),
1075                        label_above: Some(Box::new(label_above)),
1076                        label_below: Some(Box::new(label_below)),
1077                        loc: None,
1078                    }
1079                }
1080                "V" | "A" => {
1081                    let (left_toks, c1) = cd_collect_until(toks, i, &dir);
1082                    i += c1;
1083                    let (right_toks, c2) = cd_collect_until(toks, i, &dir);
1084                    i += c2;
1085                    let label_above = cd_parse_tokens(parser, left_toks)?;
1086                    let label_below = cd_parse_tokens(parser, right_toks)?;
1087                    ParseNode::CdArrow {
1088                        mode,
1089                        direction: if dir == "V" { "down" } else { "up" }.to_string(),
1090                        label_above: Some(Box::new(label_above)),
1091                        label_below: Some(Box::new(label_below)),
1092                        loc: None,
1093                    }
1094                }
1095                "=" => ParseNode::CdArrow {
1096                    mode,
1097                    direction: "horiz_eq".to_string(),
1098                    label_above: None,
1099                    label_below: None,
1100                    loc: None,
1101                },
1102                "|" => ParseNode::CdArrow {
1103                    mode,
1104                    direction: "vert_eq".to_string(),
1105                    label_above: None,
1106                    label_below: None,
1107                    loc: None,
1108                },
1109                "." => ParseNode::CdArrow {
1110                    mode,
1111                    direction: "none".to_string(),
1112                    label_above: None,
1113                    label_below: None,
1114                    loc: None,
1115                },
1116                _ => return Err(ParseError::msg(format!("Unknown CD directive: @{}", dir))),
1117            };
1118            cells.push(arrow);
1119        } else {
1120            // Object cell: collect until next `@`
1121            let (obj_toks, consumed) = cd_collect_until_at(toks, i);
1122            i += consumed;
1123            let obj = cd_parse_tokens(parser, obj_toks)?;
1124            cells.push(obj);
1125        }
1126    }
1127
1128    // Post-process: structure cells into the (2n-1) grid pattern.
1129    Ok(cd_structure_row(cells, parser.mode))
1130}
1131
1132/// Given the raw parsed cells of one CD row, produce the correctly-structured grid row.
1133///
1134/// Object rows already alternate: obj, h-arrow, obj, h-arrow, …, obj.
1135/// Arrow rows contain only CdArrow nodes (plus whitespace OrdGroups which we strip),
1136/// and need empty OrdGroup fillers inserted between consecutive arrows.
1137fn cd_structure_row(cells: Vec<ParseNode>, mode: Mode) -> Vec<ParseNode> {
1138    // Detect arrow row: all cells are either CdArrow or empty OrdGroup
1139    let is_arrow_row = cells.iter().all(|c| match c {
1140        ParseNode::CdArrow { .. } => true,
1141        ParseNode::OrdGroup { body, .. } => body.is_empty(),
1142        _ => false,
1143    }) && cells.iter().any(|c| matches!(c, ParseNode::CdArrow { .. }));
1144
1145    if is_arrow_row {
1146        let arrows: Vec<ParseNode> = cells
1147            .into_iter()
1148            .filter(|c| matches!(c, ParseNode::CdArrow { .. }))
1149            .collect();
1150
1151        if arrows.is_empty() {
1152            return vec![];
1153        }
1154
1155        let empty = || ParseNode::OrdGroup {
1156            mode,
1157            body: vec![],
1158            semisimple: None,
1159            loc: None,
1160        };
1161
1162        let mut result = Vec::with_capacity(arrows.len() * 2 - 1);
1163        for (idx, arrow) in arrows.into_iter().enumerate() {
1164            if idx > 0 {
1165                result.push(empty());
1166            }
1167            result.push(arrow);
1168        }
1169        result
1170    } else {
1171        // Object row: already in correct format
1172        cells
1173    }
1174}
1175
1176// ── subarray ────────────────────────────────────────────────────────────
1177
1178fn register_subarray(map: &mut HashMap<&'static str, EnvSpec>) {
1179    fn handle_subarray(
1180        ctx: &mut EnvContext,
1181        args: Vec<ParseNode>,
1182        _opt_args: Vec<Option<ParseNode>>,
1183    ) -> ParseResult<ParseNode> {
1184        let colalign = match &args[0] {
1185            ParseNode::OrdGroup { body, .. } => body.clone(),
1186            other if other.is_symbol_node() => vec![other.clone()],
1187            _ => return Err(ParseError::msg("Invalid column alignment for subarray")),
1188        };
1189
1190        let mut cols = Vec::new();
1191        for nde in &colalign {
1192            let ca = nde
1193                .symbol_text()
1194                .ok_or_else(|| ParseError::msg("Expected column alignment character"))?;
1195            match ca {
1196                "l" | "c" => cols.push(AlignSpec {
1197                    align_type: AlignType::Align,
1198                    align: Some(ca.to_string()),
1199                    pregap: None,
1200                    postgap: None,
1201                }),
1202                _ => return Err(ParseError::msg(format!("Unknown column alignment: {}", ca))),
1203            }
1204        }
1205
1206        if cols.len() > 1 {
1207            return Err(ParseError::msg("{subarray} can contain only one column"));
1208        }
1209
1210        let config = ArrayConfig {
1211            cols: Some(cols),
1212            hskip_before_and_after: Some(false),
1213            arraystretch: Some(0.5),
1214            ..Default::default()
1215        };
1216
1217        let res = parse_array(ctx.parser, config, Some(StyleStr::Script))?;
1218
1219        if let ParseNode::Array { ref body, .. } = res {
1220            if !body.is_empty() && body[0].len() > 1 {
1221                return Err(ParseError::msg("{subarray} can contain only one column"));
1222            }
1223        }
1224
1225        Ok(res)
1226    }
1227
1228    map.insert(
1229        "subarray",
1230        EnvSpec {
1231            num_args: 1,
1232            num_optional_args: 0,
1233            handler: handle_subarray,
1234        },
1235    );
1236}
1237
1238// ── prooftree (bussproofs subset) ───────────────────────────────────────
1239//
1240// Implemented: \AxiomC, \UnaryInfC, \BinaryInfC, \TrinaryInfC, \QuaternaryInfC,
1241//   \QuinaryInfC (and their abbreviations \AXC, \UIC, \BIC, \TIC),
1242//   \LeftLabel/\RightLabel (and \LL, \RL), \fCenter,
1243//   line styles: \solidLine, \dashedLine, \noLine (and \always* variants),
1244//   \rootAtTop, \rootAtBottom (and \always* variants).
1245//
1246// Not yet implemented: \InsertBetweenHyps, \ScoreTree, \Cell, \noCell.
1247
1248fn register_prooftree(map: &mut HashMap<&'static str, EnvSpec>) {
1249    fn handle_prooftree(
1250        ctx: &mut EnvContext,
1251        _args: Vec<ParseNode>,
1252        _opt_args: Vec<Option<ParseNode>>,
1253    ) -> ParseResult<ParseNode> {
1254        parse_prooftree(ctx.parser)
1255    }
1256
1257    map.insert(
1258        "prooftree",
1259        EnvSpec {
1260            num_args: 0,
1261            num_optional_args: 0,
1262            handler: handle_prooftree,
1263        },
1264    );
1265}
1266
1267fn proof_command_arity(name: &str) -> Option<usize> {
1268    match name {
1269        "\\UnaryInfC" | "\\UnaryInf" | "\\UIC" => Some(1),
1270        "\\BinaryInfC" | "\\BinaryInf" | "\\BIC" => Some(2),
1271        "\\TrinaryInfC" | "\\TrinaryInf" | "\\TIC" => Some(3),
1272        "\\QuaternaryInfC" | "\\QuaternaryInf" => Some(4),
1273        "\\QuinaryInfC" | "\\QuinaryInf" => Some(5),
1274        _ => None,
1275    }
1276}
1277
1278fn parse_prooftree_arg(parser: &mut Parser, command: &str) -> ParseResult<Vec<ParseNode>> {
1279    let arg = parser
1280        .parse_argument_group(false, None)?
1281        .ok_or_else(|| ParseError::msg(format!("Expected argument for {}", command)))?;
1282    Ok(ParseNode::ord_argument(arg))
1283}
1284
1285fn parse_prooftree(parser: &mut Parser) -> ParseResult<ParseNode> {
1286    let mut stack: Vec<ProofBranch> = Vec::new();
1287    let mut left_label: Option<Vec<ParseNode>> = None;
1288    let mut right_label: Option<Vec<ParseNode>> = None;
1289    let mut next_line_style = ProofLineStyle::Solid;
1290    let mut default_line_style = ProofLineStyle::Solid;
1291    let mut next_root_at_top = false;
1292    let mut default_root_at_top = false;
1293
1294    loop {
1295        parser.consume_spaces()?;
1296        let token = parser.fetch()?;
1297        let command = token.text.clone();
1298
1299        if command == "\\end" {
1300            break;
1301        }
1302        parser.consume();
1303
1304        match command.as_str() {
1305            "\\AxiomC" | "\\Axiom" | "\\AXC" => {
1306                let conclusion = parse_prooftree_arg(parser, &command)?;
1307                stack.push(ProofBranch {
1308                    conclusion,
1309                    premises: Vec::new(),
1310                    left_label: None,
1311                    right_label: None,
1312                    line_style: ProofLineStyle::None,
1313                    root_at_top: false,
1314                });
1315            }
1316            "\\LeftLabel" | "\\LL" => {
1317                left_label = Some(parse_prooftree_arg(parser, &command)?);
1318            }
1319            "\\RightLabel" | "\\RL" => {
1320                right_label = Some(parse_prooftree_arg(parser, &command)?);
1321            }
1322            "\\singleLine" | "\\solidLine" => {
1323                next_line_style = ProofLineStyle::Solid;
1324            }
1325            "\\dashedLine" => {
1326                next_line_style = ProofLineStyle::Dashed;
1327            }
1328            "\\noLine" => {
1329                next_line_style = ProofLineStyle::None;
1330            }
1331            "\\alwaysSingleLine" | "\\alwaysSolidLine" => {
1332                default_line_style = ProofLineStyle::Solid;
1333                next_line_style = ProofLineStyle::Solid;
1334            }
1335            "\\alwaysDashedLine" => {
1336                default_line_style = ProofLineStyle::Dashed;
1337                next_line_style = ProofLineStyle::Dashed;
1338            }
1339            "\\alwaysNoLine" => {
1340                default_line_style = ProofLineStyle::None;
1341                next_line_style = ProofLineStyle::None;
1342            }
1343            "\\rootAtTop" => {
1344                next_root_at_top = true;
1345            }
1346            "\\rootAtBottom" => {
1347                next_root_at_top = false;
1348            }
1349            "\\alwaysRootAtTop" => {
1350                default_root_at_top = true;
1351                next_root_at_top = true;
1352            }
1353            "\\alwaysRootAtBottom" => {
1354                default_root_at_top = false;
1355                next_root_at_top = false;
1356            }
1357            name if proof_command_arity(name).is_some() => {
1358                let arity = proof_command_arity(name).unwrap();
1359                if stack.len() < arity {
1360                    return Err(ParseError::msg(format!(
1361                        "{} needs {} premise(s), but only {} available",
1362                        name,
1363                        arity,
1364                        stack.len()
1365                    )));
1366                }
1367                let conclusion = parse_prooftree_arg(parser, name)?;
1368                let start = stack.len() - arity;
1369                let premises = stack.split_off(start);
1370                stack.push(ProofBranch {
1371                    conclusion,
1372                    premises,
1373                    left_label: left_label.take(),
1374                    right_label: right_label.take(),
1375                    line_style: next_line_style.clone(),
1376                    root_at_top: next_root_at_top,
1377                });
1378                next_line_style = default_line_style.clone();
1379                next_root_at_top = default_root_at_top;
1380            }
1381            _ => {
1382                return Err(ParseError::msg(format!(
1383                    "{} valid only as a supported bussproofs command within prooftree",
1384                    command
1385                )));
1386            }
1387        }
1388    }
1389
1390    if stack.len() != 1 {
1391        return Err(ParseError::msg(format!(
1392            "prooftree ended with {} proof stack item(s), expected 1",
1393            stack.len()
1394        )));
1395    }
1396
1397    if left_label.is_some() || right_label.is_some() {
1398        return Err(ParseError::msg(
1399            "prooftree has a \\LeftLabel or \\RightLabel without a following inference command",
1400        ));
1401    }
1402
1403    Ok(ParseNode::ProofTree {
1404        mode: parser.mode,
1405        tree: stack.pop().unwrap(),
1406        loc: None,
1407    })
1408}