Skip to main content

badness_parser/parser/
grammar.rs

1//! Recursive-descent grammar for LaTeX surface syntax.
2//!
3//! The parser walks the full token stream (trivia included) and emits a flat
4//! list of [`Event`]s — `Start(kind)` / `Tok(idx)` / `Finish` — that
5//! [`super::tree_builder`] replays into a green tree. Because every token is
6//! emitted exactly once, in order, via [`Parser::bump`], losslessness holds by
7//! construction: `pos` only ever advances through `bump`, and nothing else
8//! touches it.
9//!
10//! It is **error-tolerant**: a malformed construct never aborts the parse. Each
11//! recovery records a [`SyntaxError`] on the side channel and either closes the
12//! current node gracefully or skips a single token, always making progress.
13//! Recovery anchors are the LaTeX-natural ones: `\end`, `}`, `]`, `$`, blank
14//! lines, and end of input.
15
16mod expl3;
17mod facts;
18mod prescan;
19mod trivia;
20
21use std::borrow::Cow;
22
23use crate::parser::conditional;
24use crate::parser::core::SyntaxError;
25use crate::parser::events::Event;
26use crate::parser::lexer::{ParseCtx, Token};
27use crate::semantic::signature::{
28    ArgKind, ArgSpec, ArgumentDomain, builtin, match_arg_slot, match_verbatim_arg_slot,
29};
30use crate::syntax::SyntaxKind;
31use facts::{
32    BracketPolicy, is_big_delimiter_command, is_command_definition_command,
33    is_definition_body_command,
34};
35use prescan::PreScan;
36use smol_str::SmolStr;
37use trivia::{BLANK_LINE_NEWLINES, CommentMode};
38
39/// Kept at this path for parser and formatter callers.
40pub use facts::is_def_prefix_command;
41
42/// Re-exported for [`crate::parser::reparse`]'s token tier, whose guard has to
43/// name the same predicate the walk does rather than drift into a copy of it.
44pub(crate) use facts::is_definition_body_command as reads_definition_body;
45
46pub(crate) const BEGIN_CMD: &str = "\\begin";
47pub(crate) const END_CMD: &str = "\\end";
48const LEFT_CMD: &str = "\\left";
49const RIGHT_CMD: &str = "\\right";
50
51/// Maximum number of cursor peeks without consuming a token. This catches
52/// non-advancing loops even on malformed input and resets after every advance.
53const PARSER_STEP_LIMIT: u32 = 15_000_000;
54
55/// A content region that groups its children into `PARAGRAPH` nodes separated
56/// by blank lines. Differs only in how the region terminates.
57#[derive(Clone, Copy, PartialEq, Eq)]
58enum Block {
59    /// The whole document; ends at EOF.
60    Document,
61    /// An environment body; ends at the next `\end` (any name — the caller
62    /// checks the name and decides whether to consume it).
63    Environment,
64    /// A `.dtx` `macrocode` body: macro code, so a bare `\end` in the code is a
65    /// plain command, and the block ends *positionally* at the pre-scanned frame
66    /// terminator ([`Parser::macrocode_end`]), never at an arbitrary `\end`.
67    Macrocode,
68}
69
70/// Parse a token stream into parser events and a list of syntax errors.
71pub(crate) fn parse(tokens: &[Token], ctx: &ParseCtx) -> (Vec<Event>, Vec<SyntaxError>) {
72    let mut p = Parser::new(tokens, ctx);
73    p.document();
74    debug_assert_balanced(&p.events);
75    (p.events, p.errors)
76}
77
78/// Debug-only structural tripwire: the event stream must be balanced — every
79/// `Start` matched by a later `Finish`, no `Finish` before its `Start`, and the
80/// document node closed exactly once. This is the cheap analog of
81/// rust-analyzer's per-`Marker` `DropBomb`: a grammar edit that leaks an
82/// [`Parser::open`] without a [`Parser::close`] (or a [`Parser::precede`] that
83/// splices in a `Start` nobody closes) is caught right here,
84/// counting *all* start/finish events regardless of how they were emitted,
85/// before [`super::tree_builder`] feeds rowan's `GreenNodeBuilder` and fails with
86/// a far more opaque `finish_node` panic. Compiled out of release builds.
87fn debug_assert_balanced(events: &[Event]) {
88    if !cfg!(debug_assertions) {
89        return;
90    }
91    let mut depth: i32 = 0;
92    for ev in events {
93        match ev {
94            Event::Start(_) => depth += 1,
95            Event::Finish => {
96                depth -= 1;
97                debug_assert!(depth >= 0, "parser emitted a Finish with no open node");
98            }
99            Event::Tok(_) | Event::SubTok { .. } => {}
100        }
101    }
102    debug_assert_eq!(
103        depth, 0,
104        "parser left {depth} node(s) unclosed at end of parse"
105    );
106}
107
108fn builtin_command_args(head: &str) -> Option<&'static [ArgSpec]> {
109    head.strip_prefix('\\')
110        .and_then(|name| builtin().command(name))
111        .map(|sig| sig.args.as_ref())
112}
113
114#[derive(Clone, Copy, PartialEq, Eq)]
115struct WalkKey {
116    macrocode_end: Option<usize>,
117    in_def_body: bool,
118    in_group: bool,
119    plain_braces: u32,
120    enclosing_math_is_dollar: bool,
121}
122
123struct GateBatch {
124    key: WalkKey,
125    verdicts: std::collections::HashMap<usize, Option<usize>>,
126}
127
128trait VerdictSink {
129    fn insert(&mut self, opener: usize, verdict: Option<usize>);
130}
131
132impl VerdictSink for std::collections::HashMap<usize, Option<usize>> {
133    fn insert(&mut self, opener: usize, verdict: Option<usize>) {
134        std::collections::HashMap::insert(self, opener, verdict);
135    }
136}
137
138struct SeedVerdict {
139    seed: usize,
140    verdict: Option<Option<usize>>,
141}
142
143impl VerdictSink for SeedVerdict {
144    fn insert(&mut self, opener: usize, verdict: Option<usize>) {
145        if opener == self.seed {
146            self.verdict = Some(verdict);
147        }
148    }
149}
150
151#[derive(PartialEq, Eq)]
152enum StrayBrace {
153    RefutesInGroup,
154    ClosesInGroup,
155    RefutesAlways,
156}
157
158#[derive(Clone, Copy, PartialEq, Eq)]
159enum MathAnchor {
160    None,
161    Opening,
162    Closing,
163}
164
165impl MathAnchor {
166    fn anchors(self, text: &str) -> bool {
167        match self {
168            MathAnchor::None => false,
169            MathAnchor::Opening => matches!(text, "\\[" | "\\("),
170            MathAnchor::Closing => matches!(text, "\\]" | "\\)"),
171        }
172    }
173}
174
175#[derive(Clone, Copy, PartialEq, Eq)]
176enum DollarAnchor {
177    Content,
178    Refutes,
179    Transparent,
180}
181
182#[derive(Clone, Copy, PartialEq, Eq)]
183enum ParagraphAnchor {
184    None,
185    OwnLevel,
186    AnyDepth,
187}
188
189#[derive(Clone, Copy, PartialEq, Eq)]
190enum EnvAnchor {
191    Counts,
192    Refutes,
193}
194
195#[derive(Clone, Copy, PartialEq, Eq)]
196enum Nesting {
197    Counted,
198    Interleaved,
199}
200
201trait GatePolicy {
202    const PARAGRAPH_ANCHOR: ParagraphAnchor;
203
204    const STRAY_BRACE: StrayBrace = StrayBrace::RefutesInGroup;
205
206    const MATH_ANCHOR: MathAnchor = MathAnchor::Opening;
207
208    const NESTING: Nesting = Nesting::Counted;
209
210    const OPENER_IS_ENV_BEGIN: bool = false;
211
212    const ENV_END_UNWINDS_OPENERS: bool = false;
213
214    const ANCHORS_AT_ANY_DEPTH: bool = false;
215
216    const ENV_ANCHOR: EnvAnchor = EnvAnchor::Counts;
217
218    const ENV_ANCHOR_IN_MACRO_CODE: bool = false;
219
220    const CLOSER_NEEDS_ENV_BALANCE: bool = true;
221
222    const MACROCODE_FRAME_ANCHORS: bool = true;
223
224    fn dollar_anchor(&self) -> DollarAnchor {
225        if Self::MATH_ANCHOR == MathAnchor::None {
226            DollarAnchor::Content
227        } else {
228            DollarAnchor::Refutes
229        }
230    }
231
232    fn last_closer(&self, p: &Parser<'_>) -> Option<usize>;
233
234    fn opens_at(&self, p: &Parser<'_>, i: usize) -> bool;
235
236    fn closes_at(&self, p: &Parser<'_>, i: usize) -> bool;
237
238    fn pairs(&self, p: &Parser<'_>, opener: usize, closer: usize) -> bool {
239        let _ = (p, opener, closer);
240        true
241    }
242}
243
244struct ConditionalGate;
245
246impl GatePolicy for ConditionalGate {
247    const PARAGRAPH_ANCHOR: ParagraphAnchor = ParagraphAnchor::OwnLevel;
248
249    fn last_closer(&self, p: &Parser<'_>) -> Option<usize> {
250        p.last_fi
251    }
252
253    fn opens_at(&self, p: &Parser<'_>, i: usize) -> bool {
254        p.conditional_openers.contains(&i)
255    }
256
257    fn closes_at(&self, p: &Parser<'_>, i: usize) -> bool {
258        p.conditional_flow_at(i) == Some(conditional::FlowWord::Fi)
259    }
260}
261
262struct AliasGate;
263
264impl GatePolicy for AliasGate {
265    const PARAGRAPH_ANCHOR: ParagraphAnchor = ParagraphAnchor::None;
266
267    fn last_closer(&self, p: &Parser<'_>) -> Option<usize> {
268        p.last_alias_closer
269    }
270
271    fn opens_at(&self, p: &Parser<'_>, i: usize) -> bool {
272        p.alias_openers.contains_key(&i) && !p.in_macro_code(i)
273    }
274
275    fn closes_at(&self, p: &Parser<'_>, i: usize) -> bool {
276        p.closer_target(i).is_some() && !p.in_macro_code(i)
277    }
278
279    fn pairs(&self, p: &Parser<'_>, opener: usize, closer: usize) -> bool {
280        p.closer_target(closer) == p.alias_openers.get(&opener).map(SmolStr::as_str)
281    }
282}
283
284struct EnvGate;
285
286impl GatePolicy for EnvGate {
287    const PARAGRAPH_ANCHOR: ParagraphAnchor = ParagraphAnchor::None;
288    const STRAY_BRACE: StrayBrace = StrayBrace::ClosesInGroup;
289    const MATH_ANCHOR: MathAnchor = MathAnchor::None;
290    const OPENER_IS_ENV_BEGIN: bool = true;
291    const ENV_END_UNWINDS_OPENERS: bool = true;
292
293    fn last_closer(&self, p: &Parser<'_>) -> Option<usize> {
294        p.last_r_brace
295    }
296
297    fn opens_at(&self, p: &Parser<'_>, i: usize) -> bool {
298        p.env_begin_at(i) && !p.in_macro_code(i)
299    }
300
301    fn closes_at(&self, _p: &Parser<'_>, _i: usize) -> bool {
302        false
303    }
304}
305
306struct DelimMathGate {
307    closer: &'static str,
308}
309
310impl GatePolicy for DelimMathGate {
311    const PARAGRAPH_ANCHOR: ParagraphAnchor = ParagraphAnchor::OwnLevel;
312    const STRAY_BRACE: StrayBrace = StrayBrace::RefutesAlways;
313    const MATH_ANCHOR: MathAnchor = MathAnchor::None;
314    const ANCHORS_AT_ANY_DEPTH: bool = true;
315    const CLOSER_NEEDS_ENV_BALANCE: bool = false;
316    const MACROCODE_FRAME_ANCHORS: bool = false;
317
318    fn last_closer(&self, p: &Parser<'_>) -> Option<usize> {
319        if self.closer == "\\]" {
320            p.last_display_math_closer
321        } else {
322            p.last_inline_math_closer
323        }
324    }
325
326    fn opens_at(&self, _p: &Parser<'_>, _i: usize) -> bool {
327        false
328    }
329
330    fn closes_at(&self, p: &Parser<'_>, i: usize) -> bool {
331        let t = &p.tokens[i];
332        t.kind == SyntaxKind::CONTROL_SYMBOL && t.text.as_str() == self.closer
333    }
334}
335
336struct DollarGate {
337    display: bool,
338}
339
340impl GatePolicy for DollarGate {
341    const PARAGRAPH_ANCHOR: ParagraphAnchor = ParagraphAnchor::OwnLevel;
342    const STRAY_BRACE: StrayBrace = StrayBrace::RefutesAlways;
343    const MATH_ANCHOR: MathAnchor = MathAnchor::None;
344    const ANCHORS_AT_ANY_DEPTH: bool = true;
345    const CLOSER_NEEDS_ENV_BALANCE: bool = false;
346    const MACROCODE_FRAME_ANCHORS: bool = false;
347
348    fn last_closer(&self, p: &Parser<'_>) -> Option<usize> {
349        p.last_dollar
350    }
351
352    fn opens_at(&self, _p: &Parser<'_>, _i: usize) -> bool {
353        false
354    }
355
356    fn closes_at(&self, p: &Parser<'_>, i: usize) -> bool {
357        p.tokens[i].kind == SyntaxKind::DOLLAR
358            && (!self.display || p.tokens.get(i + 1).map(|t| t.kind) == Some(SyntaxKind::DOLLAR))
359    }
360}
361
362struct LeftRightGate;
363
364impl GatePolicy for LeftRightGate {
365    const PARAGRAPH_ANCHOR: ParagraphAnchor = ParagraphAnchor::OwnLevel;
366    const STRAY_BRACE: StrayBrace = StrayBrace::RefutesAlways;
367    const MATH_ANCHOR: MathAnchor = MathAnchor::Closing;
368    const NESTING: Nesting = Nesting::Interleaved;
369    const MACROCODE_FRAME_ANCHORS: bool = false;
370
371    fn last_closer(&self, p: &Parser<'_>) -> Option<usize> {
372        p.last_right
373    }
374
375    fn opens_at(&self, p: &Parser<'_>, i: usize) -> bool {
376        let t = &p.tokens[i];
377        t.kind == SyntaxKind::CONTROL_WORD && t.text.as_str() == LEFT_CMD
378    }
379
380    fn closes_at(&self, p: &Parser<'_>, i: usize) -> bool {
381        let t = &p.tokens[i];
382        t.kind == SyntaxKind::CONTROL_WORD && t.text.as_str() == RIGHT_CMD
383    }
384}
385
386struct TextBracketGate;
387
388impl GatePolicy for TextBracketGate {
389    const PARAGRAPH_ANCHOR: ParagraphAnchor = ParagraphAnchor::AnyDepth;
390    const STRAY_BRACE: StrayBrace = StrayBrace::RefutesAlways;
391    const MATH_ANCHOR: MathAnchor = MathAnchor::None;
392    const ANCHORS_AT_ANY_DEPTH: bool = true;
393    const ENV_ANCHOR: EnvAnchor = EnvAnchor::Refutes;
394
395    fn last_closer(&self, p: &Parser<'_>) -> Option<usize> {
396        p.last_r_bracket
397    }
398
399    fn opens_at(&self, p: &Parser<'_>, i: usize) -> bool {
400        p.bracket_abuts_command(i)
401    }
402
403    fn closes_at(&self, p: &Parser<'_>, i: usize) -> bool {
404        p.tokens[i].kind == SyntaxKind::R_BRACKET
405    }
406}
407
408struct MathBracketGate {
409    enclosing_is_dollar: bool,
410}
411
412impl GatePolicy for MathBracketGate {
413    const PARAGRAPH_ANCHOR: ParagraphAnchor = ParagraphAnchor::AnyDepth;
414    const STRAY_BRACE: StrayBrace = StrayBrace::RefutesAlways;
415    const MATH_ANCHOR: MathAnchor = MathAnchor::Closing;
416    const ANCHORS_AT_ANY_DEPTH: bool = true;
417    const ENV_ANCHOR: EnvAnchor = EnvAnchor::Refutes;
418    const ENV_ANCHOR_IN_MACRO_CODE: bool = true;
419    fn dollar_anchor(&self) -> DollarAnchor {
420        if self.enclosing_is_dollar {
421            DollarAnchor::Refutes
422        } else {
423            DollarAnchor::Transparent
424        }
425    }
426
427    fn last_closer(&self, p: &Parser<'_>) -> Option<usize> {
428        p.last_r_bracket
429    }
430
431    fn opens_at(&self, p: &Parser<'_>, i: usize) -> bool {
432        p.bracket_abuts_command(i)
433    }
434
435    fn closes_at(&self, p: &Parser<'_>, i: usize) -> bool {
436        p.tokens[i].kind == SyntaxKind::R_BRACKET
437    }
438}
439
440struct MacrocodeBracketGate;
441
442impl GatePolicy for MacrocodeBracketGate {
443    const PARAGRAPH_ANCHOR: ParagraphAnchor = ParagraphAnchor::AnyDepth;
444    const STRAY_BRACE: StrayBrace = StrayBrace::RefutesAlways;
445    const MATH_ANCHOR: MathAnchor = MathAnchor::None;
446    const ANCHORS_AT_ANY_DEPTH: bool = true;
447    const ENV_ANCHOR: EnvAnchor = EnvAnchor::Refutes;
448
449    fn last_closer(&self, p: &Parser<'_>) -> Option<usize> {
450        p.last_r_bracket
451    }
452
453    fn opens_at(&self, _p: &Parser<'_>, _i: usize) -> bool {
454        false
455    }
456
457    fn closes_at(&self, p: &Parser<'_>, i: usize) -> bool {
458        p.tokens[i].kind == SyntaxKind::R_BRACKET
459    }
460}
461
462struct Parser<'t> {
463    tokens: &'t [Token],
464    /// User-defined verbatim constructs, consulted to route a verbatim environment to
465    /// its raw-body branch (its body is already one `VERBATIM_BODY` token from the
466    /// lexer; the grammar must not try to parse it structurally).
467    ctx: &'t ParseCtx,
468    /// `starts[i]` is the byte offset of token `i`; `starts[len]` is the total
469    /// length. Used to give syntax errors byte ranges.
470    starts: Vec<usize>,
471    pos: usize,
472    events: Vec<Event>,
473    errors: Vec<SyntaxError>,
474    /// Consecutive-peek budget for the stuck-loop guard ([`Self::step`]).
475    /// `Cell` because the lookahead primitives that tick it are `&self`.
476    steps: std::cell::Cell<u32>,
477    /// The cursor position at the last [`Self::step`] tick; the budget resets
478    /// whenever `pos` has advanced past it (i.e. real progress was made).
479    last_step_pos: std::cell::Cell<usize>,
480    /// One entry per lexically enclosing math body (`$…$`, `\[…\]`, `\(…\)`,
481    /// math environments), innermost last, holding that level's *flavor*:
482    /// `true` for a `$…$`/`$$…$$` (dollar-delimited) level, `false` for
483    /// `\[…\]`/`\(…\)` and math environments.
484    ///
485    /// Its **depth** ([`Self::in_math`]) is read where the `math` routing flags
486    /// threaded through the grammar are not enough: it *persists* into the
487    /// text-mode body of an unknown environment nested inside math
488    /// (`\[ … \begin{myaligned} … \]`), where the grammar can't verify the body
489    /// is math but the enclosing delimiters are a static lexical fact, and
490    /// optional-argument attachment uses it to treat a spaced `[` as content
491    /// (see [`Self::attach_arguments`]).
492    ///
493    /// Its **last entry** ([`Self::enclosing_math_is_dollar`]) is read by
494    /// [`Self::bracket_closes_before_math_end`]: inside dollar math a `$` is the
495    /// closer (a boundary), whereas inside `\[…\]` a `$` opens a genuine nested
496    /// inline region (`\inferrule*[right=$\Pi$-eq]`), so the two must be scanned
497    /// differently.
498    ///
499    /// Not every `MATH` node pushes here: [`Self::left_right`] opens one without
500    /// a push, because a `\left…\right` always sits inside math already.
501    math_dollar: Vec<bool>,
502    /// True while parsing the attached arguments of a definition-body command
503    /// ([`is_definition_body_command`], issues #45/#55). Those groups are
504    /// macro-code definition bodies that need not self-balance
505    /// `\begin`/`\end`, so while set, `\begin`/`\end` parse as plain commands
506    /// ([`Self::element`], [`Self::math_atom`]) and stop being bail anchors for
507    /// an optional argument ([`Self::optional`]). Saved and restored around
508    /// [`Self::attach_arguments`] in [`Self::command`], so it covers the whole
509    /// definition subtree (nested groups included) and nothing after it.
510    in_def_body: bool,
511    /// Environment names whose `\begin` the brace-group gate demoted to a plain
512    /// command ([`Self::environment_escapes_group`]). Their `\end` is then an
513    /// orphan by construction — the gate removed its partner, not the author — so
514    /// [`Self::end_orphans_a_demoted_begin`] demotes it in the same way instead of
515    /// letting it unwind (and falsely un-close) every enclosing environment.
516    demoted_envs: std::collections::HashSet<String>,
517    /// Names of the environments open around the cursor, outermost first. Read
518    /// only by [`Self::end_orphans_a_demoted_begin`], to tell an `\end` that
519    /// really does close something from one whose `\begin` was demoted.
520    open_envs: Vec<String>,
521    /// Token index of the `{` opening each currently-open brace group, innermost
522    /// last ([`Self::group`] / [`Self::math_group`]).
523    ///
524    /// Its **depth** ([`Self::in_group`]) is the `\end`-side twin of
525    /// [`Self::environment_escapes_group`]: an `\end` reached inside a group has
526    /// its `\begin` outside it, so it is macro code rather than a stray
527    /// (`\StopEventually{\end{document}}`, issue #71).
528    ///
529    /// Its **last entry** is read by [`Self::doc_margin_exempt`] to tell a group
530    /// the `.dtx` *documentation* layer opened itself from one stranded by the
531    /// code layer.
532    group_opens: Vec<usize>,
533    /// Inside a `.dtx` `macrocode` body: the token index of the terminating
534    /// frame `\end` (or `tokens.len()` when the frame is missing), pre-scanned
535    /// by [`Self::macrocode_body`]. `None` outside a macrocode body. The frame
536    /// is the *only* terminator of the chunk (docstrip is line-oriented), so
537    /// [`Self::at_block_end`] and the bracket/optional guards read it to keep
538    /// any construct from consuming past the frame.
539    macrocode_end: Option<usize>,
540    /// Brace tokens inside the current `macrocode` body with no match within
541    /// the chunk. A `macrocode` chunk is macro code: a definition regularly
542    /// opens a `{` in one chunk and closes it in a later one (`\def\foo#1{%` …
543    /// frame … `bar}`), so an unmatched brace is an ordinary token — no
544    /// `GROUP`, no unclosed/unmatched diagnostic. Matched pairs still parse as
545    /// groups. Computed per chunk by [`Self::macrocode_body`].
546    plain_braces: std::collections::HashSet<usize>,
547    /// Bumped on every mutation of [`Self::plain_braces`], so a gate batch can
548    /// key on the set without cloning it ([`WalkKey`]).
549    plain_braces_version: u32,
550    /// expl3 catcode-mode toggle tokens, ascending: `(token index, state after
551    /// the toggle)`. The same fixed toggle set the lexer flips
552    /// ([`expl_toggle`]), pre-scanned once so [`Self::in_expl_region`] is a
553    /// binary search. An expl3 region is *code* — token lists pass
554    /// `\begin`/`\end` around as data (`\tl_set:Nn { \begin{longtable} … }`,
555    /// issue #60) — so inside one, `\begin`/`\end` parse as plain commands
556    /// exactly as in a definition body ([`Self::plain_env`]). `.dtx` doc-margin
557    /// lines are exempt: a region regularly spans macrocode chunks, and the
558    /// doc-layer markup between them (`\begin{macro}`, the frames) must keep
559    /// pairing.
560    expl_toggles: Vec<(usize, bool)>,
561    /// The `.dtx` doc-margin lines, as `(first DOC_MARGIN on the line, the line's
562    /// terminating NEWLINE)`, ascending and disjoint. Pre-scanned once so
563    /// [`Self::on_doc_margin_line`] is a binary search rather than a walk back to
564    /// the previous newline. **Empty for every non-`.dtx` file** — only that lexer
565    /// mode emits `DOC_MARGIN` — so the predicate costs nothing there.
566    doc_margin_lines: Vec<(usize, usize)>,
567    /// Token indices of the `CONTROL_WORD`s that are *live* conditional openers:
568    /// `\if`-prefixed, not one of the brace-argument `if*` macros, and not
569    /// sitting in an operand slot (`\newif\if@foo`, `\let\ifpdf\iftrue`) or an
570    /// `\ifcsname` body. Pre-scanned once in [`Self::new`] because the
571    /// operand-slot rule is a *running* state over the whole token stream, which
572    /// the recursive-descent walk cannot carry, and because both
573    /// [`Self::element`] and [`Self::conditional_pairs`] need the same verdict.
574    ///
575    /// Openers inside an expl3 region are excluded outright: in-region layout is
576    /// the formatter's, owned through `semantic::expl3`'s statement segmentation
577    /// (`AGENTS.md`, expl3 code formatting), and a `CONDITIONAL` node there would
578    /// contend with it. The exclusion also keeps the `\else:`/`\or:`/`\fi:`
579    /// spellings out of scope. Recognition itself is shared with the linter's
580    /// `ConditionalIndex` ([`conditional::OpenerScan`]) so the two cannot drift.
581    conditional_openers: std::collections::HashSet<usize>,
582    /// Dollar tokens in a `\def`-family parameter text. TeX reads these as
583    /// literal delimiters before the replacement body, so they cannot open math.
584    /// Pre-scanned once because a false opener can otherwise pair with a later
585    /// definition's delimiter and swallow both bodies (issue #129).
586    def_parameter_dollars: std::collections::HashSet<usize>,
587    /// Token indices of *environment-alias openers* — bare control words whose
588    /// definition body is exactly `\begin{X}` — mapped to the target environment
589    /// `X` (issue #109). Pre-scanned in [`Self::new`] for the same reason as
590    /// [`Self::conditional_openers`]: the definee filter is a running state over
591    /// the stream that the recursive walk cannot carry.
592    ///
593    /// That filter is load-bearing, not defensive, and it counts *slots* rather
594    /// than testing a single word ([`definition_name_slots`]). [`Self::command`]
595    /// sets `in_def_body` after a `\def`-family head only when the definee is a
596    /// `CONTROL_SYMBOL`, so in `\def\bea{\begin{eqnarray}}` the definee `\bea`
597    /// reaches [`Self::element`] as an ordinary sibling command at brace depth 0
598    /// with `in_macro_code` false. Unfiltered, the dispatch fires on it, the scan
599    /// finds `\def\eea`'s definee at the same depth, and the two *definition lines*
600    /// pair into an `ENVIRONMENT` — lossless and silent, but layout is destroyed.
601    /// `\let\oldbea\bea` is the same failure one slot over: the *source* operand
602    /// is a mention, not a call, and left live it pairs with a later `\eea` and
603    /// swallows the prose in between. The braced `\newcommand{\bea}{…}` form is
604    /// covered by `in_def_body` instead. Expl3 regions are excluded outright, as
605    /// for conditionals.
606    alias_openers: std::collections::HashMap<usize, SmolStr>,
607    /// The closer mirror of [`Self::alias_openers`] (`\end{X}` bodies).
608    alias_closers: std::collections::HashMap<usize, SmolStr>,
609    /// Token indices of *literal* `\end{X}` closers whose `X` some alias opens,
610    /// mapped to that `X` (issue #117).
611    ///
612    /// A begin alias stands in for `\begin{X}`, so what closes it is whatever
613    /// closes an `X` — a closer alias, and equally the `\end{X}` an author
614    /// writes out. Kept a separate map from [`Self::alias_closers`] because the
615    /// two are consumed differently: this one's token is a `\end` carrying a
616    /// `NAME_GROUP`, so [`Self::alias_environment`] emits the same two-token
617    /// `END` a spelled-out environment does, and [`Self::finish_environment`]'s
618    /// mirror arm must not mistake one for the other.
619    ///
620    /// The index is an *over*-approximation, per the pre-scan's standing rule:
621    /// it is built from `peek_end_name`, which is looser than the walk's
622    /// [`Self::env_end_at`], so the gate re-tests membership against that.
623    literal_alias_closers: std::collections::HashMap<usize, SmolStr>,
624    /// The largest index in [`Self::alias_closers`] or
625    /// [`Self::literal_alias_closers`], or `None` when the file has neither.
626    /// [`Self::alias_closer`] can only ever return an index in one of those two
627    /// maps, so this bounds its forward scan — which is what keeps a file of
628    /// openers that never pair linear instead of quadratic.
629    last_alias_closer: Option<usize>,
630    /// The `last_alias_closer` treatment, generalized (`TODO.md`, container
631    /// stack C0): each shape gate succeeds only at one closer token shape, so
632    /// truncating its scan at the last occurrence of that shape is
633    /// verdict-preserving — past it, every path is a refusal, whether by anchor
634    /// or by running out of range — and a file with none refuses without
635    /// scanning at all. Recording may *over*-approximate (a `\fi` inside an
636    /// expl3 region, a `\right` inside a brace group): a bound only needs to be
637    /// at or past the last index that could ever succeed.
638    ///
639    /// This one is the last `]`, bounding [`Self::bracket_closes_in_text`] and
640    /// [`Self::bracket_closes_before_math_end`].
641    last_r_bracket: Option<usize>,
642    /// Last `\]` — bounds [`Self::delim_math_closes`] for a `\[` opener.
643    last_display_math_closer: Option<usize>,
644    /// Last `\)` — bounds [`Self::delim_math_closes`] for a `\(` opener.
645    last_inline_math_closer: Option<usize>,
646    /// Last `\right` — bounds [`Self::left_right_closes`].
647    last_right: Option<usize>,
648    /// Last `}` — bounds [`Self::environment_escapes_group`], whose only `true`
649    /// is a `}` at depth 0. Rarely effective (every `\begin{…}` opener carries a
650    /// `}` in its own name group, so this index usually sits near EOF), but
651    /// sound and free; the gate's residual quadratic shape is recorded in
652    /// `TODO.md` (container stack, C2).
653    last_r_brace: Option<usize>,
654    /// Last `\fi`-flavored flow word — bounds [`Self::conditional_closer`].
655    last_fi: Option<usize>,
656    /// Last `$` — bounds [`Self::dollar_closes`]. The weakest of these bounds by
657    /// construction, since this gate's closer is its opener's own token kind: in
658    /// the adversarial shape (a file of `$` openers) the last one *is* an
659    /// opener, so the bound cuts nothing. Recorded anyway, because the driver's
660    /// contract is that every gate names the last index that could settle an
661    /// entry, and a file whose `$`s all sit before a long tail does get the cut.
662    last_dollar: Option<usize>,
663    /// The most recent [`ConditionalGate`] batch ([`Self::gate_batch`]),
664    /// memoized with the walk state its scan read. A lookup hits only when
665    /// that key matches the walk's current state *and* the queried opener was
666    /// settled by the batch; anything else re-batches from the queried opener.
667    /// One slot is all the reuse there is: [`Self::element`] queries each
668    /// opener once, in ascending order, under a stable state between
669    /// re-batches. `RefCell` because the gate is `&self` — the pattern the
670    /// alias gate's pre-batch memo used, with a map of settled openers where
671    /// that one kept a single verdict.
672    conditional_batch: std::cell::RefCell<Option<GateBatch>>,
673    /// Tokens visited by the shape-gate scans, summed over the whole parse. A
674    /// measurement hook for the linearity regression tests in this file's
675    /// `mod tests` — never a budget (`TODO.md` rejects scan budgets as
676    /// hard-coded special cases). `Cell` because the gates take `&self`, and
677    /// `cfg(test)` because the counter is pure measurement: ticking it once per
678    /// scanned token is a real cost in the driver's hottest loop, paid for
679    /// nothing in a release build.
680    #[cfg(test)]
681    scan_work: std::cell::Cell<usize>,
682    /// The [`EnvGate`] twin of [`Self::conditional_batch`]. Its verdicts are
683    /// the *scan's* alone: [`Self::environment_escapes_group`]'s per-opener
684    /// pre-checks (the group depth, the `.dtx` doc-margin exemption) are applied
685    /// at query time, so a batch entry never carries them.
686    env_batch: std::cell::RefCell<Option<GateBatch>>,
687    /// The [`AliasGate`] twin of [`Self::conditional_batch`]. Both
688    /// [`Self::starts_block_env`] and the [`Self::element`] dispatch ask about
689    /// the same opener at the same cursor position, so even before the batch
690    /// settled its neighbors this slot was load-bearing: without it every
691    /// opener paid for its walk twice.
692    alias_batch: std::cell::RefCell<Option<GateBatch>>,
693    /// The [`LeftRightGate`] twin of [`Self::conditional_batch`]. Its openers
694    /// nest densely — a `\left` whose `\right` the walk cannot reach is retried
695    /// as a plain command and every `\left` after it asked in turn — so the
696    /// batch is what keeps a run of them from being quadratic.
697    left_right_batch: std::cell::RefCell<Option<GateBatch>>,
698    /// The [`TextBracketGate`] twin of [`Self::conditional_batch`]. A `[` the
699    /// gate refuses stays an ordinary token the walk steps over, and the next
700    /// command-abutting `[` is asked in turn, so a run of them re-scanned per
701    /// opener before the batch.
702    text_bracket_batch: std::cell::RefCell<Option<GateBatch>>,
703    /// The [`MathBracketGate`] twin, keyed like the others — including on the
704    /// enclosing math's flavor, which this gate alone reads ([`WalkKey`]).
705    math_bracket_batch: std::cell::RefCell<Option<GateBatch>>,
706    /// The arity-directed expl3 scan's matching-brace table
707    /// ([`expl3::BraceMatches`]). Not a gate batch — it settles *pairings*
708    /// rather than verdicts — but the same trade for the same reason: nested
709    /// call sites ask about spans their enclosing ones already covered.
710    brace_matches: std::cell::RefCell<Option<expl3::BraceMatches>>,
711    /// Token index of the alias closer bounding the environment body currently
712    /// being parsed, if any. Saved and restored around the body in
713    /// [`Self::alias_environment`]. An alias environment has no `\end{…}` to stop
714    /// at, so this positional bound is what terminates it — read by
715    /// [`Self::at_block_end`], [`Self::trivia_run_is_separator`], and
716    /// [`Self::binding_run`].
717    alias_end: Option<usize>,
718    /// Whether the environment body currently being parsed is a curated
719    /// `statementBody` body ([`ParseCtx::is_statement_environment`], the
720    /// TikZ/pgf picture family), so [`Self::parse_block`]'s run loop wraps each
721    /// run up to a top-level `;`-carrying `WORD` in a `STATEMENT` node. Saved
722    /// and restored around every environment body — a nested non-statement
723    /// environment turns it off for its own body, a nested `scope` turns it
724    /// back on — and never inherited by a `group()`/`conditional()` element
725    /// loop, which is what keeps recognition to the body's own top level.
726    in_statement_body: bool,
727}
728
729impl<'t> Parser<'t> {
730    fn new(tokens: &'t [Token], ctx: &'t ParseCtx) -> Self {
731        let pre = PreScan::run(tokens, ctx);
732        Self {
733            tokens,
734            ctx,
735            starts: pre.starts,
736            pos: 0,
737            events: Vec::new(),
738            steps: std::cell::Cell::new(0),
739            last_step_pos: std::cell::Cell::new(0),
740            errors: Vec::new(),
741            math_dollar: Vec::new(),
742            in_def_body: false,
743            demoted_envs: std::collections::HashSet::new(),
744            open_envs: Vec::new(),
745            group_opens: Vec::new(),
746            macrocode_end: None,
747            plain_braces: std::collections::HashSet::new(),
748            plain_braces_version: 0,
749            expl_toggles: pre.expl_toggles,
750            doc_margin_lines: pre.doc_margin_lines,
751            conditional_openers: pre.conditional_openers,
752            def_parameter_dollars: pre.def_parameter_dollars,
753            last_alias_closer: pre
754                .alias_closers
755                .keys()
756                .chain(pre.literal_alias_closers.keys())
757                .copied()
758                .max(),
759            last_r_bracket: pre.last_r_bracket,
760            last_display_math_closer: pre.last_display_math_closer,
761            last_inline_math_closer: pre.last_inline_math_closer,
762            last_right: pre.last_right,
763            last_r_brace: pre.last_r_brace,
764            last_fi: pre.last_fi,
765            last_dollar: pre.last_dollar,
766            conditional_batch: std::cell::RefCell::new(None),
767            #[cfg(test)]
768            scan_work: std::cell::Cell::new(0),
769            alias_openers: pre.alias_openers,
770            alias_closers: pre.alias_closers,
771            literal_alias_closers: pre.literal_alias_closers,
772            alias_batch: std::cell::RefCell::new(None),
773            env_batch: std::cell::RefCell::new(None),
774            left_right_batch: std::cell::RefCell::new(None),
775            text_bracket_batch: std::cell::RefCell::new(None),
776            math_bracket_batch: std::cell::RefCell::new(None),
777            brace_matches: std::cell::RefCell::new(None),
778            alias_end: None,
779            in_statement_body: false,
780        }
781    }
782
783    /// The conditional divider or closer at token `idx`, if any. Flow words are
784    /// classified from the name alone — `\else`/`\or`/`\fi` are never anything
785    /// else — but never inside an expl3 region, where the openers are excluded
786    /// too (see [`Self::conditional_openers`]).
787    ///
788    /// Total in `idx`: `Parser::pos` is one past the last token at EOF, and
789    /// [`Self::conditional`] asks about the cursor after its loop has run out of
790    /// input, so an out-of-range index is "no flow word here", not a bug.
791    fn conditional_flow_at(&self, idx: usize) -> Option<conditional::FlowWord> {
792        let t = self.tokens.get(idx)?;
793        if t.kind != SyntaxKind::CONTROL_WORD || self.in_expl_region(idx) {
794            return None;
795        }
796        t.text.strip_prefix('\\').and_then(conditional::flow_word)
797    }
798
799    /// True when token `idx` sits inside an expl3 region (after an
800    /// `\ExplSyntaxOn`/`\ProvidesExpl*` with no intervening `\ExplSyntaxOff`).
801    /// The toggle token itself is outside its own region.
802    fn in_expl_region(&self, idx: usize) -> bool {
803        let n = self.expl_toggles.partition_point(|&(i, _)| i < idx);
804        n > 0 && self.expl_toggles[n - 1].1
805    }
806
807    /// True when token `idx` lies on a `.dtx` doc-margin line (a `DOC_MARGIN`
808    /// opens its physical line).
809    ///
810    /// Answered from the pre-scanned [`Self::doc_margin_lines`], the same posture
811    /// as [`Self::in_expl_region`]. This used to walk back to the preceding
812    /// `NEWLINE`, justified by doc lines being short and [`Self::in_macro_code`]
813    /// reaching it only for a token already inside an expl3 region — but
814    /// [`Self::doc_margin_exempt`] calls it *unconditionally*, and that runs from
815    /// [`Self::environment_escapes_group`] and its `\end` mirror for every
816    /// `\begin`/`\end` in the file. On a document written as one long line the
817    /// walk is `O(line length)` per opener, so the pair was `O(N x line length)`.
818    fn on_doc_margin_line(&self, idx: usize) -> bool {
819        // The candidate is the last line whose margin opens strictly before
820        // `idx`; the lines are disjoint, so no earlier one can reach. It reaches
821        // when `idx` is still on it — at or before its terminating newline, which
822        // is where the backward scan would have stopped.
823        let n = self.doc_margin_lines.partition_point(|&(m, _)| m < idx);
824        n > 0 && self.doc_margin_lines[n - 1].1 >= idx
825    }
826
827    /// Whether token `idx` is covered by the `.dtx` doc-margin exemption from the
828    /// brace-group gates ([`Self::environment_escapes_group`] and its `\end`-side
829    /// mirror): it sits on a documentation line *and* every group open around it
830    /// was opened by the code layer.
831    ///
832    /// The exemption exists for braces the *code* layer stranded — a
833    /// `\iffalse{\fi` editor-balance hack, a `` \char`{ `` constant, a
834    /// catcode-swapped region — which keep a group open for the rest of the
835    /// file and would otherwise unnest the whole doc layer behind them. A
836    /// group the documentation layer opened itself is not stranded: it is right
837    /// there on a doc line, so a `\begin`/`\end` inside it really is inside it
838    /// and the gates apply as they do in code (theorem.dtx's
839    /// `% \def\deflist#1{\begin{list}…}` / `% \def\enddeflist{\end{list}}`
840    /// split definition, issue #71).
841    fn doc_margin_exempt(&self, idx: usize) -> bool {
842        self.on_doc_margin_line(idx)
843            && !self
844                .group_opens
845                .last()
846                .is_some_and(|&brace| self.on_doc_margin_line(brace))
847    }
848
849    /// Whether the `\end` at `idx` is the orphaned partner of a `\begin` the
850    /// brace-group gate demoted: its name was gated somewhere earlier
851    /// ([`Self::demoted_envs`]) and no environment of that name is open here.
852    ///
853    /// The gate turns a `\begin` into a plain command, and a lone `\end` then
854    /// unwinds every enclosing environment on its way to the root — one gated
855    /// `\begin` inside a `\lowercase{…}` group un-closes the whole `document`
856    /// (amsldoc.tex, issue #71). Demoting the `\end` too keeps the gate's two
857    /// halves consistent. A genuine typo (`\end{itemiz}`) is untouched: nothing
858    /// demoted that name, so it stays a stray `\end`.
859    fn end_orphans_a_demoted_begin(&self, idx: usize) -> bool {
860        if self.demoted_envs.is_empty() {
861            return false;
862        }
863        peek_end_name(self.tokens, idx).is_some_and(|name| {
864            self.demoted_envs.contains(name.as_ref())
865                && !self.open_envs.iter().any(|open| open == name.as_ref())
866        })
867    }
868
869    /// True when token `idx` sits in *macro code*: inside a definition body
870    /// (issues #45/#55) or inside an expl3 region (issue #60; `.dtx` doc-margin
871    /// lines exempt, see [`Self::expl_toggles`]). There `\begin`/`\end` are
872    /// plain commands that need not pair, and an orphan `\]`/`\)` is data
873    /// (`AGENTS.md` decision #1).
874    fn in_macro_code(&self, idx: usize) -> bool {
875        self.in_def_body || (self.in_expl_region(idx) && !self.on_doc_margin_line(idx))
876    }
877
878    /// True when the cursor sits lexically inside a math body — including inside
879    /// a text-mode block (unknown environment, `\text{…}`-style group) nested in
880    /// one. See the [`Self::math_dollar`] field.
881    fn in_math(&self) -> bool {
882        !self.math_dollar.is_empty()
883    }
884
885    /// True when at least one brace group is open around the cursor. See the
886    /// [`Self::group_opens`] field.
887    fn in_group(&self) -> bool {
888        !self.group_opens.is_empty()
889    }
890
891    // --- cursor primitives -------------------------------------------------
892
893    /// Tick the stuck-loop guard, called from every lookahead primitive. Resets
894    /// the budget whenever the cursor has advanced since the last tick (real
895    /// progress — via `bump` or the math-word slicing path, both of which move
896    /// `pos`), so the surviving count is the number of *consecutive* peeks with no
897    /// token consumed. Exceeding [`PARSER_STEP_LIMIT`] means the parser is wedged
898    /// in a non-advancing loop; abort loudly rather than hang. This can only fire
899    /// on a grammar bug or pathological input, never on a real document, and the
900    /// async callers (the language server's worker + read pool) already recover
901    /// from a parse panic, degrading a wedged parse to a logged error.
902    #[inline]
903    fn step(&self) {
904        if self.pos != self.last_step_pos.get() {
905            self.last_step_pos.set(self.pos);
906            self.steps.set(0);
907        }
908        let steps = self.steps.get();
909        assert!(
910            steps < PARSER_STEP_LIMIT,
911            "parser exceeded {PARSER_STEP_LIMIT} peeks without consuming a token at position {} \
912             — non-advancing loop",
913            self.pos
914        );
915        self.steps.set(steps + 1);
916    }
917
918    fn kind(&self) -> Option<SyntaxKind> {
919        self.step();
920        self.tokens.get(self.pos).map(|t| t.kind)
921    }
922
923    fn nth_kind(&self, n: usize) -> Option<SyntaxKind> {
924        self.step();
925        self.tokens.get(self.pos + n).map(|t| t.kind)
926    }
927
928    fn text(&self) -> &str {
929        self.tokens
930            .get(self.pos)
931            .map(|t| t.text.as_str())
932            .unwrap_or("")
933    }
934
935    fn at_end(&self) -> bool {
936        self.pos >= self.tokens.len()
937    }
938
939    fn at_command(&self, name: &str) -> bool {
940        self.kind() == Some(SyntaxKind::CONTROL_WORD) && self.text() == name
941    }
942
943    /// True if the `\begin`/`\end` at token index `pos` reads as a LaTeX
944    /// environment delimiter: a `{` follows across trivia, without crossing a
945    /// blank line, and the name inside is name-shaped. Macro code uses the
946    /// bare TeX primitive and delimiter patterns (`\let\end\@@end`,
947    /// `\long\def\@gobble@nv#1\end#2{…}`, `\expandafter\end`, xparse's
948    /// `\begin \end {#3}` argument data — issue #60) at least as often as
949    /// prose omits the brace by mistake, so a brace-less `\begin`/`\end` is a
950    /// plain command everywhere: no environment, no diagnostic, and no
951    /// recovery anchor. Likewise a name group holding a parameter or control
952    /// word (`\end{#2}`, `\edef…{\noexpand\end{\reserved@a}}`) is computed
953    /// macro data — statically unpairable — so it too stays a plain command
954    /// (the group attaches as an ordinary argument).
955    fn env_name_follows(&self, pos: usize) -> bool {
956        let s = self.scan_trivia(pos + 1, CommentMode::Skip);
957        if s.saw_blank_line || s.next_kind != Some(SyntaxKind::L_BRACE) {
958            return false;
959        }
960        // Scan the name up to the closing `}` on the same line: a parameter
961        // (`#`), a control word/symbol, or a nested `{` before it is macro
962        // data, not a name. An *unterminated* name (line end or EOF first) is
963        // an in-progress edit — stay optimistic so `\begin{ali` still parses
964        // as a `BEGIN` + `NAME_GROUP` and environment-name completion sees it.
965        for t in &self.tokens[s.next + 1..] {
966            match t.kind {
967                SyntaxKind::R_BRACE | SyntaxKind::NEWLINE => return true,
968                SyntaxKind::HASH
969                | SyntaxKind::CONTROL_WORD
970                | SyntaxKind::CONTROL_SYMBOL
971                | SyntaxKind::L_BRACE => return false,
972                _ => {}
973            }
974        }
975        true
976    }
977
978    /// The cursor is on a `\begin` that reads as an environment delimiter
979    /// ([`Self::env_name_follows`]).
980    fn at_env_begin(&self) -> bool {
981        self.at_command(BEGIN_CMD) && self.env_name_follows(self.pos)
982    }
983
984    /// The `\end` twin of [`Self::at_env_begin`].
985    fn at_env_end(&self) -> bool {
986        self.at_command(END_CMD) && self.env_name_follows(self.pos)
987    }
988
989    /// [`Self::at_env_begin`] at an explicit index.
990    ///
991    /// Deliberately *not* routed through [`Self::at_command`]: that ticks the
992    /// stuck-loop budget ([`Self::step`]), which is a peek counter for the walk,
993    /// and this form is called once per token from inside the gate scans — where
994    /// a visit is progress, not a non-advancing peek. Indexes directly, as every
995    /// call site did before.
996    fn env_begin_at(&self, idx: usize) -> bool {
997        self.tokens[idx].text == BEGIN_CMD && self.env_name_follows(idx)
998    }
999
1000    /// The `\end` twin of [`Self::env_begin_at`], with the same no-tick rule.
1001    fn env_end_at(&self, idx: usize) -> bool {
1002        self.tokens[idx].text == END_CMD && self.env_name_follows(idx)
1003    }
1004
1005    // --- event emission ----------------------------------------------------
1006
1007    fn bump(&mut self) {
1008        debug_assert!(!self.at_end(), "bump past end of input");
1009        self.events.push(Event::Tok(self.pos));
1010        self.pos += 1;
1011    }
1012
1013    fn open(&mut self, kind: SyntaxKind) {
1014        self.events.push(Event::Start(kind));
1015    }
1016
1017    fn close(&mut self) {
1018        self.events.push(Event::Finish);
1019    }
1020
1021    /// Open a node *retroactively*, wrapping everything emitted since
1022    /// `checkpoint` — the event-stream analog of rust-analyzer's
1023    /// `Marker::precede`, done locally without a marker type. The caller still
1024    /// owes the matching [`Self::close`]; [`debug_assert_balanced`] catches it
1025    /// if not.
1026    ///
1027    /// Used where a construct can only be classified *after* parsing it: a
1028    /// `PARAGRAPH` (whether the run held a lone block environment), a `SCRIPTED`
1029    /// (whether a `^`/`_` followed the base atom).
1030    fn precede(&mut self, checkpoint: usize, kind: SyntaxKind) {
1031        self.events.insert(checkpoint, Event::Start(kind));
1032    }
1033
1034    /// Move the `Start` already sitting at `at` back to `checkpoint`, so the
1035    /// node it opens also covers everything emitted between them. Both its kind
1036    /// and its `Finish` are the ones already in the stream, so the node's extent
1037    /// grows and nothing else changes.
1038    ///
1039    /// Used to pull a construct's own node back over the `DOC_COMMENT` bound in
1040    /// front of it ([`Self::doc_comment_bind`]): the construct self-opens, and
1041    /// only then is its kind known.
1042    fn extend_back(&mut self, checkpoint: usize, at: usize) {
1043        debug_assert!(checkpoint <= at, "extend_back must move a Start backwards");
1044        if let Event::Start(kind) = self.events[at] {
1045            self.events.remove(at);
1046            self.events.insert(checkpoint, Event::Start(kind));
1047        }
1048    }
1049
1050    fn error(&mut self, message: impl Into<String>) {
1051        let (start, end) = if self.at_end() {
1052            let end = *self.starts.last().expect("starts is non-empty");
1053            (end, end)
1054        } else {
1055            (self.starts[self.pos], self.starts[self.pos + 1])
1056        };
1057        self.errors.push(SyntaxError {
1058            message: message.into(),
1059            start,
1060            end,
1061        });
1062    }
1063
1064    /// Report an error at an explicit byte range. Used for *unclosed*-delimiter
1065    /// errors, which are detected at the closing anchor (a recovery token or EOF)
1066    /// but belong on the *opener* (`{`, `$`, `\[`, `\left`, `\begin{…}`)—the
1067    /// token the reader must fix. Pointing them at the detection site would land
1068    /// every unclosed error on EOF (a zero-width span at end of file).
1069    fn error_at(&mut self, range: (usize, usize), message: impl Into<String>) {
1070        self.errors.push(SyntaxError {
1071            message: message.into(),
1072            start: range.0,
1073            end: range.1,
1074        });
1075    }
1076
1077    /// Byte range of the token at `pos` (`[starts[pos], starts[pos + 1])`).
1078    /// Captured at a construct's opener before it is consumed, so an unclosed
1079    /// error can point back at it (see [`Self::error_at`]).
1080    fn token_span(&self, pos: usize) -> (usize, usize) {
1081        (self.starts[pos], self.starts[pos + 1])
1082    }
1083
1084    // --- grammar -----------------------------------------------------------
1085
1086    fn document(&mut self) {
1087        self.parse_block(Block::Document);
1088    }
1089
1090    /// Whether the construct at token `idx` opens a *block* environment — one
1091    /// [`parse_block`](Self::parse_block) leaves bare rather than wrapping in a
1092    /// `PARAGRAPH`. Block-ness is read from the built-in signature DB
1093    /// ([`ParseCtx::is_block_environment`]), never from a name list here.
1094    ///
1095    /// Covers both spellings, so an alias formats like the environment it stands
1096    /// for: `\bea … \eea` must not be wrapped in a `PARAGRAPH` when the identical
1097    /// `\begin{eqnarray} … \end{eqnarray}` is not. The alias arm re-runs the shape
1098    /// gate, since a demoted opener is a plain command and must keep its paragraph.
1099    fn starts_block_env(&self, idx: usize) -> bool {
1100        if self.tokens.get(idx).is_some_and(|t| t.text == BEGIN_CMD) {
1101            return peek_begin_name(self.tokens, idx)
1102                .as_deref()
1103                .is_some_and(|name| self.ctx.is_block_environment(name));
1104        }
1105        self.alias_openers.get(&idx).is_some_and(|target| {
1106            self.ctx.is_block_environment(target) && self.alias_closer(idx).is_some()
1107        })
1108    }
1109
1110    /// Whether the construct at token `idx` will parse as a genuine
1111    /// `ENVIRONMENT` — a paired `\begin{…}` or a pairing alias opener. In a
1112    /// `statementBody` body this is a **statement boundary**: an environment is
1113    /// a sibling of the statements around it, never statement content, so the
1114    /// run loop abandons its pending `STATEMENT` checkpoint here (the elements
1115    /// before it stay unwrapped) and restarts after the environment.
1116    ///
1117    /// Mirrors [`Self::element`]'s dispatch exactly, gate verdicts included
1118    /// (memoized, so re-asking is cheap): a *demoted* `\begin` is a plain
1119    /// command there and stays statement content here — the same
1120    /// gate-mirrors-the-walk discipline every shape gate carries.
1121    fn statement_boundary(&self, idx: usize) -> bool {
1122        if self.in_macro_code(idx) {
1123            return false;
1124        }
1125        if self.env_begin_at(idx) {
1126            return !self.environment_escapes_group(idx);
1127        }
1128        self.alias_openers.contains_key(&idx) && self.alias_closer(idx).is_some()
1129    }
1130
1131    /// Consume a leading comment-bind located by [`Self::binding_run`]: float
1132    /// the trivia before `comment_start`, group the bound `%` run into a
1133    /// `DOC_COMMENT` node, parse the construct at `construct_pos`, and extend
1134    /// the construct's own node back over the comments
1135    /// ([`Self::extend_back`] — the construct self-opens, so its kind is only
1136    /// known afterwards).
1137    ///
1138    /// The bound run becomes a named node rather than bare leaves — the
1139    /// named-trivia enrichment `AGENTS.md` #9 reserved — so downstream
1140    /// (LSP/formatter) sees the doc comment as one unit.
1141    ///
1142    /// Shared by [`Self::parse_block`] and [`Self::conditional`], which differ
1143    /// only in what they check *before* calling (a conditional divider is not
1144    /// documentable) and what they track *after*.
1145    fn doc_comment_bind(&mut self, comment_start: usize, construct_pos: usize) {
1146        while self.pos < comment_start {
1147            self.bump();
1148        }
1149        let checkpoint = self.events.len();
1150        self.open(SyntaxKind::DOC_COMMENT);
1151        while self.pos < construct_pos {
1152            self.bump();
1153        }
1154        self.close();
1155        let construct_start = self.events.len();
1156        self.element();
1157        self.extend_back(checkpoint, construct_start);
1158    }
1159
1160    /// Parse a content region, grouping runs of content into `PARAGRAPH` nodes
1161    /// delimited by blank lines (the TeX `\par` boundary). Blank-line trivia
1162    /// (and any trailing trivia) sits between paragraphs as direct children of
1163    /// the enclosing node, not inside a paragraph.
1164    fn parse_block(&mut self, block: Block) {
1165        loop {
1166            if self.at_block_end(block) {
1167                break;
1168            }
1169            // Separator trivia (blank lines / trailing whitespace) is emitted
1170            // directly, never wrapped in a paragraph — except a trailing own-line
1171            // comment run that binds into the construct after it: stop before that
1172            // comment so the construct (next iteration) absorbs it as leading.
1173            if self.kind().is_some_and(Self::is_trivia) && self.trivia_run_is_separator(block) {
1174                let stop = self
1175                    .binding_run(self.pos)
1176                    .map_or(self.tokens.len(), |(comment_start, ..)| comment_start);
1177                while self.pos < stop && self.kind().is_some_and(Self::is_trivia) {
1178                    self.bump();
1179                }
1180                continue;
1181            }
1182            // Otherwise we're at paragraph content (guaranteed ≥1 token, so no
1183            // empty paragraph and no infinite loop). Parse the run first, then
1184            // splice in the `PARAGRAPH` wrapper afterwards (the `precede` idiom,
1185            // cf. `math_scripted`) — unless the run's only non-trivia element is a
1186            // lone block environment, which we leave bare. Block-ness is read from
1187            // the signature data (`ParseCtx::is_block_environment`).
1188            let checkpoint = self.events.len();
1189            let mut nontrivia_count = 0usize;
1190            let mut lone_block_env = false;
1191            // In a curated `statementBody` body, the pending `STATEMENT`'s
1192            // checkpoint. Set lazily at the run's (or the previous statement's)
1193            // first non-trivia element so inter-statement trivia floats outside
1194            // the node; dropped at run end, so a run that never reaches a `;`
1195            // stays plain paragraph content (recognition degrades silently,
1196            // like every gated construct).
1197            let mut stmt_checkpoint: Option<usize> = None;
1198            loop {
1199                if self.at_block_end(block) {
1200                    break;
1201                }
1202                if self.kind().is_some_and(Self::is_trivia) && self.trivia_run_is_separator(block) {
1203                    break;
1204                }
1205                // Leading comment-bind: an own-line `%` run immediately before a
1206                // documentable construct attaches *leading* into it (see
1207                // `doc_comment_bind`). Block-ness is peeked from the construct's
1208                // index, so it reads the same before or after the bind.
1209                if let Some((comment_start, construct_pos, _)) = self.binding_run(self.pos) {
1210                    let starts_block_env = self.starts_block_env(construct_pos);
1211                    if self.in_statement_body {
1212                        if self.statement_boundary(construct_pos) {
1213                            stmt_checkpoint = None;
1214                        } else {
1215                            // Float the trivia before the bound `%` run first
1216                            // (`doc_comment_bind` would otherwise consume it
1217                            // after the checkpoint), so a lazily opened
1218                            // statement starts at its `DOC_COMMENT`.
1219                            while self.pos < comment_start {
1220                                self.bump();
1221                            }
1222                            stmt_checkpoint.get_or_insert(self.events.len());
1223                        }
1224                    }
1225                    self.doc_comment_bind(comment_start, construct_pos);
1226                    nontrivia_count += 1;
1227                    lone_block_env = nontrivia_count == 1 && starts_block_env;
1228                    continue;
1229                }
1230                let is_nontrivia = !self.kind().is_some_and(Self::is_trivia);
1231                // Peek block-env status *before* consuming (the name is only
1232                // available while still on the `\begin`).
1233                let starts_block_env = self.starts_block_env(self.pos);
1234                // Statement bookkeeping, peeked before consuming for the same
1235                // reason: a genuine environment is a *sibling* of the statements
1236                // around it (the pending run is abandoned, unwrapped), and the
1237                // terminator test needs the `WORD` while the cursor is on it.
1238                let mut terminator = false;
1239                if self.in_statement_body && is_nontrivia {
1240                    if self.statement_boundary(self.pos) {
1241                        stmt_checkpoint = None;
1242                    } else {
1243                        stmt_checkpoint.get_or_insert(self.events.len());
1244                        terminator =
1245                            self.kind() == Some(SyntaxKind::WORD) && self.text().contains(';');
1246                    }
1247                }
1248                self.element();
1249                if terminator && let Some(cp) = stmt_checkpoint.take() {
1250                    self.precede(cp, SyntaxKind::STATEMENT);
1251                    self.close(); // matching Finish for STATEMENT
1252                }
1253                if is_nontrivia {
1254                    nontrivia_count += 1;
1255                    lone_block_env = nontrivia_count == 1 && starts_block_env;
1256                }
1257            }
1258            if !lone_block_env {
1259                self.precede(checkpoint, SyntaxKind::PARAGRAPH);
1260                self.close(); // matching Finish for PARAGRAPH
1261            }
1262        }
1263    }
1264
1265    fn at_block_end(&self, block: Block) -> bool {
1266        self.at_end()
1267            || match block {
1268                Block::Document => false,
1269                Block::Environment => {
1270                    // An alias environment has no `\end{…}`: its body ends at the
1271                    // closer the gate located. Checked first because
1272                    // `math_environment_body` hardcodes `Block::Environment`, so
1273                    // this one bound terminates both the math and the prose body.
1274                    self.alias_end.is_some_and(|end| self.pos >= end)
1275                        || (self.at_env_end() && !self.end_orphans_a_demoted_begin(self.pos))
1276                        || self.at_alias_end_for_open_env()
1277                }
1278                // `>=` (not `==`): defensive against an element overshooting the
1279                // pre-scanned terminator, so the loop still stops.
1280                Block::Macrocode => self.macrocode_end.is_some_and(|end| self.pos >= end),
1281            }
1282    }
1283
1284    /// Whether the cursor is on a *closer alias* for the environment innermost
1285    /// open here — the mirror of the literal closer [`Self::closer_target`]
1286    /// admits (issue #117).
1287    ///
1288    /// `\def\eeq{\end{equation}}` expands to `\end{equation}`, so it closes a
1289    /// spelled-out `\begin{equation}` just as it closes an alias-opened one. The
1290    /// alias-opened direction already stops at [`Self::alias_end`], the index its
1291    /// gate positively located; this arm is what a `\begin{…}` needs, which pairs
1292    /// by default and locates nothing.
1293    ///
1294    /// Deliberately *not* generalized past the innermost environment: an alias
1295    /// closer naming some outer environment is a plain command here, exactly as
1296    /// a mismatched `\end{…}` is left for the caller to unwind.
1297    fn alias_end_for_open_env(&self, idx: usize) -> bool {
1298        self.alias_closers.get(&idx).is_some_and(|target| {
1299            !self.in_macro_code(idx) && self.open_envs.last().is_some_and(|open| open == target)
1300        })
1301    }
1302
1303    /// [`Self::alias_end_for_open_env`] at the cursor.
1304    fn at_alias_end_for_open_env(&self) -> bool {
1305        self.alias_end_for_open_env(self.pos)
1306    }
1307
1308    /// True if the contiguous trivia run at the current position should separate
1309    /// paragraphs: it contains a blank line, or only trivia remains before the
1310    /// block terminator (the `\end`, or EOF).
1311    fn trivia_run_is_separator(&self, block: Block) -> bool {
1312        let s = self.scan_trivia(self.pos, CommentMode::Skip);
1313        if s.saw_blank_line {
1314            return true;
1315        }
1316        // A macrocode body ends positionally at the frame terminator; trivia
1317        // reaching it (the frame line's own margin and indent) is a separator.
1318        if block == Block::Macrocode {
1319            return s.next_kind.is_none() || self.macrocode_end.is_some_and(|end| s.next >= end);
1320        }
1321        match s.next_kind {
1322            // Only trivia remains before the block terminator (`\end`, or EOF).
1323            None => true,
1324            Some(SyntaxKind::CONTROL_WORD) => {
1325                block == Block::Environment
1326                    && (self.env_end_at(s.next)
1327                            // The alias twin: the run reaches the located closer.
1328                            || self.alias_end.is_some_and(|end| s.next >= end)
1329                            // …or a closer alias for the environment open here,
1330                            // which is what an unlocated `\begin{…}` stops at.
1331                            || self.alias_end_for_open_env(s.next))
1332            }
1333            Some(_) => false,
1334        }
1335    }
1336
1337    /// One element in text mode. Always consumes at least one token.
1338    fn element(&mut self) {
1339        let Some(k) = self.kind() else { return };
1340        match k {
1341            k if Self::is_trivia(k) => self.bump(),
1342            SyntaxKind::CONTROL_WORD => {
1343                // Inside a definition body or an expl3 region, `\begin`/`\end`
1344                // are plain commands: the two need not balance within one group
1345                // (issues #45/#60), so neither opens an environment nor is
1346                // stray. A brace-less `\begin`/`\end` is likewise a plain
1347                // command (`env_name_follows`).
1348                if !self.in_macro_code(self.pos) && self.at_env_begin() {
1349                    // Shape-gated like `\[`: an environment cannot outlive the
1350                    // brace group it opened in, so one whose `\end` is not
1351                    // reachable before that group closes is macro code — a
1352                    // plain command, no diagnostic (issue #71).
1353                    if self.environment_escapes_group(self.pos) {
1354                        if let Some(name) = peek_end_name(self.tokens, self.pos) {
1355                            self.demoted_envs.insert(name.into_owned());
1356                        }
1357                        self.command();
1358                    } else {
1359                        self.environment();
1360                    }
1361                } else if !self.in_macro_code(self.pos) && self.at_env_end() {
1362                    // The mirror case: reached inside a group, this `\end`'s
1363                    // `\begin` is outside it, so it is macro code rather than
1364                    // stray (`\StopEventually{\end{document}}`, issue #71).
1365                    if (self.in_group() && !self.doc_margin_exempt(self.pos))
1366                        || self.end_orphans_a_demoted_begin(self.pos)
1367                    {
1368                        self.command();
1369                    } else {
1370                        self.stray_end();
1371                    }
1372                } else if let Some((target, closer)) = (!self.in_macro_code(self.pos))
1373                    .then(|| {
1374                        let target = self.alias_openers.get(&self.pos)?.clone();
1375                        Some((target, self.alias_closer(self.pos)?))
1376                    })
1377                    .flatten()
1378                {
1379                    // A command whose definition body is exactly `\begin{X}`, whose
1380                    // partner is reachable: pair the two into an `ENVIRONMENT` of
1381                    // `X` (issue #109). Shape-gated like `\begin` and `\if`, and
1382                    // like them it demotes silently when the gate refuses.
1383                    self.alias_environment(&target, closer);
1384                } else if let Some(closer) = self
1385                    .conditional_openers
1386                    .contains(&self.pos)
1387                    .then(|| self.conditional_closer(self.pos))
1388                    .flatten()
1389                {
1390                    // Shape-gated like `\[` and `\begin`: an `\if` whose own
1391                    // `\fi` is not reachable is macro code — a plain command,
1392                    // no diagnostic ([`Self::conditional_closer`]).
1393                    self.conditional(closer);
1394                } else {
1395                    self.command();
1396                }
1397            }
1398            SyntaxKind::CONTROL_SYMBOL => {
1399                let sym = self.text().to_owned();
1400                match sym.as_str() {
1401                    // Shape-gated like `$` ([`Self::delim_math_closes`]): an
1402                    // opener with no reachable closer is macro-code data
1403                    // (`\expandafter\@tempa\[\@nil`, issue #65) — an ordinary
1404                    // token, no math, no diagnostic.
1405                    "\\[" => {
1406                        if self.delim_math_closes(self.pos, "\\]") {
1407                            self.delim_math(SyntaxKind::DISPLAY_MATH, "\\[", "\\]");
1408                        } else {
1409                            self.bump();
1410                        }
1411                    }
1412                    "\\(" => {
1413                        if self.delim_math_closes(self.pos, "\\)") {
1414                            self.delim_math(SyntaxKind::INLINE_MATH, "\\(", "\\)");
1415                        } else {
1416                            self.bump();
1417                        }
1418                    }
1419                    "\\]" | "\\)" => {
1420                        // In macro code (a definition body, macrocode chunk,
1421                        // or expl3 region) an orphan closer is data, not a
1422                        // stray delimiter (`\char_set_catcode_letter:N \)`,
1423                        // issue #60) — an ordinary token, no diagnostic. In
1424                        // prose it still diagnoses, catching a `\[…\]` typo'd
1425                        // across a paragraph break on its closer.
1426                        if !self.in_macro_code(self.pos) {
1427                            self.error(format!("unmatched `{sym}`"));
1428                        }
1429                        self.bump();
1430                    }
1431                    // `\\` line break, with its tightly-bound `*` / `[len]`.
1432                    "\\\\" => self.line_break(),
1433                    // Any other bare control symbol (`\,`, `\%`, `\;`, …). Surface
1434                    // model: emit as a token; these take no arguments.
1435                    _ => self.bump(),
1436                }
1437            }
1438            // A brace unmatched within a `macrocode` chunk is an ordinary macro-
1439            // code token (the definition it belongs to spans chunks): no `GROUP`,
1440            // no diagnostic.
1441            SyntaxKind::L_BRACE => {
1442                if self.plain_braces.contains(&self.pos) {
1443                    self.bump();
1444                } else {
1445                    self.group();
1446                }
1447            }
1448            SyntaxKind::R_BRACE => {
1449                if !self.plain_braces.contains(&self.pos) {
1450                    self.error("unmatched `}`");
1451                }
1452                self.bump();
1453            }
1454            SyntaxKind::DOLLAR => {
1455                if self.def_parameter_dollars.contains(&self.pos) {
1456                    self.bump();
1457                    return;
1458                }
1459                let display = self.nth_kind(1) == Some(SyntaxKind::DOLLAR);
1460                if self.dollar_closes(self.pos, display) {
1461                    self.dollar_math();
1462                } else {
1463                    // No reachable closer: this dollar is macro-code data
1464                    // (`>{$}`, `{ $ }`), not a math delimiter — an ordinary
1465                    // token, no math, no diagnostic. Each `$` of an ungated
1466                    // `$$` re-enters here and is gated independently.
1467                    self.bump();
1468                }
1469            }
1470            // WORD, brackets, & # ^ _ ~, ERROR: ordinary tokens in text mode.
1471            _ => self.bump(),
1472        }
1473    }
1474
1475    /// `\foo` followed by its greedily-attached argument groups.
1476    ///
1477    /// Arity is unknown without the semantic layer, so we attach every trailing
1478    /// `{…}` / `[…]` group (see `AGENTS.md`, Core decision #8, and
1479    /// [`Self::attach_arguments`] for the `[…]` shape gates). The one curated
1480    /// exception: a delimiter-size command (`\Big`, `\bigl`, …) never takes a
1481    /// `[…]` argument — its `[` is the delimiter it sizes (`\Big[ x \Big]`),
1482    /// mirroring the `\left`/`\right` special case.
1483    fn command(&mut self) {
1484        let builtin_args = builtin_command_args(self.text());
1485        let bracket = if is_big_delimiter_command(self.text()) {
1486            BracketPolicy::Forbid
1487        } else {
1488            BracketPolicy::Greedy
1489        };
1490        // A definition-body command's attached groups are macro-code bodies
1491        // (issues #45/#55): flag them so `\begin`/`\end` inside parse as
1492        // plain commands. OR-ed with the saved flag so a definition nested in
1493        // another definition's body stays flagged; restored after the
1494        // arguments so following siblings are unaffected.
1495        let saved = self.in_def_body;
1496        self.in_def_body = saved || is_definition_body_command(self.text());
1497        let consumes_control_symbol_name =
1498            is_def_prefix_command(self.text()) || is_command_definition_command(self.text());
1499        // Arity-directed expl3 attachment (decision #8's sanctioned
1500        // deviation): resolve the head's argspec and scan the whole unit
1501        // *before* any event is emitted; the replay below consumes exactly
1502        // the plan, so the scan mirrors the walk by construction. A
1503        // colon-carrying head is never a def-prefix or definition-body name
1504        // (both sets are colonless), so the branches cannot overlap.
1505        let expl3_plan = self
1506            .expl3_arity_slots()
1507            .and_then(|slots| self.scan_expl3_unit(&slots));
1508        self.open(SyntaxKind::COMMAND);
1509        self.bump(); // the control word
1510        // A command definer may take its name as the next unbraced control
1511        // sequence. Consume a control-symbol name here as a plain token so it
1512        // is never misparsed as live syntax (`\def\[{…}` and
1513        // `\DeclareRobustCommand\[{…}` are not math openers). The attached
1514        // replacement group is then a macro-code body: the stacks-project
1515        // redefinition opens `trivlist` in `\def\[`'s body and closes it in
1516        // `\def\]`'s (issue #65), the same no-balance fact as
1517        // `is_definition_body_command`.
1518        if consumes_control_symbol_name {
1519            let scan = self.scan_trivia(self.pos, CommentMode::Skip);
1520            if scan.next_kind == Some(SyntaxKind::CONTROL_SYMBOL) && !scan.saw_blank_line {
1521                self.skip_trivia();
1522                self.bump(); // the defined name
1523                self.in_def_body = true;
1524            }
1525        }
1526        match &expl3_plan {
1527            Some(plan) => self.attach_expl3_arguments(plan),
1528            None => self.attach_arguments(bracket, builtin_args),
1529        }
1530        self.in_def_body = saved;
1531        self.close();
1532    }
1533
1534    /// The `\\` line break and its tightly-bound modifiers: an optional `*`
1535    /// (no-page-break variant) and an optional `[length]` (`\\`, `\\*`,
1536    /// `\\[2ex]`, `\\*[2ex]`). These bind to the `\\` only when they *directly*
1537    /// abut it — no intervening trivia is crossed — so a lone `\\` at end of line
1538    /// stays bare and the modifiers are never pulled across a break. Grouping
1539    /// them into one `LINE_BREAK` node (rather than leaving loose tokens) is what
1540    /// lets the formatter treat `\\[2ex]` as one unit instead of stranding the
1541    /// `[2ex]` on the next line.
1542    ///
1543    /// Unlike `command`, this attaches *no* `{…}` arguments (`\\` takes none) and
1544    /// does not skip trivia. The `*` is recognized only as its own `WORD` token
1545    /// (the lexer glues `*` into following letters, so `\\*foo` keeps the star on
1546    /// the word — a vanishingly rare form we deliberately leave alone).
1547    fn line_break(&mut self) {
1548        self.open(SyntaxKind::LINE_BREAK);
1549        self.bump(); // \\
1550        if self.kind() == Some(SyntaxKind::WORD) && self.text() == "*" {
1551            self.bump(); // *
1552        }
1553        if self.kind() == Some(SyntaxKind::L_BRACKET) {
1554            self.optional(); // [length]
1555        }
1556        self.close();
1557    }
1558
1559    /// Greedily attach trailing `{…}` / `[…]` argument groups to the currently
1560    /// open node, allowing intervening trivia but stopping at a paragraph break.
1561    /// Shared by `\foo` commands and `\begin{env}` (see `AGENTS.md`, Core
1562    /// decision #8). Arity is unknown without the semantic layer.
1563    ///
1564    /// `[…]` attachment is additionally shape-gated (issue #43) — `[`/`]` are
1565    /// not real grouping in TeX, so a bracket is an argument only when it reads
1566    /// as one:
1567    /// - **Lexically inside math, only when it directly abuts.** Real math
1568    ///   optionals are written tight (`\sqrt[3]{x}`, `\\[2ex]`); a spaced `[`
1569    ///   is a delimiter or interval (`\bE [ x ]`). This uses [`Self::in_math`],
1570    ///   so it also covers text-mode bodies of unknown environments nested in
1571    ///   math (`\[ … \begin{myaligned} \Big [ … \]`).
1572    /// - **Inside math, only when [`Self::bracket_closes_before_math_end`]
1573    ///   finds its `]`**; otherwise it is left for the math loop as an ordinary
1574    ///   atom, so open-interval notation (`$]0;\num{0.5}[$`) does not swallow
1575    ///   the math closer as an optional-argument body.
1576    /// - **In text mode, only when [`Self::bracket_closes_in_text`] finds its
1577    ///   `]`** (issue #60): macro code tests for and re-emits lone brackets
1578    ///   (`\@ifnextchar [\@xmpar\@ympar`), so a `[` whose closer is not
1579    ///   reachable stays an ordinary token — no `OPTIONAL`, no diagnostic —
1580    ///   mirroring the `$` shape gate ([`Self::dollar_closes`]).
1581    /// - **Per the caller's [`BracketPolicy`]:** `Tight` (a curated math
1582    ///   environment's `\begin` — its math body starts right after, so a
1583    ///   detached `[` is content: `\begin{align}` + newline + `[a]_1`) demands
1584    ///   a directly-abutting `[` even outside math; `Forbid` (the
1585    ///   delimiter-size commands, [`Self::command`]) never attaches one.
1586    ///   `Greedy` — everything else — keeps decision #8's trivia-crossing
1587    ///   attachment, which the semantic layer legitimizes downstream (the
1588    ///   xparse-signature glue relies on a next-line `[Warning]` still
1589    ///   attaching to `\begin{note}`).
1590    fn attach_arguments(&mut self, bracket: BracketPolicy, args: Option<&[ArgSpec]>) {
1591        let mut slot = 0usize;
1592        loop {
1593            let (next, paragraph_break) = self.peek_meaningful();
1594            if paragraph_break {
1595                break;
1596            }
1597            match next {
1598                Some(SyntaxKind::L_BRACE) => {
1599                    // A chunk-unmatched macrocode brace is a plain token, never
1600                    // an argument group (`\gdef\foo{%` … next chunk).
1601                    let scan = self.scan_trivia(self.pos, CommentMode::Skip);
1602                    if self.plain_braces.contains(&scan.next) {
1603                        break;
1604                    }
1605                    self.skip_trivia();
1606                    let domain = args
1607                        .and_then(|args| match_arg_slot(args, &mut slot, ArgKind::Brace))
1608                        .map_or(ArgumentDomain::Unknown, |spec| spec.domain);
1609                    self.argument_group(domain);
1610                }
1611                Some(SyntaxKind::L_BRACKET) => {
1612                    if bracket == BracketPolicy::Forbid {
1613                        break;
1614                    }
1615                    let scan = self.scan_trivia(self.pos, CommentMode::Skip);
1616                    let tight_only = self.in_math() || bracket == BracketPolicy::Tight;
1617                    if tight_only && scan.next != self.pos {
1618                        break;
1619                    }
1620                    if self.in_math() && !self.bracket_closes_before_math_end(scan.next) {
1621                        break;
1622                    }
1623                    // In a macrocode body, a `[` is an argument only when its `]`
1624                    // closes inside the chunk: macro code uses bare brackets
1625                    // freely, and an optional must never consume the frame.
1626                    if self.macrocode_end.is_some()
1627                        && !self.bracket_closes_before_macrocode_end(scan.next)
1628                    {
1629                        break;
1630                    }
1631                    // In text mode, a `[` is an argument only when its `]` is
1632                    // reachable ([`Self::bracket_closes_in_text`]): macro code
1633                    // tests for and re-emits lone brackets at least as often as
1634                    // prose writes real optionals (`\@ifnextchar [\@xmpar\@ympar`,
1635                    // issue #60), so an unreachable closer means the bracket is
1636                    // data, not an argument.
1637                    if !self.in_math()
1638                        && self.macrocode_end.is_none()
1639                        && !self.bracket_closes_in_text(scan.next)
1640                    {
1641                        break;
1642                    }
1643                    self.skip_trivia();
1644                    let domain = args
1645                        .and_then(|args| match_arg_slot(args, &mut slot, ArgKind::Bracket))
1646                        .map_or(ArgumentDomain::Unknown, |spec| spec.domain);
1647                    self.argument_optional(domain);
1648                }
1649                // A verbatim-argument command's body (`\url{…}`, `\lstinline|…|`,
1650                // the final arg of `\mintinline{lang}{code}`) is lexed as a single
1651                // `VERB` token immediately following the command, so attach it as a
1652                // child like any other argument (decision #8) instead of leaving it
1653                // a sibling. A *standalone* `\verb…`/`\verb*…` token (its text starts
1654                // with `\`) is self-contained and belongs to no command — never
1655                // capture it. `lex_verbatim_command` emits its non-`\` `VERB`
1656                // *directly* after its own command tokens, so only a directly
1657                // abutting `VERB` attaches: a spaced one is a doc short-verb span
1658                // (`\emph{x} |y|`), a freestanding sibling that must keep its
1659                // interword space.
1660                Some(SyntaxKind::VERB)
1661                    if self.scan_trivia(self.pos, CommentMode::Skip).next == self.pos
1662                        && !self
1663                            .peek_meaningful_text()
1664                            .is_some_and(|t| t.starts_with('\\')) =>
1665                {
1666                    if let Some(args) = args
1667                        && self
1668                            .peek_meaningful_text()
1669                            .is_some_and(|text| text.starts_with('{'))
1670                    {
1671                        match_verbatim_arg_slot(args, &mut slot);
1672                    }
1673                    self.bump(); // the VERB argument
1674                }
1675                // A starred-variant marker `*` folds into the invocation so the
1676                // arguments that follow it still attach (`\section*{…}`,
1677                // `\inferrule*[…]`, `\\*[2pt]`).
1678                Some(SyntaxKind::WORD) if self.at_star_variant_marker() => {
1679                    self.bump(); // the `*`
1680                }
1681                _ => break,
1682            }
1683        }
1684    }
1685
1686    /// Whether the next token is a *starred-variant marker* to fold into the
1687    /// command invocation: a lone `*` tight to the command, itself followed by
1688    /// an argument opener (`[`/`{`). LaTeX's `\@ifstar` commands carry the star
1689    /// before their arguments (`\section*{…}`, mathpartir's `\inferrule*[…]`,
1690    /// the `\\*[2pt]` line break), so folding it lets those arguments attach
1691    /// (decision #8) instead of the `*` breaking the run. Gating on a *following
1692    /// argument* keeps a math operator (`\pi*r`, `\Gamma * x`) — a `*` with no
1693    /// argument after it — from being mistaken for a marker. The `*` must be a
1694    /// lone token tight to the command: a spaced `\foo *` is not a marker, and
1695    /// `\foo*bar` lexes the star into a single `*bar` word (text ≠ `*`), so
1696    /// neither folds. Does not consume.
1697    fn at_star_variant_marker(&self) -> bool {
1698        if self.scan_trivia(self.pos, CommentMode::Skip).next != self.pos {
1699            return false; // the star must be tight to the command
1700        }
1701        if self.tokens.get(self.pos).map(|t| (t.kind, t.text.as_str()))
1702            != Some((SyntaxKind::WORD, "*"))
1703        {
1704            return false;
1705        }
1706        matches!(
1707            self.scan_trivia(self.pos + 1, CommentMode::Skip).next_kind,
1708            Some(SyntaxKind::L_BRACKET | SyntaxKind::L_BRACE)
1709        )
1710    }
1711
1712    /// A brace group `{ … }`.
1713    fn group(&mut self) {
1714        self.argument_group(ArgumentDomain::Unknown);
1715    }
1716
1717    fn argument_group(&mut self, domain: ArgumentDomain) {
1718        debug_assert_eq!(self.kind(), Some(SyntaxKind::L_BRACE));
1719        let opener = self.token_span(self.pos);
1720        self.open(SyntaxKind::GROUP);
1721        self.bump(); // {
1722        self.group_opens.push(self.pos - 1);
1723        loop {
1724            match self.kind() {
1725                None => {
1726                    self.error_at(opener, "unclosed `{`");
1727                    break;
1728                }
1729                Some(SyntaxKind::R_BRACE) => {
1730                    self.bump();
1731                    break;
1732                }
1733                _ => match domain {
1734                    ArgumentDomain::Math => self.math_element(),
1735                    ArgumentDomain::Text | ArgumentDomain::Unknown => self.element(),
1736                },
1737            }
1738        }
1739        self.group_opens.pop();
1740        self.close();
1741    }
1742
1743    /// An optional-argument group `[ … ]`.
1744    ///
1745    /// `[` and `]` are not real grouping in TeX, so this is heuristic: it ends
1746    /// at the first `]`, and bails defensively (rather than swallowing the
1747    /// document) on a structural `}`, a `\begin`/`\end`, a paragraph break, or
1748    /// EOF. A chunk-unmatched macrocode `}` is an ordinary token.
1749    fn optional(&mut self) {
1750        self.argument_optional(ArgumentDomain::Unknown);
1751    }
1752
1753    fn argument_optional(&mut self, domain: ArgumentDomain) {
1754        debug_assert_eq!(self.kind(), Some(SyntaxKind::L_BRACKET));
1755        let opener = self.token_span(self.pos);
1756        self.open(SyntaxKind::OPTIONAL);
1757        self.bump(); // [
1758        loop {
1759            match self.kind() {
1760                None => {
1761                    self.error_at(opener, "unclosed `[`");
1762                    break;
1763                }
1764                Some(SyntaxKind::R_BRACE) if !self.plain_braces.contains(&self.pos) => {
1765                    self.error_at(opener, "unclosed `[`");
1766                    break;
1767                }
1768                Some(SyntaxKind::R_BRACKET) => {
1769                    self.bump();
1770                    break;
1771                }
1772                // In a definition body or expl3 region `\begin`/`\end` are
1773                // plain commands (issues #45/#60), so they don't signal a
1774                // runaway `[` — nor does a brace-less one (issue #60).
1775                Some(SyntaxKind::CONTROL_WORD)
1776                    if !self.in_macro_code(self.pos)
1777                        && (self.at_env_begin() || self.at_env_end()) =>
1778                {
1779                    self.error_at(opener, "unclosed `[`");
1780                    break;
1781                }
1782                _ => {
1783                    // The macrocode frame terminator is absolute: an optional
1784                    // still open there is abandoned, never consumes the frame.
1785                    if self.at_paragraph_break_outside_guards()
1786                        || self.macrocode_end.is_some_and(|end| self.pos >= end)
1787                    {
1788                        self.error_at(opener, "unclosed `[`");
1789                        break;
1790                    }
1791                    match domain {
1792                        ArgumentDomain::Math => self.math_element(),
1793                        ArgumentDomain::Text | ArgumentDomain::Unknown => self.element(),
1794                    }
1795                }
1796            }
1797        }
1798        self.close();
1799    }
1800
1801    /// One tick per token a shape-gate scan visits, into [`Self::scan_work`].
1802    /// Compiled away outside `cfg(test)`: the linearity regression tests are
1803    /// the only reader, and [`Self::gate_batch`]'s loop is hot enough that an
1804    /// unconditional counter shows up in the parse benchmarks.
1805    #[cfg(test)]
1806    fn tick_scan(&self) {
1807        self.scan_work.set(self.scan_work.get() + 1);
1808    }
1809
1810    #[cfg(not(test))]
1811    fn tick_scan(&self) {}
1812
1813    /// True if the `[` at token index `open` is closed by a `]` before the
1814    /// current macrocode chunk's frame terminator. Depth-tracks only the braces
1815    /// that really form groups (chunk-matched ones — [`Self::plain_braces`] are
1816    /// plain tokens), and gives up at a *blank line* — the same paragraph-break
1817    /// bail as [`Self::optional`], so an optional the formatter has re-wrapped
1818    /// over several lines still attaches on the second pass. Keeps a code
1819    /// bracket (`\@tempcnta[` with no `]` in the chunk) an ordinary token
1820    /// instead of an optional that would swallow the frame.
1821    ///
1822    /// Runs on the shared batch driver as [`MacrocodeBracketGate`] (`TODO.md`,
1823    /// container stack C2.5), which carries the chunk frame as its bound and
1824    /// adds the C0 last-`]` bound the hand-written scan never had. It is the one
1825    /// bracket gate the batch cannot make linear: single-entry by policy, so a
1826    /// chunk of `\cmd[` atoms whose only `]` sits outside it still scans to the
1827    /// frame per opener.
1828    fn bracket_closes_before_macrocode_end(&self, open: usize) -> bool {
1829        // Total in `open` for a caller outside a chunk, where the frame that
1830        // bounds this gate does not exist and every `[` passes.
1831        if self.macrocode_end.is_none() {
1832            return true;
1833        }
1834        self.gate_verdict(open, &MacrocodeBracketGate).is_some()
1835    }
1836
1837    /// True if the `[` at token index `open` is closed by a `]` before a token
1838    /// that would end the enclosing math. Mirrors [`Self::optional`]'s bail
1839    /// anchors (an unbalanced `}`, `\begin`/`\end`, a paragraph break, EOF) and
1840    /// adds the delimited math closers (`\]`, `\)`), which `optional` cannot
1841    /// stop at in text mode (`\item[$x$]` is legit) but which inside math mean
1842    /// the `[` is not an argument at all — e.g. the open-interval notation
1843    /// `$]0;\num{0.5}[$`. A `]` counts only outside `{…}` nesting, matching how
1844    /// `optional` consumes whole groups via `element` — and only past the `]`s
1845    /// owed to intervening *command-abutting* `[`s: such a `[` is itself
1846    /// argument-shaped (or a `\left`/`\Big` delimiter) and will claim the next
1847    /// `]` when parsed, so that `]` cannot also satisfy the outer `[`
1848    /// (`\P[\gamma[0, \infty) \cap A = \emptyset]`, issue #55 — the lone `]`
1849    /// belongs to `\gamma[`, so `\P[` stays an ordinary atom). A `[` abutting
1850    /// anything else (`x[i]`, the interval `[0, \infty)`) parses as an ordinary
1851    /// atom and claims nothing, so it adds no nesting here either.
1852    ///
1853    /// How a `$` at brace depth 0 is read depends on the *innermost enclosing
1854    /// math's flavor* ([`Self::math_dollar`]):
1855    /// - **Enclosing `\[…\]`/`\(…\)` (or a math environment).** A `$` opens a
1856    ///   genuine nested inline region, so a balanced `$…$` pair inside the
1857    ///   bracket is *transparent*: the `$` toggles an inline region rather than
1858    ///   ending the search, and `]`/`[` inside it are math content, ignored
1859    ///   (`\[ \inferrule*[right=$\Pi$-eq]{A}{B} \]` — the `$\Pi$` label sits
1860    ///   inside the optional). An *unbalanced* `$` leaves the region open, no
1861    ///   `]` is ever accepted, and the scan falls through to `false`.
1862    /// - **Enclosing `$…$`/`$$…$$`.** TeX cannot nest a `$` inside dollar math,
1863    ///   so the first depth-0 `$` is this math's *closer*: a `]` beyond it lives
1864    ///   in a later math and cannot be this bracket's, so bail like `\]`/`\)`.
1865    ///   Without this a stray `[` in dollar math (`$\mathcal{N}[\mathcal{S}$`,
1866    ///   a missing `]`, stacks-project issue #99) would scan past the closing
1867    ///   `$` into following math and wrongly attach an optional that swallows
1868    ///   it. Does not consume.
1869    ///
1870    /// Runs on the shared batch driver as [`MathBracketGate`] (`TODO.md`,
1871    /// container stack C2.5), where the transparent `$…$` region, the flavor
1872    /// that decides it, and the gate's two preserved strictnesses (a
1873    /// `\begin`/`\end` anchors inside macro code too, and a chunk-unmatched
1874    /// brace is group structure) are named policies. The enclosing flavor is
1875    /// walk state, so it rides the batch's memo key ([`WalkKey`]).
1876    fn bracket_closes_before_math_end(&self, open: usize) -> bool {
1877        let gate = MathBracketGate {
1878            enclosing_is_dollar: self.enclosing_math_is_dollar(),
1879        };
1880        self.gated_closer(open, &gate, &self.math_bracket_batch)
1881            .is_some()
1882    }
1883
1884    /// True if the `[` at token index `open` is closed by a `]` before a token
1885    /// that would make [`Self::optional`] bail in text mode. `[`/`]` are not
1886    /// real grouping in TeX, and macro code tests for and re-emits lone
1887    /// brackets (`\@ifnextchar [\@xmpar\@ympar`, `\def\@xfloat#1[#2]{…}`
1888    /// re-implementations — issue #60) at least as often as prose writes real
1889    /// optionals, so — like the `$` shape gate ([`Self::dollar_closes`]) — a
1890    /// bracket attaches only when it *reads* as an argument: its closer must be
1891    /// reachable. Mirrors `optional`'s bail anchors (an unbalanced `}`,
1892    /// `\begin`/`\end` outside a definition body, a paragraph break, EOF). A
1893    /// `]` counts only outside `{…}` nesting (matching how `optional` consumes
1894    /// whole groups via `element`) and only past the `]`s owed to intervening
1895    /// *command-abutting* `[`s, exactly as in
1896    /// [`Self::bracket_closes_before_math_end`] (issue #55). A gated bracket
1897    /// stays an ordinary token with **no diagnostic**: in code the shape is
1898    /// routine, so it is not statically an error. Does not consume.
1899    ///
1900    /// Runs on the shared batch driver as [`TextBracketGate`] (`TODO.md`,
1901    /// container stack C2.5). The claim countdown above *is* the driver's
1902    /// nested-opener stack — closer matching is LIFO either way — so one scan
1903    /// now settles every command-abutting `[` in the seed's own brace frame,
1904    /// where a refused bracket used to leave the walk to ask the next one from
1905    /// scratch. The C0 bound (the last `]` in the file) rides
1906    /// [`GatePolicy::last_closer`].
1907    fn bracket_closes_in_text(&self, open: usize) -> bool {
1908        self.gated_closer(open, &TextBracketGate, &self.text_bracket_batch)
1909            .is_some()
1910    }
1911
1912    /// True if the `$` (or `$$`) opener at token index `open` is closed by a
1913    /// matching delimiter before a token that would end the math. `$`/`$$` are
1914    /// data in macro code at least as often as they are math delimiters (a
1915    /// tabular preamble's `>{$}`, an expl3 token list's `{ $ }`, catcode
1916    /// comparisons in `\def` bodies), so — like `[…]` attachment (issue #43) —
1917    /// a dollar opens math only when it *reads* as math: a closer must be
1918    /// reachable. Mirrors [`Self::dollar_math`]'s recovery anchors (an
1919    /// unbalanced `}`, an `\end` not owed to an intervening `\begin`, a
1920    /// paragraph break, EOF, the macrocode chunk end). A closing `$` counts
1921    /// only outside `{…}` nesting — [`Self::math_group`] consumes a nested
1922    /// dollar as an ordinary atom, never as the closer — and for `$$` a lone
1923    /// `$` is skipped exactly as `dollar_math` skips it (malformed but
1924    /// consumed). Likewise a paragraph break blocks only at the math body's
1925    /// own level. Inside a definition body `\begin`/`\end` are plain commands
1926    /// (issue #45), so neither anchors nor nests there. Does not consume.
1927    ///
1928    /// Runs on the shared batch driver as [`DollarGate`] (`TODO.md`, container
1929    /// stack C2.3) — for the uniformity, not for speed: the gate is
1930    /// single-entry, so its "batch" is one verdict, and its residual adversarial
1931    /// shape (a `${` per line: depth ratchets upward, so the level-gated
1932    /// paragraph anchor never fires and no depth-0 `$` ever appears) is one only
1933    /// a precomputed map could reach.
1934    ///
1935    /// A display opener is two tokens and its scan starts past both, so the seed
1936    /// handed to the driver — which scans from `seed + 1` — is the *second* `$`.
1937    fn dollar_closes(&self, open: usize, display: bool) -> bool {
1938        let seed = if display { open + 1 } else { open };
1939        self.gate_verdict(seed, &DollarGate { display }).is_some()
1940    }
1941
1942    /// The delimited-math twin of [`Self::dollar_closes`]: `\[`/`\(` opens
1943    /// math only when its `\]`/`\)` is reachable. Macro code passes the
1944    /// delimiters around as data tokens — stacks-project feeds `\[` to a
1945    /// splitter (`\expandafter\@tempa\[\@nil`, issue #65) — so an opener with
1946    /// no reachable closer is an ordinary token, no math, **no diagnostic**
1947    /// (the shape is routine in code, so it is not statically an error; a
1948    /// likely-typo unclosed `\[` in prose is linter territory, exactly as for
1949    /// `$`). Same blockers as `dollar_closes`, mirroring
1950    /// [`Self::delim_math`]'s recovery anchors: an unbalanced `}`, an `\end`
1951    /// not owed to an intervening `\begin`, a paragraph break, the macrocode
1952    /// chunk end, EOF. The closer counts only outside `{…}` nesting, and a
1953    /// paragraph break blocks only at the math body's own level.
1954    ///
1955    /// Runs on the shared batch driver as [`DelimMathGate`] (`TODO.md`,
1956    /// container stack C2.3), which carries the C0 bound — the last `\]`/`\)` in
1957    /// the file — as [`GatePolicy::last_closer`]. The gate is single-entry: a
1958    /// `\[` whose closer is reachable swallows every opener up to it, so there
1959    /// is never a same-frame neighbor left to settle, and it was measured linear
1960    /// before the migration. It joins the driver for the one copy of the
1961    /// bookkeeping, not for speed.
1962    fn delim_math_closes(&self, open: usize, closer: &'static str) -> bool {
1963        self.gate_verdict(open, &DelimMathGate { closer }).is_some()
1964    }
1965
1966    /// The `\left…\right` twin of [`Self::delim_math_closes`]: whether the
1967    /// `\left` at token index `open` has a matching `\right` reachable before a
1968    /// token that would end its body. `\left`/`\right` pair by *count* (nested
1969    /// pairs recurse in [`Self::left_right`]), so — unlike `$`/`\[` which are
1970    /// often data in code — an unclosed `\left` is genuinely malformed math, but
1971    /// it is still a *likely-typo* the linter should flag, never a parser error
1972    /// that blocks the whole file for the formatter (issue #77's
1973    /// `\left(1 …) …\left(…\right)` and `\left\bra …` with no `\right`). So it
1974    /// gets the same shape gate as `\[`: a `\left` whose `\right` is unreachable
1975    /// stays an ordinary command, **no diagnostic**. Mirrors [`Self::left_right`]'s
1976    /// recovery anchors — an unbalanced `}`, a closing `$`/`\]`/`\)`, an `\end`
1977    /// not owed to an intervening `\begin`, a paragraph break, EOF — with `\right`
1978    /// and the anchors counting only at the `\left`'s own brace/env/pair level.
1979    /// Does not consume.
1980    ///
1981    /// Runs on the shared batch driver as [`LeftRightGate`] (`TODO.md`,
1982    /// container stack C2.4), which is where those anchors and the deliberate
1983    /// `in_macro_code` blind spot now live as policy.
1984    fn left_right_closes(&self, open: usize) -> bool {
1985        self.gated_closer(open, &LeftRightGate, &self.left_right_batch)
1986            .is_some()
1987    }
1988
1989    /// Inline `$ … $` or display `$$ … $$` math. The body's atoms are wrapped in
1990    /// a `MATH` node (the delimiters stay direct children of the math node); the
1991    /// atoms themselves are parsed in math mode (see [`Self::math_element`]).
1992    /// Entry is gated by [`Self::dollar_closes`]: the caller has already
1993    /// verified a closer is reachable, so the unclosed-math recovery paths
1994    /// below fire only for shapes the gate scan cannot see (they remain as
1995    /// belt-and-braces recovery, never the expected path).
1996    fn dollar_math(&mut self) {
1997        let display = self.nth_kind(1) == Some(SyntaxKind::DOLLAR);
1998        let (kind, label) = if display {
1999            (SyntaxKind::DISPLAY_MATH, "$$")
2000        } else {
2001            (SyntaxKind::INLINE_MATH, "$")
2002        };
2003        let opener = (
2004            self.starts[self.pos],
2005            self.starts[self.pos + if display { 2 } else { 1 }],
2006        );
2007        self.open(kind);
2008        self.bump(); // $
2009        if display {
2010            self.bump(); // second $
2011        }
2012        self.open(SyntaxKind::MATH);
2013        self.math_dollar.push(true);
2014        loop {
2015            match self.kind() {
2016                None => {
2017                    self.error_at(opener, format!("unclosed `{label}`"));
2018                    break;
2019                }
2020                // `}` and `\end` are recovery anchors: `$`-math cannot span a
2021                // group or environment boundary, so a `}` here closes the
2022                // enclosing group (a math subgroup would have entered via `{`)
2023                // and a `\end` belongs to an enclosing environment. Leave the
2024                // token for the caller and report the unclosed math.
2025                Some(SyntaxKind::R_BRACE) => {
2026                    self.error_at(opener, format!("unclosed `{label}`"));
2027                    break;
2028                }
2029                Some(SyntaxKind::CONTROL_WORD) if self.at_env_end() => {
2030                    self.error_at(opener, format!("unclosed `{label}`"));
2031                    break;
2032                }
2033                Some(SyntaxKind::DOLLAR) => {
2034                    if display && self.nth_kind(1) != Some(SyntaxKind::DOLLAR) {
2035                        // A lone `$` inside `$$`: malformed; emit and continue.
2036                        self.bump();
2037                        continue;
2038                    }
2039                    // The closing delimiter belongs to the math node, not its
2040                    // body: break and bump it after closing `MATH`.
2041                    break;
2042                }
2043                _ => {
2044                    if self.at_paragraph_break() {
2045                        // Faithful to TeX: a blank line is a `\par`, and `\par`
2046                        // in math mode is "Missing $ inserted" — even inside an
2047                        // alignment cell (#35). Name the cause so the opener
2048                        // span isn't read as a bogus report.
2049                        self.error_at(
2050                            opener,
2051                            format!("unclosed `{label}` (a blank line ends math)"),
2052                        );
2053                        break;
2054                    }
2055                    self.math_element();
2056                }
2057            }
2058        }
2059        self.math_dollar.pop();
2060        self.close(); // MATH
2061        if self.kind() == Some(SyntaxKind::DOLLAR) {
2062            self.bump(); // closing $
2063            if display {
2064                self.bump(); // second closing $
2065            }
2066        }
2067        self.close(); // INLINE_MATH / DISPLAY_MATH
2068    }
2069
2070    /// Delimited math: `\[ … \]` (display) or `\( … \)` (inline). As with
2071    /// [`Self::dollar_math`], the body's atoms are wrapped in a `MATH` node and
2072    /// parsed in math mode.
2073    fn delim_math(&mut self, kind: SyntaxKind, opener: &str, closer: &str) {
2074        let opener_span = self.token_span(self.pos);
2075        self.open(kind);
2076        self.bump(); // \[ or \(
2077        self.open(SyntaxKind::MATH);
2078        self.math_dollar.push(false);
2079        loop {
2080            match self.kind() {
2081                None => {
2082                    self.error_at(opener_span, format!("unclosed `{opener}`"));
2083                    break;
2084                }
2085                Some(SyntaxKind::CONTROL_SYMBOL) if self.text() == closer => {
2086                    // The closer belongs to the math node, not its body.
2087                    break;
2088                }
2089                // A `}` closes an enclosing group: it cannot belong to this
2090                // math (a subgroup would have entered via `{`). Leave it for
2091                // the caller and report the unclosed math.
2092                Some(SyntaxKind::R_BRACE) => {
2093                    self.error_at(opener_span, format!("unclosed `{opener}`"));
2094                    break;
2095                }
2096                Some(SyntaxKind::CONTROL_WORD) if self.at_env_end() => {
2097                    self.error_at(opener_span, format!("unclosed `{opener}`"));
2098                    break;
2099                }
2100                _ => {
2101                    if self.at_paragraph_break() {
2102                        // Same rationale as in `dollar_math`: `\par` ends math.
2103                        self.error_at(
2104                            opener_span,
2105                            format!("unclosed `{opener}` (a blank line ends math)"),
2106                        );
2107                        break;
2108                    }
2109                    self.math_element();
2110                }
2111            }
2112        }
2113        self.math_dollar.pop();
2114        self.close(); // MATH
2115        if self.kind() == Some(SyntaxKind::CONTROL_SYMBOL) && self.text() == closer {
2116            self.bump(); // \] or \)
2117        }
2118        self.close(); // INLINE_MATH / DISPLAY_MATH
2119    }
2120
2121    /// One element inside a math body. Trivia is emitted inline (for
2122    /// losslessness); everything else is an atom, possibly carrying `^`/`_`
2123    /// scripts (see [`Self::math_scripted`]). Callers guard the math closers and
2124    /// recovery anchors before invoking this, so the cursor is at body content.
2125    fn math_element(&mut self) {
2126        match self.kind() {
2127            Some(k) if Self::is_trivia(k) => self.bump(),
2128            _ => self.math_scripted(),
2129        }
2130    }
2131
2132    /// A base atom with any tightly-bound `^`/`_` scripts — the one sanctioned
2133    /// Pratt site (`AGENTS.md`, decision #3). Sub/superscripts are postfix with a
2134    /// single-atom right operand, so this is a base atom followed by a postfix
2135    /// loop, not full precedence climbing.
2136    ///
2137    /// We only wrap the base in a `SCRIPTED` node when a script actually
2138    /// attaches, so an unscripted atom stays a bare token/node (matching the
2139    /// `LINE_BREAK`-only-when-modifiers idiom). Because the base atom's extent is
2140    /// not known until parsed (a command greedily attaches its args), we parse it
2141    /// first and, if a script follows, retroactively splice a `SCRIPTED` start
2142    /// event in front of it — the event-stream analog of rust-analyzer's
2143    /// `precede`, done locally without touching the event layer.
2144    fn math_scripted(&mut self) {
2145        // The lexer keeps ordinary characters in coarse `WORD` runs. Preserve an
2146        // unscripted run as one CST token. When a script follows, expose only the
2147        // final Unicode scalar as TeX's one-token base without changing the lexer.
2148        if self.kind() == Some(SyntaxKind::WORD) {
2149            let idx = self.pos;
2150            self.pos += 1;
2151            if self.at_script() {
2152                let end = self.tokens[idx].text.len();
2153                self.math_word_fragment(idx, 0, end);
2154            } else {
2155                self.events.push(Event::Tok(idx));
2156            }
2157            return;
2158        }
2159        let checkpoint = self.events.len();
2160        self.math_atom();
2161        self.math_scripts(checkpoint);
2162    }
2163
2164    /// Emit one unconsumed byte range of a lexer `WORD`. If a script follows, the
2165    /// final Unicode scalar is isolated as its base; TeX tokenizes an ordinary
2166    /// input character separately even though the lossless lexer coalesces such
2167    /// characters.
2168    ///
2169    /// A fragment can be the remainder of a bare script argument (`x^23_i` leaves
2170    /// `3` after the `2`). In that case `self.pos` already points beyond the lexer
2171    /// token, so a following script correctly binds to the fragment's final atom.
2172    fn math_word_fragment(&mut self, idx: usize, start: usize, end: usize) {
2173        debug_assert!(start < end, "math WORD fragment must be non-empty");
2174        if !self.at_script() {
2175            self.events.push(Event::SubTok { idx, start, end });
2176            return;
2177        }
2178
2179        let last = self.tokens[idx].text[start..end]
2180            .char_indices()
2181            .next_back()
2182            .map(|(offset, _)| start + offset)
2183            .expect("a WORD fragment is non-empty");
2184        if start < last {
2185            self.events.push(Event::SubTok {
2186                idx,
2187                start,
2188                end: last,
2189            });
2190        }
2191        let checkpoint = self.events.len();
2192        self.events.push(Event::SubTok {
2193            idx,
2194            start: last,
2195            end,
2196        });
2197        self.math_scripts(checkpoint);
2198    }
2199
2200    /// Attach any `^`/`_` scripts that follow the base atom emitted since
2201    /// `checkpoint`, retro-splicing a `SCRIPTED` wrapper in front of it
2202    /// ([`Self::precede`]). No script → the base stays a bare atom.
2203    fn math_scripts(&mut self, checkpoint: usize) {
2204        if !self.at_script() {
2205            return; // bare atom, no wrapper
2206        }
2207        self.precede(checkpoint, SyntaxKind::SCRIPTED);
2208        let mut remainder = None;
2209        while self.at_script() {
2210            self.skip_trivia(); // trivia between base/scripts rides inside SCRIPTED
2211            let sub = self.kind() == Some(SyntaxKind::UNDERSCORE);
2212            self.open(if sub {
2213                SyntaxKind::SUBSCRIPT
2214            } else {
2215                SyntaxKind::SUPERSCRIPT
2216            });
2217            self.bump(); // `_` or `^`
2218            remainder = self.math_script_arg();
2219            self.close();
2220            // The rest of a coalesced WORD is outer math content. Any next script
2221            // belongs to its final atom, not to the base we just closed.
2222            if remainder.is_some() {
2223                break;
2224            }
2225        }
2226        self.close(); // SCRIPTED
2227        if let Some((idx, start, end)) = remainder {
2228            self.math_word_fragment(idx, start, end);
2229        }
2230    }
2231
2232    /// True if a `^`/`_` script operator directly follows, skipping only
2233    /// `WHITESPACE`/`NEWLINE` (not a comment, which must end its line — so a
2234    /// script never binds across a comment) and not a blank line (a paragraph
2235    /// break ends the math).
2236    fn at_script(&self) -> bool {
2237        // `CommentMode::Stop`: a comment ends the line, so it stops the scan (and
2238        // is reported as the next meaningful token, which is not a script), rather
2239        // than being skipped as it is elsewhere. A blank line ends the math.
2240        let s = self.scan_trivia(self.pos, CommentMode::Stop);
2241        !s.saw_blank_line
2242            && matches!(
2243                s.next_kind,
2244                Some(SyntaxKind::CARET | SyntaxKind::UNDERSCORE)
2245            )
2246    }
2247
2248    /// A single base atom: a `{…}` group (parsed in math mode), a command with
2249    /// its greedily-attached arguments, an environment, a `\\` line break, or one
2250    /// ordinary token. Always consumes at least one token.
2251    ///
2252    /// **Caller contract: the cursor must not be at EOF.** The `None` arm below
2253    /// consumes nothing and emits nothing, so a caller that reaches it from a
2254    /// loop spins until [`PARSER_STEP_LIMIT`] and panics far from the mistake.
2255    /// Every loop that reaches here guards EOF already — the four math bodies
2256    /// with an explicit `None` arm, `math_environment_body` through
2257    /// [`Self::at_block_end`], and [`Self::math_script_arg`] through its own
2258    /// missing-argument check — and this turns that unwritten contract into a
2259    /// tripwire that fires at the offending call instead.
2260    fn math_atom(&mut self) {
2261        debug_assert!(!self.at_end(), "math_atom at EOF: caller must guard first");
2262        match self.kind() {
2263            Some(SyntaxKind::L_BRACE) => self.math_group(),
2264            Some(SyntaxKind::CONTROL_WORD) => {
2265                // Same definition-body/expl3-region and brace-less gates as
2266                // [`Self::element`] (issues #45/#60).
2267                if !self.in_macro_code(self.pos) && self.at_env_begin() {
2268                    // The group-escape gate is mode-independent: braces remain
2269                    // TeX structure inside math, so an environment macro cannot
2270                    // consume the closing brace of the group that contains it.
2271                    if self.environment_escapes_group(self.pos) {
2272                        if let Some(name) = peek_end_name(self.tokens, self.pos) {
2273                            self.demoted_envs.insert(name.into_owned());
2274                        }
2275                        self.command();
2276                    } else {
2277                        self.environment();
2278                    }
2279                } else if !self.in_macro_code(self.pos) && self.at_env_end() {
2280                    // [`Self::at_block_end`] declines to end a math body at a
2281                    // `\end` that orphans a `\begin` the brace-group gate demoted
2282                    // (issue #71), so that one arrives *here* — and must land as
2283                    // the plain command that verdict already made it. Reporting
2284                    // it stray would have the two halves of one gate disagree.
2285                    if self.end_orphans_a_demoted_begin(self.pos) {
2286                        self.command();
2287                    } else {
2288                        self.stray_end();
2289                    }
2290                } else if let Some((target, closer)) = (!self.in_macro_code(self.pos))
2291                    .then(|| {
2292                        let target = self.alias_openers.get(&self.pos)?.clone();
2293                        Some((target, self.alias_closer(self.pos)?))
2294                    })
2295                    .flatten()
2296                {
2297                    // The [`Self::element`] arm, in math (issue #117). Not an
2298                    // optional extra: `split` — the environment the issue is
2299                    // about — is math-only, so an alias for it is *always* read
2300                    // here and nowhere else. `alias_environment` routes the body
2301                    // by the target exactly as `environment()` does one token
2302                    // earlier in this same match.
2303                    self.alias_environment(&target, closer);
2304                } else if self.at_command(LEFT_CMD) && self.left_right_closes(self.pos) {
2305                    self.left_right();
2306                } else if self.at_command(RIGHT_CMD) {
2307                    self.stray_right();
2308                } else {
2309                    self.command();
2310                }
2311            }
2312            // `\\` line break (with its tightly-bound `*`/`[len]`) vs. a bare
2313            // control symbol (`\,`, `\;`, `\!`, spacing) — emit the latter as a
2314            // single token.
2315            Some(SyntaxKind::CONTROL_SYMBOL) if self.text() == "\\\\" => self.line_break(),
2316            // Any other single token (WORD, digit, `&`, `~`, `#`, brackets, a
2317            // bare control symbol, or a `^`/`_` with no base): one token, so the
2318            // loop always makes progress.
2319            Some(_) => self.bump(),
2320            // Ruled out by the caller contract above; kept because release
2321            // builds compile the assert away and the match must be total.
2322            None => {}
2323        }
2324    }
2325
2326    /// One script argument: a single atom (a `{…}` group, a command with its
2327    /// args, or one input character from a lexer `WORD`). The remainder of a
2328    /// `WORD` is returned to [`Self::math_scripts`] so it can become outer math
2329    /// content after the `SCRIPTED` node closes. A missing argument (the next
2330    /// meaningful token is a closer, `\end`, a paragraph break, or EOF) is
2331    /// reported, not consumed — the closer must stay for the enclosing math loop.
2332    fn math_script_arg(&mut self) -> Option<(usize, usize, usize)> {
2333        if self.at_paragraph_break() {
2334            self.error("missing argument after `^`/`_`");
2335            return None;
2336        }
2337        self.skip_trivia();
2338        let missing = match self.kind() {
2339            None | Some(SyntaxKind::R_BRACE | SyntaxKind::DOLLAR) => true,
2340            Some(SyntaxKind::CONTROL_SYMBOL) => matches!(self.text(), "\\]" | "\\)"),
2341            Some(SyntaxKind::CONTROL_WORD) => self.at_env_end(),
2342            _ => false,
2343        };
2344        if missing {
2345            self.error("missing argument after `^`/`_`");
2346            return None;
2347        }
2348        if self.kind() == Some(SyntaxKind::WORD) {
2349            let idx = self.pos;
2350            let text = &self.tokens[idx].text;
2351            let end = text.len();
2352            let first_end = text.char_indices().nth(1).map_or(end, |(offset, _)| offset);
2353            self.events.push(Event::SubTok {
2354                idx,
2355                start: 0,
2356                end: first_end,
2357            });
2358            self.pos += 1;
2359            return (first_end < end).then_some((idx, first_end, end));
2360        }
2361        self.math_atom();
2362        None
2363    }
2364
2365    /// A brace group `{ … }` whose body is parsed in math mode (so `x^{a_b}`
2366    /// nests). Recovery mirrors [`Self::group`].
2367    fn math_group(&mut self) {
2368        self.argument_group(ArgumentDomain::Math);
2369    }
2370
2371    /// A `\left<delim> … \right<delim>` matched delimiter pair (`AGENTS.md`,
2372    /// decision #3: the one precedence-climbing site — here just balanced
2373    /// matching by *count*, which is exactly how TeX pairs them, so a mismatched
2374    /// `\left( … \right]` still nests correctly). The `\left`/`\right` control
2375    /// words and their delimiter tokens are direct children (mirroring how `$` /
2376    /// `\[` delimiters stay direct children of the math node); the enclosed atoms
2377    /// are wrapped in a `MATH` body. Nested pairs recurse via [`Self::math_atom`].
2378    ///
2379    /// An unclosed `\left` recovers at the enclosing math/group/environment
2380    /// closer (the same anchors the surrounding math loop uses), leaving that
2381    /// token for the caller.
2382    fn left_right(&mut self) {
2383        debug_assert!(self.at_command(LEFT_CMD));
2384        let opener = self.token_span(self.pos);
2385        self.open(SyntaxKind::LEFT_RIGHT);
2386        self.bump(); // \left
2387        self.math_delim(LEFT_CMD);
2388        self.open(SyntaxKind::MATH);
2389        loop {
2390            match self.kind() {
2391                None => {
2392                    self.error_at(opener, "unclosed `\\left`");
2393                    break;
2394                }
2395                Some(SyntaxKind::CONTROL_WORD) if self.at_command(RIGHT_CMD) => break,
2396                // Enclosing-scope closers: `\left … \right` cannot span a group,
2397                // math, or environment boundary, so hand the token back.
2398                Some(SyntaxKind::R_BRACE | SyntaxKind::DOLLAR) => {
2399                    self.error_at(opener, "unclosed `\\left`");
2400                    break;
2401                }
2402                Some(SyntaxKind::CONTROL_SYMBOL) if matches!(self.text(), "\\]" | "\\)") => {
2403                    self.error_at(opener, "unclosed `\\left`");
2404                    break;
2405                }
2406                Some(SyntaxKind::CONTROL_WORD) if self.at_env_end() => {
2407                    self.error_at(opener, "unclosed `\\left`");
2408                    break;
2409                }
2410                _ => {
2411                    if self.at_paragraph_break() {
2412                        self.error_at(opener, "unclosed `\\left`");
2413                        break;
2414                    }
2415                    self.math_element();
2416                }
2417            }
2418        }
2419        self.close(); // MATH
2420        if self.at_command(RIGHT_CMD) {
2421            self.bump(); // \right
2422            self.math_delim(RIGHT_CMD);
2423        }
2424        self.close(); // LEFT_RIGHT
2425    }
2426
2427    /// Consume the single delimiter token following `\left`/`\right`: skip inline
2428    /// trivia (it rides as a direct child of the pair for losslessness; the
2429    /// formatter drops it), then take one token. The lexer has already isolated a
2430    /// word-character delimiter (`(`, `|`, `.`, …) into its own token, so a single
2431    /// `bump` suffices. A missing delimiter — the next meaningful token is a
2432    /// closer, another `\left`/`\right`, `\end`, a paragraph break, or EOF — is
2433    /// reported, not consumed.
2434    fn math_delim(&mut self, after: &str) {
2435        self.skip_trivia();
2436        let missing = match self.kind() {
2437            None | Some(SyntaxKind::R_BRACE | SyntaxKind::DOLLAR) => true,
2438            Some(SyntaxKind::CONTROL_SYMBOL) => matches!(self.text(), "\\]" | "\\)"),
2439            Some(SyntaxKind::CONTROL_WORD) => {
2440                self.at_env_end() || self.at_command(LEFT_CMD) || self.at_command(RIGHT_CMD)
2441            }
2442            _ => false,
2443        };
2444        if missing {
2445            self.error(format!("missing delimiter after `{after}`"));
2446            return;
2447        }
2448        self.bump();
2449    }
2450
2451    /// A `\right` with no open `\left` (the math loop only reaches one here when
2452    /// it is unmatched). Report it and consume it with its delimiter so the parse
2453    /// stays lossless and makes progress.
2454    fn stray_right(&mut self) {
2455        debug_assert!(self.at_command(RIGHT_CMD));
2456        self.error("`\\right` without matching `\\left`");
2457        self.bump(); // \right
2458        self.math_delim(RIGHT_CMD);
2459    }
2460
2461    /// The environment twin of [`Self::delim_math_closes`]: whether the
2462    /// `\begin` at `open` is cut short by the closing brace of a group it sits
2463    /// *inside*, with no `\end` of its own reachable first.
2464    ///
2465    /// Brace groups are catcode-level structure while `\begin`/`\end` are only
2466    /// macros, so a `}` closing a group opened before the `\begin` always wins —
2467    /// the environment cannot span it. Package code leans on this constantly:
2468    /// the two halves sit in sibling groups
2469    /// (`\newcolumntype{w}[2]{>{\begin{lrbox}…}c<{\end{lrbox}…}}`, array.sty),
2470    /// in sibling macros (`\newcommand\BeginExample{…\begin{VerbatimOut}…}`
2471    /// paired with `\EndExample`, rotex.tex), or the `\begin` is prose in a
2472    /// message argument that never runs as structure
2473    /// (`\PackageError{amstex}{\string\begin{split} is not allowed…}`,
2474    /// amstex.sty — all issue #71). In each the `\begin` is an ordinary token:
2475    /// it opens no `ENVIRONMENT` and draws **no diagnostic**, the same shape
2476    /// gate `\[` already gets from [`Self::delim_math_closes`]. Without it the
2477    /// environment swallows the `}` and cascades into unmatched-brace noise
2478    /// that fails the whole file for the formatter.
2479    ///
2480    /// Only the *group boundary* suppresses the environment. A `\begin` that
2481    /// merely runs out of file still opens one, so the unclosed-environment
2482    /// diagnostic keeps firing on a genuinely forgotten `\end`. A `\end` of
2483    /// another name terminates the scan too, leaving the existing mismatch
2484    /// recovery in [`Self::finish_environment`] untouched. Does not consume.
2485    fn environment_escapes_group(&self, open: usize) -> bool {
2486        // Only a group the `\begin` is *actually* inside can cut it short. At
2487        // the outer level there is no such brace, and a later unbalanced `}`
2488        // is somebody else's business — notably a `.dtx` doc-line
2489        // `\begin{macro}`, whose intervening `macrocode` chunks split
2490        // definitions across braces on purpose ([`Self::plain_braces`], only
2491        // populated once that chunk is entered). Without this guard the scan
2492        // reads those as its own boundary and unnests the whole doc layer.
2493        if !self.in_group() {
2494            return false;
2495        }
2496        // `.dtx` doc-margin lines are exempt, exactly as they are from the
2497        // expl3 carve-out ([`Self::expl_toggles`]): `\begin{macro}` and friends
2498        // are the *documentation* layer and must keep pairing across the
2499        // macrocode chunks between them. Those bodies routinely span code that
2500        // leaves a brace open on purpose — a `\iffalse}\fi` editor-balance
2501        // hack, a `` \char`} `` constant, a catcode-swapped region — which
2502        // leaves a group open for the rest of the file and would
2503        // otherwise unnest the whole doc layer behind it. (A paragraph-break
2504        // bound cannot stand in here: a blank `.dtx` doc line is still a `%`
2505        // margin, so it never reads as a `\par`.)
2506        //
2507        // The exemption is about *stranded* braces, so it lifts when the
2508        // enclosing group opened on a doc-margin line too: that `{` is the
2509        // documentation layer's own, locally visible, and the `\begin` really is
2510        // inside it. `% \def\deflist#1{\begin{list}…}` paired with
2511        // `% \def\enddeflist{\end{list}}` (theorem.dtx, issue #71) is the split
2512        // environment definition the gate exists for, merely written as doc
2513        // prose.
2514        if self.doc_margin_exempt(open) {
2515            return false;
2516        }
2517        // Both checks above are per-opener walk state, so they stay outside the
2518        // batch: a `\begin` they reject never consults it, and the batch stores
2519        // only what the *scan* decided.
2520        //
2521        // The `{name}` group of the `\begin` itself nests and unnests inside the
2522        // scan, so it resumes at the environment's own level. The only escape is
2523        // a `}` at that level, so the last `}` in the file bounds the scan
2524        // ([`Self::last_r_brace`]) — sound, but rarely effective, since a
2525        // `\begin{…}` opener's own name group carries one and pushes the index
2526        // toward EOF. That is why this gate needed the batch
2527        // ([`EnvGate`], `TODO.md` container stack C2.2): the bound alone left it
2528        // quadratic in the number of openers.
2529        self.gated_closer(open, &EnvGate, &self.env_batch).is_some()
2530    }
2531
2532    /// The conditional twin of [`Self::delim_math_closes`]: whether the live
2533    /// opener at token `open` ([`Self::conditional_openers`]) has its own `\fi`
2534    /// reachable before a token that would end it.
2535    ///
2536    /// `\if…\else…\or…\fi` is not a construct the surface syntax guarantees. A
2537    /// `\fi` is routinely assembled elsewhere — `\def\stopit{\fi}`,
2538    /// `\expandafter\fi`, an `\iffalse…\fi` used to comment a region out — so
2539    /// after subtracting the `\newif` and `\ifthenelse` families 268 of 6205
2540    /// corpus files still have unbalanced opener/`\fi` counts. An opener that
2541    /// does not pair is therefore ordinary macro code: it stays a plain
2542    /// `COMMAND` with **no diagnostic**, exactly as a gated `$`/`\[`/`\begin`
2543    /// does (`AGENTS.md` decision #1). Does not consume.
2544    ///
2545    /// The anchors mirror the math gates — an unbalanced `}`, an `\end` not owed
2546    /// to an intervening `\begin`, a paragraph break, the macrocode chunk end,
2547    /// EOF — with two deliberate differences from
2548    /// [`Self::environment_escapes_group`]:
2549    ///
2550    /// - **EOF does not pair.** The environment gate keeps a run-out-of-file
2551    ///   `\begin` so `finish_environment` can still report an unclosed
2552    ///   environment. A conditional has no diagnostic to preserve, and an
2553    ///   unpaired `\if` is routine, so running out of file just demotes.
2554    /// - **No `.dtx` doc-margin exemption.** That exemption exists so the
2555    ///   documentation layer keeps pairing `\begin{macro}` across the macrocode
2556    ///   chunks between them. A conditional has no such split-across-chunks
2557    ///   story, and bounding the scan at `macrocode_end` is precisely what makes
2558    ///   the `\iffalse}\fi` editor-balance hack demote instead of swallowing the
2559    ///   chunk.
2560    ///
2561    /// A paragraph break anchors at the construct's own level only, so the ~11%
2562    /// of corpus conditionals that span a blank line demote and keep their
2563    /// pre-node layout. That keeps
2564    /// `CONDITIONAL` a within-paragraph construct: it can never straddle a
2565    /// `PARAGRAPH` boundary, so no paragraph nests inside one.
2566    ///
2567    /// The closer must be reachable at the opener's **own level of every nesting
2568    /// the parse itself recognizes** — braces, environments, and math alike — not
2569    /// just braces. A token scan that counts a `\fi` the parse will consume inside
2570    /// some other construct promises a pairing the walk cannot honor, and
2571    /// [`Self::conditional`] then runs past it looking for a closer that is gone:
2572    /// `ltboxes.dtx`'s `\else\@pboxswtrue $\vcenter \fi\fi\fi … \if@pboxsw
2573    /// \m@th$\fi` puts all three `\fi`s inside a `$…$`, and the construct ran over
2574    /// 160 lines and every `macrocode` chunk in between. Hence the `envs == 0`
2575    /// requirement on the closer and the math anchor.
2576    ///
2577    /// The guarantee this buys is **one-directional, and that is the direction
2578    /// that matters**: the walk never runs *past* the index returned here (it is
2579    /// bounded by it outright). The walk may still stop *earlier*, because this
2580    /// scan counts nested openers by name while the walk re-gates each one and may
2581    /// demote it — and a demoted opener's `\fi` is then a closer the walk reaches
2582    /// first. `\ifA \begin{center} \ifB \end{center} \fi \fi` is the shape: the
2583    /// scan counts `\ifB` as nested and picks the second `\fi`, while the walk
2584    /// demotes `\ifB` (whose own scan meets an unowed `\end`) and closes at the
2585    /// first, leaving the second a plain `COMMAND`. Lossless, and the node is still
2586    /// well formed — but it is why [`crate::ast::Conditional::closer`] is fallible
2587    /// and why nothing downstream may assume the two indices agree
2588    /// (`conditional_walk_may_close_before_the_located_fi`, `tests/parser.rs`).
2589    ///
2590    /// **Cost.** Verdicts are computed in *batches* (`TODO.md`, container-stack
2591    /// C1): one forward scan seeded at the queried opener settles every
2592    /// same-frame opener it passes ([`Self::gate_batch`] under
2593    /// [`ConditionalGate`]), and the batch is memoized against the walk state
2594    /// it read
2595    /// ([`Self::conditional_batch`]) — so a run of top-level openers costs one
2596    /// O(n) pass where it used to cost one scan each. The scan stays bounded
2597    /// by the last `\fi`-flavored word in the file ([`Self::last_fi`], C0), so
2598    /// a file with none refuses without scanning at all. Openers the batch did
2599    /// not settle (they sat behind a brace at batch time) and queries under a
2600    /// changed walk state re-batch; every ordinary anchor still cuts a scan
2601    /// short, which is why real conditional-heavy packages (`biblatex.sty`,
2602    /// `latexrelease.sty`, `memoir.cls`) were within noise of the pre-node
2603    /// parser even before the batch.
2604    fn conditional_closer(&self, open: usize) -> Option<usize> {
2605        self.gated_closer(open, &ConditionalGate, &self.conditional_batch)
2606    }
2607
2608    /// The walk state a gate batch's scan reads — see [`WalkKey`].
2609    fn walk_key(&self) -> WalkKey {
2610        WalkKey {
2611            macrocode_end: self.macrocode_end,
2612            in_def_body: self.in_def_body,
2613            in_group: self.in_group(),
2614            plain_braces: self.plain_braces_version,
2615            enclosing_math_is_dollar: self.enclosing_math_is_dollar(),
2616        }
2617    }
2618
2619    /// Whether the innermost enclosing math body is dollar-delimited
2620    /// ([`Self::math_dollar`]). Outside math the answer is unused; `false` is
2621    /// the reading a bracket gate would take there anyway.
2622    fn enclosing_math_is_dollar(&self) -> bool {
2623        self.math_dollar.last().copied().unwrap_or(false)
2624    }
2625
2626    /// Whether the token at `i` is a `[` that **directly abuts** a command, and
2627    /// so claims the next `]` for itself when parsed — the bracket family's
2628    /// nested opener ([`TextBracketGate`]). The pre-batch scans derived this
2629    /// from a running `abuts_command` flag that every token kind but a control
2630    /// word or symbol cleared, trivia included, which is this test one token
2631    /// back.
2632    fn bracket_abuts_command(&self, i: usize) -> bool {
2633        self.tokens[i].kind == SyntaxKind::L_BRACKET
2634            && i > 0
2635            && matches!(
2636                self.tokens[i - 1].kind,
2637                SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL
2638            )
2639    }
2640
2641    /// The memoized front of [`Self::gate_batch`]: answer `open` from `memo`
2642    /// when the batch there was harvested under the current walk state and
2643    /// settled this opener, and otherwise re-batch from `open` and keep the
2644    /// result.
2645    ///
2646    /// One slot per gate is all the reuse there is *for verdicts*: the walk
2647    /// queries each opener once, in ascending order, under a state that is
2648    /// stable between re-batches. The slot's **storage** is reused further
2649    /// than that — a miss takes the stale map, clears it, and refills it, so a
2650    /// gate allocates about once per parse instead of once per re-batch. A
2651    /// cleared `HashMap` keeps its capacity, and the batches of one gate over
2652    /// one file are all much of a size.
2653    fn gated_closer<P: GatePolicy>(
2654        &self,
2655        open: usize,
2656        policy: &P,
2657        memo: &std::cell::RefCell<Option<GateBatch>>,
2658    ) -> Option<usize> {
2659        // The C0 bound as an early-out: a file with no closer of this gate's
2660        // shape refuses without scanning at all.
2661        policy.last_closer(self)?;
2662        let key = self.walk_key();
2663        if let Some(batch) = memo.borrow().as_ref()
2664            && batch.key == key
2665            && let Some(&verdict) = batch.verdicts.get(&open)
2666        {
2667            return verdict;
2668        }
2669        // Recycle the superseded batch's map: its verdicts are stale (the key
2670        // missed, or it did not settle this opener), but its allocation is not.
2671        let mut verdicts =
2672            memo.borrow_mut()
2673                .take()
2674                .map_or_else(std::collections::HashMap::new, |stale| {
2675                    let mut map = stale.verdicts;
2676                    map.clear();
2677                    map
2678                });
2679        self.gate_batch(open, policy, &mut verdicts);
2680        let verdict = verdicts.get(&open).copied();
2681        debug_assert!(verdict.is_some(), "the batch must settle its own seed");
2682        *memo.borrow_mut() = Some(GateBatch { key, verdicts });
2683        verdict.flatten()
2684    }
2685
2686    /// The unmemoized front, for a **single-entry** gate ([`DelimMathGate`],
2687    /// [`DollarGate`]): one that opens no nested entry, so its batch settles the
2688    /// seed and nothing else and there is no neighbor to save.
2689    ///
2690    /// A memo slot would not merely be idle here, it would be a hazard. The one
2691    /// re-query these gates see is a demoted `$$` whose second `$` re-enters
2692    /// [`Self::element`] as a fresh opener: same token index, same walk state,
2693    /// but `display: false` — a *different question*, which a slot keyed on the
2694    /// walk state alone would answer from the display verdict.
2695    ///
2696    /// With nothing to memoize and nothing but the seed to settle, the batch
2697    /// collects into a [`SeedVerdict`] rather than a map: these are the gates
2698    /// the walk queries most (`$` and `\[` are everywhere), and a per-query
2699    /// allocation for a single verdict is the whole cost of asking.
2700    fn gate_verdict<P: GatePolicy>(&self, open: usize, policy: &P) -> Option<usize> {
2701        // The C0 bound as an early-out, as in [`Self::gated_closer`].
2702        policy.last_closer(self)?;
2703        let mut sink = SeedVerdict {
2704            seed: open,
2705            verdict: None,
2706        };
2707        self.gate_batch(open, policy, &mut sink);
2708        debug_assert!(sink.verdict.is_some(), "the batch must settle its own seed");
2709        sink.verdict.flatten()
2710    }
2711
2712    /// The batched walk behind every shape gate: one forward scan seeded at
2713    /// `open` that also settles, as a by-product, every opener it passes in
2714    /// the seed's own brace frame — the exact verdict each one's own scan
2715    /// would have computed under the current walk state. Settled verdicts go
2716    /// to `verdicts`, whose two implementations decide how many are kept
2717    /// ([`VerdictSink`]); the scan itself never reads them back.
2718    ///
2719    /// The transform from a per-opener scan is possible because such a scan
2720    /// counts nested openers only at `depth == 0`: every opener this scan
2721    /// passes shares the seed's brace frame exactly, so `depth` is common to
2722    /// all of them, an entry's environment count relative to itself is
2723    /// `envs - envs_at_push`, and its nested-opener count is the number of
2724    /// stack entries above it — closer matching is pure LIFO.
2725    ///
2726    /// The one non-obvious rule: a refuted entry is **settled, never
2727    /// removed**. A per-opener scan counts nested openers *by name*
2728    /// ([`GatePolicy::opens_at`] membership) and never un-counts one, so a
2729    /// later closer must still be consumed by the refuted entry's slot. In
2730    /// `\ifA \begin{center} \ifB \end{center} \fi \fi`, the unowed `\end`
2731    /// refutes `\ifB` — but `\ifA`'s own scan still counts `\ifB` as nested
2732    /// and pairs with the *second* `\fi`. Popping `\ifB` at the `\end` would
2733    /// hand the first `\fi` to `\ifA`: a different verdict, a different tree.
2734    /// A closer that pops an already-settled entry records nothing. Every gate
2735    /// that joins this driver has the same never-un-counted countdown, so the
2736    /// rule is the driver's, not the conditional gate's.
2737    ///
2738    /// Per anchor, mirroring the pre-batch conditional scan token for token:
2739    /// - a closer at depth 0 pops the top entry; if it was still live, its
2740    ///   verdict is `Some` iff no `\begin`-opened environment stands in the
2741    ///   way (`envs == envs_at_push`, the old `envs == 0` restated — waived by
2742    ///   [`GatePolicy::CLOSER_NEEDS_ENV_BALANCE`]) and [`GatePolicy::pairs`]
2743    ///   accepts it;
2744    /// - a paragraph break (for a gate that anchors on one) or an unowed
2745    ///   `\end` refutes exactly the live
2746    ///   entries at their own level (`envs_at_push == envs`) — a contiguous
2747    ///   top suffix of the live stack, whose `envs_at_push` values are
2748    ///   non-decreasing and capped at `envs` by construction — and the `\end`
2749    ///   then decrements `envs` for the survivors;
2750    /// - math, an unbalanced `}` (under an enclosing group, or anywhere for a
2751    ///   gate reading [`StrayBrace::RefutesAlways`]), a `macrocode` frame, and
2752    ///   the end bound refute everything still live.
2753    ///
2754    /// The scan ends as soon as no live entry remains.
2755    fn gate_batch<P: GatePolicy, S: VerdictSink>(&self, open: usize, policy: &P, verdicts: &mut S) {
2756        struct Entry {
2757            opener: usize,
2758            envs_at_push: usize,
2759            settled: bool,
2760        }
2761        /// Settle every live entry sitting at its own environment level: the
2762        /// level anchor at hand refutes exactly those.
2763        fn settle_level<S: VerdictSink>(
2764            pending: &mut [Entry],
2765            live: &mut Vec<usize>,
2766            verdicts: &mut S,
2767            envs: usize,
2768        ) {
2769            while let Some(&idx) = live.last() {
2770                let entry = &mut pending[idx];
2771                if entry.envs_at_push != envs {
2772                    break;
2773                }
2774                entry.settled = true;
2775                verdicts.insert(entry.opener, None);
2776                live.pop();
2777            }
2778        }
2779        /// The [`Nesting::Interleaved`] twin of [`settle_level`]: settle the one
2780        /// entry that owns the innermost frame, and only when no environment
2781        /// stands inside it. The entries below are shielded by that frame and
2782        /// keep scanning — a settled entry keeps its frame, so a later closer
2783        /// still consumes it.
2784        fn settle_innermost<S: VerdictSink>(
2785            pending: &mut [Entry],
2786            live: &mut Vec<usize>,
2787            verdicts: &mut S,
2788            envs: usize,
2789        ) {
2790            let Some(entry) = pending.last_mut() else {
2791                return;
2792            };
2793            if entry.settled || entry.envs_at_push != envs {
2794                return;
2795            }
2796            entry.settled = true;
2797            verdicts.insert(entry.opener, None);
2798            // An unsettled top of `pending` is the topmost live entry: an entry
2799            // leaves `live` only by being settled or by being popped from
2800            // `pending` outright.
2801            debug_assert_eq!(live.last().copied(), Some(pending.len() - 1));
2802            live.pop();
2803        }
2804        let mut pending = vec![Entry {
2805            opener: open,
2806            envs_at_push: 0,
2807            settled: false,
2808        }];
2809        // Indices into `pending` of the entries still awaiting a verdict,
2810        // ascending.
2811        let mut live = vec![0usize];
2812        let mut depth = 0usize;
2813        let mut envs = 0usize;
2814        let mut newlines = 0;
2815        // Inside a `$…$` region the entries read *through*: their openers and
2816        // closers stop counting until the matching `$`. Only
2817        // [`DollarAnchor::Transparent`] ever sets it.
2818        let mut transparent = false;
2819        let end = self
2820            .macrocode_end
2821            .unwrap_or(self.tokens.len())
2822            .min(self.tokens.len())
2823            .min(policy.last_closer(self).map_or(0, |last| last + 1));
2824        let mut i = open + 1;
2825        while i < end {
2826            self.tick_scan();
2827            let t = &self.tokens[i];
2828            match t.kind {
2829                SyntaxKind::NEWLINE => {
2830                    newlines += 1;
2831                    // A break anchors at an entry's *own* level only,
2832                    // `depth == 0 && envs == envs_at_push`. Deeper than that it
2833                    // is ordinary body trivia, and a gate stricter than the
2834                    // parse it guards drops the node: a display equation built
2835                    // out of `tikzpicture` cells (`\[ \begin{array}…
2836                    // \begin{tikzpicture}<blank line>… \]`, issue #70) lost its
2837                    // math node and reported its own `\]` as unmatched. The
2838                    // bracket family is the exception, and for the same reason:
2839                    // `optional` bails at a break wherever the cursor stands
2840                    // ([`ParagraphAnchor::AnyDepth`]).
2841                    if newlines >= BLANK_LINE_NEWLINES
2842                        && match P::PARAGRAPH_ANCHOR {
2843                            ParagraphAnchor::None => false,
2844                            ParagraphAnchor::OwnLevel => depth == 0,
2845                            ParagraphAnchor::AnyDepth => true,
2846                        }
2847                    {
2848                        if P::PARAGRAPH_ANCHOR == ParagraphAnchor::AnyDepth {
2849                            break;
2850                        }
2851                        // Under interleaved nesting the break is seen only by
2852                        // the entry owning the innermost frame: every entry
2853                        // below has that frame on its own stack, so its
2854                        // `stack.is_empty()` test cannot fire ([`Nesting`]).
2855                        match P::NESTING {
2856                            Nesting::Counted => {
2857                                settle_level(&mut pending, &mut live, verdicts, envs);
2858                            }
2859                            Nesting::Interleaved => {
2860                                settle_innermost(&mut pending, &mut live, verdicts, envs);
2861                            }
2862                        }
2863                        if live.is_empty() {
2864                            return;
2865                        }
2866                    }
2867                    i += 1;
2868                    continue;
2869                }
2870                // A `.dtx` doc margin floats like whitespace — it is one byte of
2871                // layout, not content — so a margin-only line `%\n%\n` still reads
2872                // as the blank line its two `NEWLINE`s make it.
2873                SyntaxKind::WHITESPACE | SyntaxKind::DOC_MARGIN => {
2874                    i += 1;
2875                    continue;
2876                }
2877                // A docstrip guard is content *on its line*, and a line docstrip
2878                // deletes outright when it strips the file, so `%<*dtx>` between
2879                // two lines does not part them (issue #71): it breaks the newline
2880                // run without being a newline. That is
2881                // [`TriviaScan::saw_blank_line_outside_guards`], the considered
2882                // model, and every gate reads it — see the type-level note on
2883                // [`GatePolicy`].
2884                SyntaxKind::GUARD => {
2885                    newlines = 0;
2886                    i += 1;
2887                    continue;
2888                }
2889                // Math swallows whatever it spans, and this scan does not model
2890                // the `$`/`\[`/`\(` shape gates that decide whether a delimiter
2891                // opens any. Rather than re-derive them, a gate that lives in
2892                // text refuses at math *starting*: a construct whose closer sits
2893                // behind such a delimiter stays a plain command. A conservative
2894                // false negative, per the parser's standing preference for them.
2895                // The demotion gate reverses the direction and a gate that lives
2896                // *inside* math reverses the side ([`MathAnchor`]). A `$` is both
2897                // sides at once, so it anchors for either — unless it opens a
2898                // region the gate reads *through* ([`DollarAnchor`]).
2899                SyntaxKind::DOLLAR
2900                    if depth == 0 && policy.dollar_anchor() == DollarAnchor::Refutes =>
2901                {
2902                    break;
2903                }
2904                SyntaxKind::DOLLAR
2905                    if depth == 0 && policy.dollar_anchor() == DollarAnchor::Transparent =>
2906                {
2907                    transparent = !transparent;
2908                }
2909                SyntaxKind::CONTROL_SYMBOL
2910                    if (depth == 0 || P::ANCHORS_AT_ANY_DEPTH)
2911                        && P::MATH_ANCHOR.anchors(t.text.as_str()) =>
2912                {
2913                    break;
2914                }
2915                SyntaxKind::L_BRACE if !self.plain_braces.contains(&i) => depth += 1,
2916                SyntaxKind::R_BRACE if !self.plain_braces.contains(&i) => {
2917                    if depth == 0 {
2918                        // A `}` closing a group opened before the opener always
2919                        // wins: braces are catcode structure while the gated
2920                        // delimiters are only macros. Whether one with *no* such
2921                        // group behind it (the walk is at the outer level) means anything,
2922                        // and what it means at all, is the gate's own call
2923                        // ([`StrayBrace`]).
2924                        match P::STRAY_BRACE {
2925                            StrayBrace::RefutesInGroup if self.in_group() => break,
2926                            StrayBrace::ClosesInGroup if self.in_group() => {
2927                                // Every live entry escapes at the same brace:
2928                                // `depth` is common to the whole frame, so each
2929                                // one's own scan would reach this `}` at its own
2930                                // depth 0 too.
2931                                for &idx in &live {
2932                                    verdicts.insert(pending[idx].opener, Some(i));
2933                                }
2934                                return;
2935                            }
2936                            StrayBrace::RefutesAlways => break,
2937                            _ => {}
2938                        }
2939                    } else {
2940                        depth -= 1;
2941                    }
2942                }
2943                // Any token at the entries' own brace level may be a delimiter:
2944                // the pairing gates close on a `CONTROL_WORD`, but the math
2945                // gates close on a `DOLLAR` and a `CONTROL_SYMBOL`. Every policy
2946                // tests the kind inside its own predicate, so asking wider costs
2947                // the narrow ones nothing but the call.
2948                _ => {
2949                    if !transparent && depth == 0 && policy.opens_at(self, i) {
2950                        // A gate whose openers are `\begin`s counts this one
2951                        // before pushing, so the entry's own environment is not
2952                        // in its `envs_at_push` — its per-opener scan starts one
2953                        // token past the `\begin` and never saw it either.
2954                        if P::OPENER_IS_ENV_BEGIN {
2955                            envs += 1;
2956                        }
2957                        live.push(pending.len());
2958                        pending.push(Entry {
2959                            opener: i,
2960                            envs_at_push: envs,
2961                            settled: false,
2962                        });
2963                    } else if !transparent && depth == 0 && policy.closes_at(self, i) {
2964                        let entry = pending
2965                            .pop()
2966                            .expect("a live entry remains, so pending is non-empty");
2967                        // Under interleaved nesting the closer pops the
2968                        // *innermost frame*, so an environment opened since this
2969                        // entry is a frame mismatch — and one every outer entry
2970                        // sees too, since this entry's frame is their innermost
2971                        // one. It refuses the whole scan rather than one entry
2972                        // ([`Nesting`]). This entry is out of `pending` already,
2973                        // so it settles itself here and the trailing refusal
2974                        // covers the rest.
2975                        if P::NESTING == Nesting::Interleaved && envs != entry.envs_at_push {
2976                            if !entry.settled {
2977                                live.pop();
2978                                verdicts.insert(entry.opener, None);
2979                            }
2980                            break;
2981                        }
2982                        if !entry.settled {
2983                            live.pop();
2984                            // `envs == envs_at_push` for the same reason as
2985                            // `depth == 0`: a closer inside an environment the
2986                            // construct opened is consumed by that
2987                            // environment's body, so it is not a closer the
2988                            // walk can reach — unless the closer is a *math
2989                            // delimiter*, which ends the body wherever it sits
2990                            // ([`GatePolicy::CLOSER_NEEDS_ENV_BALANCE`]).
2991                            let balanced =
2992                                !P::CLOSER_NEEDS_ENV_BALANCE || envs == entry.envs_at_push;
2993                            let paired = balanced && policy.pairs(self, entry.opener, i);
2994                            verdicts.insert(entry.opener, paired.then_some(i));
2995                            if live.is_empty() {
2996                                return;
2997                            }
2998                        }
2999                    } else if t.kind == SyntaxKind::CONTROL_WORD
3000                        && (depth == 0 || P::ANCHORS_AT_ANY_DEPTH)
3001                        && (P::ENV_ANCHOR_IN_MACRO_CODE || !self.in_macro_code(i))
3002                    {
3003                        // In a definition body or an expl3 region `\begin`/`\end`
3004                        // are plain commands that need not pair, so neither
3005                        // anchors nor nests there (issues #45/#60) — bar the one
3006                        // gate whose pre-batch scan never carried the filter
3007                        // ([`GatePolicy::ENV_ANCHOR_IN_MACRO_CODE`]).
3008                        if self.env_begin_at(i) {
3009                            // A `macrocode` chunk is a hard boundary in both
3010                            // directions: docstrip is line-oriented, so the code
3011                            // layer and the documentation layer around it are
3012                            // different files as far as TeX is concerned. Nothing
3013                            // is gained by pairing across one, and a `.dtx` doc
3014                            // layer that does — `%<latexrelease>` guarded
3015                            // `\if#1b\vbox \else…` blocks in `ltboxes.dtx` — runs
3016                            // the construct over every chunk in between, stranding
3017                            // the cursor past `macrocode_end` for every
3018                            // chunk-bounded scan downstream. (The other direction
3019                            // is already bounded: a conditional *inside* a chunk
3020                            // scans only to `macrocode_end`.) The math gates opt
3021                            // out ([`GatePolicy::MACROCODE_FRAME_ANCHORS`]).
3022                            if P::MACROCODE_FRAME_ANCHORS
3023                                && peek_begin_name(self.tokens, i).is_some_and(|n| {
3024                                    matches!(n.as_ref(), "macrocode" | "macrocode*")
3025                                })
3026                            {
3027                                break;
3028                            }
3029                            // An optional never legitimately spans an
3030                            // environment, so for the bracket family either half
3031                            // is a runaway `[` and there is nothing to count
3032                            // ([`EnvAnchor`]).
3033                            if P::ENV_ANCHOR == EnvAnchor::Refutes {
3034                                break;
3035                            }
3036                            envs += 1;
3037                        } else if self.env_end_at(i) {
3038                            if P::ENV_ANCHOR == EnvAnchor::Refutes {
3039                                break;
3040                            }
3041                            if P::ENV_END_UNWINDS_OPENERS {
3042                                let end_name = peek_end_name(self.tokens, i);
3043                                let mut matched = false;
3044                                while let Some(entry) = pending.pop() {
3045                                    envs = entry.envs_at_push;
3046                                    if !entry.settled {
3047                                        let live_entry = live.pop();
3048                                        debug_assert_eq!(live_entry, Some(pending.len()));
3049                                        verdicts.insert(entry.opener, None);
3050                                    }
3051                                    if peek_begin_name(self.tokens, entry.opener).as_deref()
3052                                        == end_name.as_deref()
3053                                    {
3054                                        matched = true;
3055                                        break;
3056                                    }
3057                                }
3058                                // A mismatched closer is the same recovery
3059                                // anchor every per-opener scan used. A named
3060                                // match may unwind several nested environments,
3061                                // exactly as `finish_environment` does.
3062                                if !matched || live.is_empty() {
3063                                    for &idx in &live {
3064                                        verdicts.insert(pending[idx].opener, None);
3065                                    }
3066                                    return;
3067                                }
3068                                i += 1;
3069                                newlines = 0;
3070                                continue;
3071                            }
3072                            match P::NESTING {
3073                                // The `\end` must find an environment innermost.
3074                                // It does not when the entry on top of `pending`
3075                                // was pushed at the current `envs`: that entry's
3076                                // frame is in the way, for it and for every entry
3077                                // below it alike, so the mismatch refuses the
3078                                // whole scan. A settled entry still holds its
3079                                // frame ([`Nesting`]).
3080                                Nesting::Interleaved => {
3081                                    if pending.last().is_some_and(|e| e.envs_at_push == envs) {
3082                                        break;
3083                                    }
3084                                }
3085                                Nesting::Counted => {
3086                                    settle_level(&mut pending, &mut live, verdicts, envs);
3087                                    if live.is_empty() {
3088                                        return;
3089                                    }
3090                                }
3091                            }
3092                            // A survivor has `envs_at_push < envs`, so the
3093                            // decrement cannot underflow.
3094                            envs -= 1;
3095                        }
3096                    }
3097                }
3098            }
3099            newlines = 0;
3100            i += 1;
3101        }
3102        // Global refusals — math, an unbalanced `}`, a `macrocode` frame, the
3103        // end bound: everything still live demotes.
3104        for &idx in &live {
3105            verdicts.insert(pending[idx].opener, None);
3106        }
3107    }
3108
3109    /// The token index closing the environment-alias opener at `open`, or `None`
3110    /// when it does not pair — in which case the opener stays a plain `COMMAND`
3111    /// with **no diagnostic**, like a gated `$`/`\[`/`\begin`.
3112    ///
3113    /// This is a **positive** gate, transcribed from [`Self::conditional_closer`]
3114    /// rather than from [`Self::environment_escapes_group`]. The `\begin` gate is a
3115    /// *demotion* gate on a construct that pairs by default and carries an
3116    /// unclosed-environment diagnostic worth preserving. An alias opener is a bare
3117    /// control word with no `{name}` corroborating it and no diagnostic to keep, so
3118    /// "pair unless refuted" would be far too optimistic: it must be refused unless
3119    /// its closer is positively located, and the walk is then bounded by that index.
3120    ///
3121    /// Requirements the driver ([`Self::gate_batch`]) carries for it, shared
3122    /// with the sibling gates:
3123    ///
3124    /// - **Brace level.** A `}` closing a group opened before the opener always
3125    ///   wins — braces are catcode structure, an alias is only a macro (issue #71).
3126    /// - **`envs == 0`.** A closer inside an environment the alias opened is
3127    ///   consumed by that environment's body, so the walk cannot reach it.
3128    /// - **Math refuses.** The scan does not model the `$`/`\[`/`\(` shape gates,
3129    ///   so rather than re-derive them it declines behind one.
3130    /// - **`macrocode` bounds it both ways**, as for conditionals.
3131    ///
3132    /// What is this gate's own is in [`AliasGate`]: no paragraph anchor, and a
3133    /// closer that must name the opener's target.
3134    ///
3135    /// Batched and memoized like the conditional gate — and here the memo was
3136    /// load-bearing before the batch existed, since the caller asks twice
3137    /// ([`Self::alias_batch`]).
3138    fn alias_closer(&self, open: usize) -> Option<usize> {
3139        // Total in `open`: [`Self::starts_block_env`] asks about any index, and
3140        // the driver would otherwise seed an entry for a token that opens
3141        // nothing.
3142        self.alias_openers.get(&open)?;
3143        self.gated_closer(open, &AliasGate, &self.alias_batch)
3144    }
3145
3146    /// The environment the token at `idx` closes, under *either* spelling: a
3147    /// closer alias (`\eea`), or the literal `\end{X}` an alias-opened `X` pairs
3148    /// with (issue #117). `None` when it closes neither.
3149    ///
3150    /// The two maps stay separate ([`Self::literal_alias_closers`]) because the
3151    /// consumers differ; this is the one place that reads them as one. The
3152    /// literal arm re-tests [`Self::env_end_at`] because the pre-scan's index is
3153    /// built from the looser `peek_end_name` — a `\end` the walk would treat as
3154    /// a plain command must not become an `END` here, or the `NAME_GROUP`
3155    /// [`Self::alias_environment`] then asks for is not there.
3156    fn closer_target(&self, idx: usize) -> Option<&str> {
3157        if let Some(target) = self.alias_closers.get(&idx) {
3158            return Some(target.as_str());
3159        }
3160        let target = self.literal_alias_closers.get(&idx)?;
3161        self.env_end_at(idx).then_some(target.as_str())
3162    }
3163
3164    /// Whether the closer at `idx` is spelled out as `\end{X}` rather than as a
3165    /// closer alias — the one thing the two [`Self::closer_target`] arms are
3166    /// consumed differently for.
3167    fn closer_is_literal(&self, idx: usize) -> bool {
3168        !self.alias_closers.contains_key(&idx) && self.literal_alias_closers.contains_key(&idx)
3169    }
3170
3171    /// `\bea … \eea`: an environment opened by a bare control word, for the
3172    /// closer [`Self::alias_closer`] located at token index `closer` — which is
3173    /// either the closer alias or a literal `\end{X}` (issue #117).
3174    ///
3175    /// Emits the *same* `ENVIRONMENT > BEGIN … END` shape a spelled-out
3176    /// `\begin{X} … \end{X}` does, so every consumer downstream — the formatter's
3177    /// lowering, folding, the outline, [`crate::ast::Environment`] — works
3178    /// unchanged. The only difference is that `BEGIN` holds a bare
3179    /// `CONTROL_WORD` instead of `\begin` plus a `NAME_GROUP`, which is why
3180    /// [`crate::ast::Begin::name`] falls back to the head control word; a
3181    /// literally-closed `END` is byte-for-byte the ordinary one.
3182    ///
3183    /// No arguments are attached to either delimiter: the alias head consumes none
3184    /// (that is an admission rule of the scan, `semantic::define`), and attaching
3185    /// them from the *target's* signature would be arity-directed grouping from
3186    /// scanned data, which `AGENTS.md` decision #8 holds the line on.
3187    fn alias_environment(&mut self, target: &str, closer: usize) {
3188        self.open(SyntaxKind::ENVIRONMENT);
3189        self.open(SyntaxKind::BEGIN);
3190        self.bump(); // the opening control word
3191        self.close();
3192
3193        let saved = self.alias_end.replace(closer);
3194        self.open_envs.push(target.to_owned());
3195        // Body routing reads the *target* name through the same curated-data-only
3196        // predicates a spelled-out environment uses, so no behavior flag ever comes
3197        // from the alias itself.
3198        let saved_stmt = self.in_statement_body;
3199        self.in_statement_body = self.ctx.is_statement_environment(target);
3200        if self.ctx.is_verbatim_environment(target) {
3201            self.verbatim_body(target);
3202        } else if self.ctx.is_math_environment(target) {
3203            self.math_environment_body();
3204        } else {
3205            self.parse_block(Block::Environment);
3206        }
3207        self.in_statement_body = saved_stmt;
3208        self.open_envs.pop();
3209        self.alias_end = saved;
3210
3211        // The walk is bounded by `closer`, so it normally stops exactly there. It
3212        // may stop earlier when a nested construct re-gates and closes first — the
3213        // same one-directional guarantee `conditional_closer` documents — in which
3214        // case the closer stays a plain command and this environment simply has no
3215        // `END`, exactly as an unclosed `\begin` does.
3216        if self.pos == closer {
3217            self.open(SyntaxKind::END);
3218            self.bump(); // the closing control word
3219            // A literal closer is a `\end` carrying its name, so it emits the
3220            // same `END > CONTROL_WORD NAME_GROUP` a spelled-out environment
3221            // does (issue #117); an alias closer is the bare word alone.
3222            if self.closer_is_literal(closer) {
3223                self.name_group();
3224            }
3225            self.close();
3226        }
3227        self.close(); // ENVIRONMENT
3228    }
3229
3230    /// `\if… … \else … \or … \fi`, for the closer [`Self::conditional_closer`]
3231    /// located at token index `closer`.
3232    ///
3233    /// The shape is a run of `CONDITIONAL_BRANCH` nodes closed by the `\fi` as
3234    /// the last child, mirroring `ENVIRONMENT > BEGIN … END`. The opener and its
3235    /// *test* ride the first branch rather than a head node of their own: the
3236    /// test's extent is not statically resolvable — `\ifnum\radius>5` scans
3237    /// ⟨number⟩⟨rel⟩⟨number⟩ by TeX's own scanner, `\ifx` takes two tokens, a
3238    /// `\newif`-defined `\if@foo` takes none — and inventing a boundary there
3239    /// would be the macro expansion the parser does not do.
3240    ///
3241    /// Every later branch *starts with* its divider, so a consumer finds the
3242    /// boundaries positionally and never by matching the name `\else`.
3243    fn conditional(&mut self, closer: usize) {
3244        self.open(SyntaxKind::CONDITIONAL);
3245        self.open(SyntaxKind::CONDITIONAL_BRANCH);
3246        self.command(); // the opener, with its usual greedy attachment
3247        loop {
3248            // The walk is bounded by the closer the gate located, so a nested
3249            // construct that consumes more than the token scan predicted can
3250            // never carry the conditional past it. Without the bound an
3251            // overrunning construct strands the cursor past `macrocode_end`, and
3252            // every chunk-bounded scan downstream then slices backwards.
3253            if self.pos >= closer || self.at_block_end(Block::Macrocode) {
3254                break;
3255            }
3256            match self.conditional_flow_at(self.pos) {
3257                Some(conditional::FlowWord::Fi) => break,
3258                Some(conditional::FlowWord::Else | conditional::FlowWord::Or) => {
3259                    self.close(); // CONDITIONAL_BRANCH
3260                    self.open(SyntaxKind::CONDITIONAL_BRANCH);
3261                    self.flow_command();
3262                    continue;
3263                }
3264                None => {}
3265            }
3266            // Leading comment-bind, as in [`Self::parse_block`]: an own-line `%`
3267            // run immediately before a documentable construct attaches *leading*
3268            // into it. A divider is not documentable, so a comment run before one
3269            // floats (the trivia falls through to `element` a token at a time and
3270            // the loop reaches the divider above).
3271            if let Some((comment_start, construct_pos, _)) = self.binding_run(self.pos)
3272                && self.conditional_flow_at(construct_pos).is_none()
3273            {
3274                self.doc_comment_bind(comment_start, construct_pos);
3275                continue;
3276            }
3277            self.element();
3278        }
3279        self.close(); // CONDITIONAL_BRANCH
3280        if self.conditional_flow_at(self.pos) == Some(conditional::FlowWord::Fi) {
3281            self.flow_command();
3282        }
3283        self.close(); // CONDITIONAL
3284    }
3285
3286    /// A conditional divider or closer as a bare `COMMAND`, with **no** argument
3287    /// attachment.
3288    ///
3289    /// Inside a `CONDITIONAL` an `\else`/`\or`/`\fi` is a structural delimiter,
3290    /// parsed like `\end`, so a following group is the next branch's first
3291    /// element rather than the divider's argument. Greedy attachment is the
3292    /// text-pure default precisely because the text carries no arity protocol
3293    /// (`AGENTS.md` decision #8); here position in the construct *is* that
3294    /// protocol, and it is a static fact, so this is a sanctioned deviation on
3295    /// the same footing as the starred-variant fold.
3296    fn flow_command(&mut self) {
3297        self.open(SyntaxKind::COMMAND);
3298        self.bump();
3299        self.close();
3300    }
3301
3302    /// `\begin{name} … \end{name}`, with environment-mismatch recovery.
3303    fn environment(&mut self) {
3304        self.open(SyntaxKind::ENVIRONMENT);
3305
3306        let begin_pos = self.pos;
3307        let begin_start = self.starts[self.pos];
3308        self.open(SyntaxKind::BEGIN);
3309        self.bump(); // \begin
3310        let name = self.name_group();
3311        // Span of the opener `\begin{name}` (before any trailing arguments), so
3312        // an unclosed environment points back at the `\begin`, not at EOF.
3313        let opener = (begin_start, self.starts[self.pos]);
3314        // A frame-lexed `.dtx` macrocode `\begin` (it rides a `DOC_MARGIN`, so
3315        // this never fires on a stray `\begin{macrocode}` in a plain document).
3316        // The frame line holds nothing but the name (`lex_macrocode_frame`), so
3317        // it takes *no* arguments — the next line's `{` is body macro code, not
3318        // an attachment — and the body routes to `macrocode_body` below.
3319        let macrocode_frame = name
3320            .as_deref()
3321            .is_some_and(|n| matches!(n, "macrocode" | "macrocode*"))
3322            && self.frame_margin_before(begin_pos);
3323        // `\begin{tabular}{ll}`, `[options]`, etc. A curated math environment's
3324        // body starts right after its `\begin`, so only a directly-abutting
3325        // `[t]`-style optional attaches; a detached bracket is body content
3326        // (`\begin{align}` + newline + `[\partial_\mu V]_1`, issue #43).
3327        let bracket = if name
3328            .as_deref()
3329            .is_some_and(|n| self.ctx.is_math_environment(n))
3330        {
3331            BracketPolicy::Tight
3332        } else {
3333            BracketPolicy::Greedy
3334        };
3335        if !macrocode_frame {
3336            let builtin_args = name
3337                .as_deref()
3338                .and_then(|name| builtin().environment(name))
3339                .map(|sig| sig.args.as_ref());
3340            self.attach_arguments(bracket, builtin_args);
3341        }
3342        self.close(); // BEGIN
3343
3344        if let Some(open) = name.as_deref() {
3345            self.open_envs.push(open.to_owned());
3346        }
3347        // Statement-body routing is per environment, never inherited: a nested
3348        // non-statement environment (an `itemize` in a `\node` label) parses its
3349        // body with the flag off, and a nested `scope` turns it back on.
3350        let saved_stmt = self.in_statement_body;
3351        self.in_statement_body = name
3352            .as_deref()
3353            .is_some_and(|n| self.ctx.is_statement_environment(n));
3354        if name
3355            .as_deref()
3356            .is_some_and(|n| self.ctx.is_verbatim_environment(n))
3357        {
3358            self.verbatim_body(name.as_deref().expect("verbatim name"));
3359        } else if name
3360            .as_deref()
3361            .is_some_and(|n| self.ctx.is_math_environment(n))
3362        {
3363            self.math_environment_body();
3364        } else if macrocode_frame {
3365            // A frame-lexed macrocode body is macro code, not document
3366            // structure (see `macrocode_frame` above).
3367            self.macrocode_body(name.as_deref().expect("macrocode name"));
3368        } else {
3369            self.parse_block(Block::Environment);
3370        }
3371        self.in_statement_body = saved_stmt;
3372        if name.is_some() {
3373            self.open_envs.pop();
3374        }
3375        self.finish_environment(&name, opener);
3376    }
3377
3378    /// True if the token at `pos` sits on a `.dtx` frame line: walking back over
3379    /// inline whitespace, the preceding token is a `DOC_MARGIN`. Margins never
3380    /// occur *inside* a macrocode body (code lines own their `%`), so this
3381    /// fingerprint distinguishes the frame `\begin`/`\end{macrocode}` from any
3382    /// look-alike in the code. Pinned by
3383    /// `macrocode_frame_margins_sit_where_the_formatter_expects` (`tests/dtx.rs`).
3384    fn frame_margin_before(&self, pos: usize) -> bool {
3385        let mut i = pos;
3386        while i > 0 {
3387            i -= 1;
3388            match self.tokens[i].kind {
3389                SyntaxKind::WHITESPACE => continue,
3390                SyntaxKind::DOC_MARGIN => return true,
3391                _ => return false,
3392            }
3393        }
3394        false
3395    }
3396
3397    /// The body of a `.dtx` `macrocode`/`macrocode*` environment: macro code
3398    /// whose one true terminator is the frame line (`%    \end{macrocode}`),
3399    /// a line-oriented docstrip fact. TeX places no balance requirements on the
3400    /// chunk — a definition regularly opens a brace in one chunk and closes it
3401    /// several chunks later, and kernel code uses the `\end` primitive — so,
3402    /// like the definition bodies of decision #1 (issues #45/#55):
3403    /// - `\begin`/`\end` inside parse as plain commands ([`Self::in_def_body`]),
3404    /// - chunk-unmatched braces are plain tokens with no diagnostics
3405    ///   ([`Self::plain_braces`]; matched pairs still parse as `GROUP`s),
3406    /// - a `[` attaches as an optional only when it closes inside the chunk.
3407    ///
3408    /// The terminator is pre-scanned here (the first `\end` on a margin whose
3409    /// name matches — [`Self::frame_margin_before`]) and parsing stops
3410    /// positionally at it ([`Block::Macrocode`]); [`Self::finish_environment`]
3411    /// then consumes and name-checks it as usual. Nesting is impossible (the
3412    /// lexer never opens a frame inside a body), but state is saved/restored
3413    /// anyway so a malformed tree cannot leak it.
3414    fn macrocode_body(&mut self, name: &str) {
3415        let mut end = self.tokens.len();
3416        for i in self.pos..self.tokens.len() {
3417            if self.tokens[i].kind == SyntaxKind::CONTROL_WORD
3418                && self.tokens[i].text == END_CMD
3419                && self.frame_margin_before(i)
3420                && peek_end_name(self.tokens, i).as_deref() == Some(name)
3421            {
3422                end = i;
3423                break;
3424            }
3425        }
3426
3427        let saved_plain = std::mem::take(&mut self.plain_braces);
3428        let saved_end = self.macrocode_end;
3429        let saved_def = self.in_def_body;
3430
3431        let mut open_stack = Vec::new();
3432        for i in self.pos..end {
3433            match self.tokens[i].kind {
3434                SyntaxKind::L_BRACE => open_stack.push(i),
3435                SyntaxKind::R_BRACE if open_stack.pop().is_none() => {
3436                    self.plain_braces.insert(i);
3437                }
3438                _ => {}
3439            }
3440        }
3441        self.plain_braces.extend(open_stack);
3442        self.plain_braces_version += 1;
3443        self.macrocode_end = Some(end);
3444        self.in_def_body = true;
3445
3446        self.parse_block(Block::Macrocode);
3447
3448        self.plain_braces = saved_plain;
3449        self.plain_braces_version += 1;
3450        self.macrocode_end = saved_end;
3451        self.in_def_body = saved_def;
3452    }
3453
3454    /// Consume the matching `\end`, or recover. `parse_block` / `verbatim_body`
3455    /// leave the cursor at a `\end` or at EOF.
3456    fn finish_environment(&mut self, name: &Option<String>, opener: (usize, usize)) {
3457        match self.kind() {
3458            None => {
3459                self.error_at(
3460                    opener,
3461                    format!("unclosed environment `{}`", name.as_deref().unwrap_or("")),
3462                );
3463            }
3464            // The cursor is at a closer alias for this very environment: consume
3465            // the bare control word as the `END` (issue #117). Tested before the
3466            // `\end` arm because `peek_end_name` would read the alias's own
3467            // following group (`\eeq{…}`) as an environment name and report a
3468            // mismatch against it.
3469            Some(_)
3470                if self.alias_closers.get(&self.pos).is_some_and(|target| {
3471                    !self.in_macro_code(self.pos) && name.as_deref() == Some(target.as_str())
3472                }) =>
3473            {
3474                self.open(SyntaxKind::END);
3475                self.bump();
3476                self.close();
3477            }
3478            // The cursor is at a `\end` (the only other non-EOF stop condition).
3479            Some(_) => {
3480                let end_name = peek_end_name(self.tokens, self.pos);
3481                if name.is_none() || name.as_deref() == end_name.as_deref() {
3482                    // Matching \end: consume it as our END.
3483                    self.open(SyntaxKind::END);
3484                    self.bump(); // \end
3485                    self.name_group();
3486                    self.close();
3487                } else {
3488                    // Mismatched \end: it belongs to an enclosing environment.
3489                    // Close this one with a diagnostic and leave the \end for
3490                    // the caller (this unwinds the stack until some level
3491                    // matches, or it becomes a stray \end at the root).
3492                    self.error_at(
3493                        opener,
3494                        format!(
3495                            "unclosed environment `{}` (found `\\end{{{}}}`)",
3496                            name.as_deref().unwrap_or(""),
3497                            end_name.as_deref().unwrap_or("")
3498                        ),
3499                    );
3500                }
3501            }
3502        }
3503        self.close(); // ENVIRONMENT
3504    }
3505
3506    /// The body of a named math environment (`equation`, `align`, `gather`, …): its
3507    /// atoms wrapped in a `MATH` node and parsed in math mode, exactly as `\[…\]`
3508    /// (see [`Self::delim_math`]) — so `^`/`_` build `SCRIPTED` nodes, the operator
3509    /// split fires, and `\left…\right` pair. Routed here for environments the
3510    /// signature data flags `math` ([`ParseCtx::is_math_environment`]).
3511    ///
3512    /// The terminator is the matching `\end` (or EOF), read via [`Self::at_block_end`]
3513    /// just like [`Self::parse_block`]; [`Self::finish_environment`] then consumes and
3514    /// name-checks it. Unlike `$`-math (where a `\end` is an *unclosed*-recovery
3515    /// anchor), `\end` is the normal, expected terminator here. A blank line inside the
3516    /// body stays trivia within the `MATH` node — no paragraph split — so losslessness
3517    /// holds. Progress is guaranteed: [`Self::math_element`] bumps trivia or descends
3518    /// into [`Self::math_scripted`], whose atom parser always consumes a token.
3519    fn math_environment_body(&mut self) {
3520        self.open(SyntaxKind::MATH);
3521        self.math_dollar.push(false);
3522        while !self.at_block_end(Block::Environment) {
3523            self.math_element();
3524        }
3525        self.math_dollar.pop();
3526        self.close(); // MATH
3527    }
3528
3529    /// The raw body of a verbatim-like environment: consume tokens unstructured
3530    /// until the matching `\end{name}`. The lexer has already collapsed the body
3531    /// into a single `VERBATIM_BODY` token; this loop also serves as a fallback.
3532    fn verbatim_body(&mut self, name: &str) {
3533        loop {
3534            match self.kind() {
3535                None => break,
3536                Some(SyntaxKind::CONTROL_WORD)
3537                    if self.at_command(END_CMD)
3538                        && peek_end_name(self.tokens, self.pos).as_deref() == Some(name) =>
3539                {
3540                    break;
3541                }
3542                _ => self.bump(),
3543            }
3544        }
3545    }
3546
3547    /// A `\end` with no matching open environment at this level.
3548    fn stray_end(&mut self) {
3549        self.error("`\\end` without matching `\\begin`");
3550        self.open(SyntaxKind::END);
3551        self.bump(); // \end
3552        self.name_group();
3553        self.close();
3554    }
3555
3556    /// The `{name}` group following `\begin` / `\end`. Returns the trimmed name.
3557    fn name_group(&mut self) -> Option<String> {
3558        self.skip_trivia();
3559        if self.kind() != Some(SyntaxKind::L_BRACE) {
3560            self.error("expected `{` for environment name");
3561            return None;
3562        }
3563        self.open(SyntaxKind::NAME_GROUP);
3564        self.bump(); // {
3565        let mut name = String::new();
3566        loop {
3567            match self.kind() {
3568                None => {
3569                    self.error("unclosed environment name");
3570                    break;
3571                }
3572                Some(SyntaxKind::R_BRACE) => {
3573                    self.bump();
3574                    break;
3575                }
3576                _ => {
3577                    name.push_str(self.text());
3578                    self.bump();
3579                }
3580            }
3581        }
3582        self.close();
3583        Some(name.trim().to_owned())
3584    }
3585}
3586
3587/// Read the environment name from a `\begin{…}` at `begin_pos` without consuming.
3588/// Identical in shape to [`peek_end_name`] (skip the control word and trivia, then
3589/// read the `{name}` group); named separately for call-site clarity.
3590fn peek_begin_name(tokens: &[Token], begin_pos: usize) -> Option<Cow<'_, str>> {
3591    peek_end_name(tokens, begin_pos)
3592}
3593
3594/// Read the environment name from a `\end{…}` at `end_pos` without consuming.
3595///
3596/// Borrows the token's own text for the single-token name every ordinary
3597/// environment has, and only allocates for one spelled across several tokens
3598/// (`\end{align *}`, a name holding a digit or a `-`). Three of the callers are
3599/// forward scans that ask once per token and only ever compare the result, so
3600/// the common case must not allocate.
3601fn peek_end_name(tokens: &[Token], end_pos: usize) -> Option<Cow<'_, str>> {
3602    let mut i = end_pos + 1; // past the \end control word
3603    while tokens.get(i).is_some_and(|t| Parser::is_trivia(t.kind)) {
3604        i += 1;
3605    }
3606    if tokens.get(i).map(|t| t.kind) != Some(SyntaxKind::L_BRACE) {
3607        return None;
3608    }
3609    i += 1;
3610    let start = i;
3611    while tokens.get(i).is_some_and(|t| t.kind != SyntaxKind::R_BRACE) {
3612        i += 1;
3613    }
3614    Some(match &tokens[start..i] {
3615        [] => Cow::Borrowed(""),
3616        [t] => Cow::Borrowed(t.text.trim()),
3617        many => {
3618            let mut name = String::new();
3619            for t in many {
3620                name.push_str(&t.text);
3621            }
3622            Cow::Owned(name.trim().to_owned())
3623        }
3624    })
3625}
3626
3627#[cfg(test)]
3628mod tests {
3629    use super::*;
3630    use crate::parser::lexer::lex;
3631
3632    #[test]
3633    fn step_guard_trips_when_wedged() {
3634        let tokens = lex("x");
3635        let ctx = ParseCtx::default();
3636        let p = Parser::new(&tokens, &ctx);
3637        p.last_step_pos.set(p.pos);
3638        p.steps.set(PARSER_STEP_LIMIT - 1);
3639        p.step(); // reaches the ceiling exactly — still allowed
3640        let wedged = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| p.step()));
3641        assert!(wedged.is_err(), "the guard must abort a non-advancing loop");
3642    }
3643
3644    #[test]
3645    fn step_budget_resets_on_cursor_progress() {
3646        let tokens = lex("xx");
3647        let ctx = ParseCtx::default();
3648        let mut p = Parser::new(&tokens, &ctx);
3649        p.last_step_pos.set(p.pos);
3650        p.steps.set(PARSER_STEP_LIMIT - 1);
3651        p.pos += 1;
3652        p.step();
3653        assert_eq!(p.steps.get(), 1, "progress should reset the peek budget");
3654    }
3655
3656    fn scan_work(input: &str) -> usize {
3657        let tokens = lex(input);
3658        let ctx = ParseCtx::default();
3659        let mut p = Parser::new(&tokens, &ctx);
3660        p.document();
3661        p.scan_work.get()
3662    }
3663
3664    #[track_caller]
3665    fn assert_scan_work_linear(small: &str, doubled: &str) {
3666        let (w1, w2) = (scan_work(small), scan_work(doubled));
3667        assert!(
3668            w2 < 3 * w1 + 64,
3669            "gate-scan work grew superlinearly: {w1} -> {w2}"
3670        );
3671    }
3672
3673    #[test]
3674    fn gate_scans_stay_linear_without_closers() {
3675        let shape = "\\ifabc x\n";
3676        assert_scan_work_linear(&shape.repeat(200), &shape.repeat(400));
3677        let shape = "\\cmd[x\n";
3678        assert_scan_work_linear(&shape.repeat(200), &shape.repeat(400));
3679        let shape = "\\[ x\n";
3680        assert_scan_work_linear(&shape.repeat(200), &shape.repeat(400));
3681    }
3682
3683    #[test]
3684    fn expl3_arity_scan_stays_linear() {
3685        let body = |n: usize| {
3686            format!(
3687                "\\ExplSyntaxOn\n{}",
3688                "\\tl_set:Nn \\l_a { x y z }\n".repeat(n)
3689            )
3690        };
3691        assert_scan_work_linear(&body(200), &body(400));
3692        let body = |n: usize| {
3693            format!(
3694                "\\ExplSyntaxOn\n{}",
3695                "\\prop_get:NnNTF \\p { k } \\l { t } x\n".repeat(n)
3696            )
3697        };
3698        assert_scan_work_linear(&body(200), &body(400));
3699    }
3700
3701    #[test]
3702    fn expl3_arity_nested_scans_stay_linear() {
3703        let body = |n: usize| {
3704            format!(
3705                "\\ExplSyntaxOn\n{}x{}\n",
3706                "\\use:n { ".repeat(n),
3707                " }".repeat(n)
3708            )
3709        };
3710        assert_scan_work_linear(&body(100), &body(200));
3711        let body = |n: usize| {
3712            format!(
3713                "\\ExplSyntaxOn\n\\prop_get:NnNTF \\p {{ k }} \\l {}x{} y\n",
3714                "\\use:n { ".repeat(n),
3715                " }".repeat(n)
3716            )
3717        };
3718        assert_scan_work_linear(&body(100), &body(200));
3719    }
3720
3721    #[test]
3722    fn conditional_batch_keeps_shared_frame_openers_linear() {
3723        let body = |n: usize| format!("{}\\fi\n", "\\ifabc x\n".repeat(n));
3724        assert_scan_work_linear(&body(200), &body(400));
3725    }
3726
3727    #[test]
3728    fn alias_batch_keeps_shared_frame_openers_linear() {
3729        let scan_work = |input: &str| {
3730            let tokens = lex(input);
3731            let mut ctx = ParseCtx::default();
3732            ctx.insert_begin_alias(SmolStr::new("bc"), SmolStr::new("center"));
3733            ctx.insert_end_alias(SmolStr::new("ec"), SmolStr::new("center"));
3734            let mut p = Parser::new(&tokens, &ctx);
3735            p.document();
3736            p.scan_work.get()
3737        };
3738        let body = |n: usize| format!("{}\\ec\n", "\\bc x\n".repeat(n));
3739        let (w1, w2) = (scan_work(&body(200)), scan_work(&body(400)));
3740        assert!(
3741            w2 < 3 * w1 + 64,
3742            "gate-scan work grew superlinearly: {w1} -> {w2}"
3743        );
3744    }
3745
3746    #[test]
3747    fn env_batch_keeps_shared_frame_openers_linear() {
3748        let body = |n: usize| format!("{{\n{}", "\\begin{itemize}\n".repeat(n));
3749        assert_scan_work_linear(&body(200), &body(400));
3750    }
3751
3752    #[test]
3753    fn left_right_batch_keeps_shared_frame_openers_linear() {
3754        let body = |n: usize| format!("$ {}\\right)$\n", "\\left( x ".repeat(n));
3755        assert_scan_work_linear(&body(200), &body(400));
3756    }
3757
3758    #[test]
3759    fn bracket_batch_keeps_shared_frame_openers_linear() {
3760        let body = |n: usize| format!("{}]\n", "\\cmd[x\n".repeat(n));
3761        assert_scan_work_linear(&body(200), &body(400));
3762        let body = |n: usize| format!("$ {}]$\n", "\\cmd[x ".repeat(n));
3763        assert_scan_work_linear(&body(200), &body(400));
3764    }
3765
3766    #[test]
3767    fn math_gate_scans_stay_linear_without_closers() {
3768        let body = |n: usize| format!("$ {}$", "\\cmd[x ".repeat(n));
3769        assert_scan_work_linear(&body(200), &body(400));
3770        let body = |n: usize| format!("\\[\n{}\\]\n", "\\left( x\n".repeat(n));
3771        assert_scan_work_linear(&body(200), &body(400));
3772    }
3773
3774    #[test]
3775    fn math_batch_stays_linear_with_one_closer_at_eof() {
3776        let body = |n: usize| format!("{}\\]\n", "\\[ x\n".repeat(n));
3777        assert_scan_work_linear(&body(200), &body(400));
3778        let body = |n: usize| format!("{}\\)\n", "\\( x\n".repeat(n));
3779        assert_scan_work_linear(&body(200), &body(400));
3780        let body = |n: usize| format!("{}$\n", "$ x\n".repeat(n));
3781        assert_scan_work_linear(&body(200), &body(400));
3782        let body = |n: usize| format!("{}$$\n", "$$ x\n".repeat(n));
3783        assert_scan_work_linear(&body(200), &body(400));
3784    }
3785}