Skip to main content

badness_parser/parser/
grammar.rs

1//! The Phase 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
16use crate::parser::core::SyntaxError;
17use crate::parser::events::Event;
18use crate::parser::lexer::{
19    ExplToggle, Token, VerbCtx, expl_toggle, is_block_environment, is_math_environment,
20};
21use crate::syntax::SyntaxKind;
22
23const BEGIN_CMD: &str = "\\begin";
24const END_CMD: &str = "\\end";
25const LEFT_CMD: &str = "\\left";
26const RIGHT_CMD: &str = "\\right";
27
28/// How [`Parser::attach_arguments`] treats a trailing `[…]` (issue #43).
29/// `[`/`]` are not real grouping in TeX, so bracket attachment is a heuristic;
30/// the policy is the caller's shape knowledge about the construct being
31/// attached to. The in-math gates apply on top of it — see
32/// [`Parser::attach_arguments`].
33#[derive(Clone, Copy, PartialEq, Eq)]
34enum BracketPolicy {
35    /// Attach across intervening trivia (decision #8's default).
36    Greedy,
37    /// Attach only a directly-abutting `[` (a curated math environment's
38    /// `\begin`: its math body starts right after, so a detached `[` is
39    /// content).
40    Tight,
41    /// Never attach one (the delimiter-size commands: their `[` is the
42    /// delimiter being sized).
43    Forbid,
44}
45
46/// The delimiter-size commands (`\big`…`\Bigg` and their `l`/`m`/`r` variants).
47/// A closed, curated set of TeX/amsmath primitives whose sole "argument" is the
48/// delimiter token that follows (`\Big[`, `\bigl(`, `\Bigg|`), so a `[…]` after
49/// one is never an optional argument (issue #43). The static-fact posture
50/// mirrors `\left`/`\right` (`AGENTS.md`, decision #1).
51fn is_big_delimiter_command(text: &str) -> bool {
52    let Some(name) = text.strip_prefix('\\') else {
53        return false;
54    };
55    ["bigg", "Bigg", "big", "Big"].iter().any(|s| {
56        name.strip_prefix(s)
57            .is_some_and(|rest| matches!(rest, "" | "l" | "m" | "r"))
58    })
59}
60
61/// The *definition-body* commands: commands whose trailing brace groups are
62/// macro-code bodies, where TeX does not require `\begin`/`\end` to balance
63/// within an individual group. Three families:
64///
65/// - The environment-definition commands (the LaTeX2e `\newenvironment` family
66///   and the xparse `\NewDocumentEnvironment` family): the `\begin` lives in
67///   the begin-code and its matching `\end` in the end-code by design
68///   (`\newenvironment{wrap}{\begin{center}}{\end{center}}`, issue #45).
69/// - The command-definition commands (the LaTeX2e `\newcommand` family and the
70///   xparse `\NewDocumentCommand` family): a body may open or close an
71///   environment for a matching hook to balance
72///   (`\newcommand{\@@newpage}{\end{page}\begin{page}}`, issue #55).
73/// - The LaTeX2e document/package hooks (`\AtBeginDocument` family): the code
74///   argument runs at a different point in the document, so it balances
75///   against that context, not within its own group
76///   (`\AtBeginDocument{\begin{page}}` … `\AtEndDocument{\end{page}}`).
77///
78/// Inside those bodies `\begin`/`\end` parse as plain commands (see
79/// [`Parser::in_def_body`]). A closed, curated set read as a static fact — the
80/// bodies are never executed, mirroring [`is_big_delimiter_command`].
81fn is_definition_body_command(text: &str) -> bool {
82    matches!(
83        text,
84        "\\newenvironment"
85            | "\\renewenvironment"
86            | "\\provideenvironment"
87            | "\\NewDocumentEnvironment"
88            | "\\RenewDocumentEnvironment"
89            | "\\ProvideDocumentEnvironment"
90            | "\\DeclareDocumentEnvironment"
91            | "\\newcommand"
92            | "\\renewcommand"
93            | "\\providecommand"
94            | "\\DeclareRobustCommand"
95            | "\\NewDocumentCommand"
96            | "\\RenewDocumentCommand"
97            | "\\ProvideDocumentCommand"
98            | "\\DeclareDocumentCommand"
99            | "\\AtBeginDocument"
100            | "\\AtEndDocument"
101            | "\\AtEndOfClass"
102            | "\\AtEndOfPackage"
103            | "\\AddToHook"
104    )
105}
106
107/// The TeX `\def`-family primitives, whose next token is always the control
108/// sequence being (re)defined. A control-*symbol* name would otherwise be
109/// misparsed as live syntax — `\def\[{…}`/`\def\]{…}` (a document class
110/// restyling display math, stacks-project issue #65) reads as a math opener,
111/// `\def\\{…}` as a line break — so [`Parser::command`] consumes it as a plain
112/// token inside the `\def`'s node. A control-*word* name already parses
113/// benignly as a generic command and keeps its current shape. A closed,
114/// curated set read as a static fact, mirroring [`is_definition_body_command`];
115/// the definition is never executed.
116///
117/// Also read by the formatter's expl3 region gate (in the `badness-formatter` crate): a toggle
118/// spelling immediately preceded by one of these is a *definee*, never an executed
119/// catcode switch, so it must not open a formatter-owned region.
120pub fn is_def_prefix_command(text: &str) -> bool {
121    matches!(text, "\\def" | "\\gdef" | "\\edef" | "\\xdef")
122}
123
124/// Maximum number of consecutive cursor peeks with **no** token consumed before
125/// the parser aborts as stuck. Modeled on rust-analyzer's `PARSER_STEP_LIMIT`
126/// (`crates/parser/src/parser.rs`), a catch-all against a non-advancing loop that
127/// holds *independent of grammar correctness*. The counter resets on every cursor
128/// advance (see [`Parser::step`]), so in normal parsing only O(1) peeks accrue
129/// between two consumed tokens; this ceiling is astronomically above any real
130/// document and can only be reached by a genuine infinite loop. Unlike the
131/// module's structural "`pos` only advances through `bump`" argument, this holds
132/// even for malformed or adversarial input (fuzzing, a corrupt corpus file).
133const PARSER_STEP_LIMIT: u32 = 15_000_000;
134
135/// A content region that groups its children into `PARAGRAPH` nodes separated
136/// by blank lines. Differs only in how the region terminates.
137#[derive(Clone, Copy, PartialEq, Eq)]
138enum Block {
139    /// The whole document; ends at EOF.
140    Document,
141    /// An environment body; ends at the next `\end` (any name — the caller
142    /// checks the name and decides whether to consume it).
143    Environment,
144    /// A `.dtx` `macrocode` body: macro code, so a bare `\end` in the code is a
145    /// plain command, and the block ends *positionally* at the pre-scanned frame
146    /// terminator ([`Parser::macrocode_end`]), never at an arbitrary `\end`.
147    Macrocode,
148}
149
150/// How the shared trivia scanner ([`Parser::scan_trivia`]) treats a `%` comment.
151#[derive(Clone, Copy, PartialEq, Eq)]
152enum CommentMode {
153    /// A comment is content that occupies its own line: it resets the newline
154    /// run (without undoing a blank line already seen) and the scan continues
155    /// past it. Used everywhere paragraph structure and leading-comment binds are
156    /// decided.
157    Skip,
158    /// A comment stops the scan and is reported as the next meaningful token.
159    /// Used by [`Parser::at_script`], where a comment ends the line so a `^`/`_`
160    /// script never binds across it.
161    Stop,
162}
163
164/// The result of scanning the contiguous trivia run at a position: everything the
165/// blank-line and comment-bind rules (AGENTS.md #9) need to decide, computed once.
166struct TriviaScan {
167    /// Index of the next meaningful (non-skipped) token, or `tokens.len()` at EOF.
168    next: usize,
169    /// Kind of the token at [`Self::next`], or `None` at EOF.
170    next_kind: Option<SyntaxKind>,
171    /// The run before `next` contains a blank line (≥2 `NEWLINE`s: the `\par`
172    /// boundary).
173    saw_blank_line: bool,
174    /// As [`Self::saw_blank_line`], but a `.dtx` docstrip guard line counts as
175    /// content rather than blank space. Docstrip *deletes* a guard-only line
176    /// when it strips the file, so `%<*dtx>` between two lines does not part
177    /// them — read by the shape gates that only ask whether their construct's
178    /// source ran out mid-shape (issue #71).
179    saw_blank_line_outside_guards: bool,
180    /// Start index of the leading own-line `%` comment run immediately preceding
181    /// `next` — the maximal blank-line-free suffix, the start of a
182    /// leading-comment bind. `None` if that suffix has no own-line comment.
183    /// Only populated in [`CommentMode::Skip`].
184    comment_start: Option<usize>,
185}
186
187/// Parse a token stream into parser events and a list of syntax errors.
188pub(crate) fn parse(tokens: &[Token], ctx: &VerbCtx) -> (Vec<Event>, Vec<SyntaxError>) {
189    let mut p = Parser::new(tokens, ctx);
190    p.document();
191    debug_assert_balanced(&p.events);
192    (p.events, p.errors)
193}
194
195/// Debug-only structural tripwire: the event stream must be balanced — every
196/// `Start` matched by a later `Finish`, no `Finish` before its `Start`, and the
197/// document node closed exactly once. This is the cheap analog of
198/// rust-analyzer's per-`Marker` `DropBomb`: a grammar edit that leaks an
199/// [`Parser::open`] without a [`Parser::close`] (or a `precede`-style
200/// `events.insert` that splices an unbalanced `Start`) is caught right here,
201/// counting *all* start/finish events regardless of how they were emitted,
202/// before [`super::tree_builder`] feeds rowan's `GreenNodeBuilder` and fails with
203/// a far more opaque `finish_node` panic. Compiled out of release builds.
204fn debug_assert_balanced(events: &[Event]) {
205    if !cfg!(debug_assertions) {
206        return;
207    }
208    let mut depth: i32 = 0;
209    for ev in events {
210        match ev {
211            Event::Start(_) => depth += 1,
212            Event::Finish => {
213                depth -= 1;
214                debug_assert!(depth >= 0, "parser emitted a Finish with no open node");
215            }
216            Event::Tok(_) | Event::SubTok { .. } => {}
217        }
218    }
219    debug_assert_eq!(
220        depth, 0,
221        "parser left {depth} node(s) unclosed at end of parse"
222    );
223}
224
225struct Parser<'t> {
226    tokens: &'t [Token],
227    /// User-defined verbatim constructs, consulted to route a verbatim environment to
228    /// its raw-body branch (its body is already one `VERBATIM_BODY` token from the
229    /// lexer; the grammar must not try to parse it structurally).
230    ctx: &'t VerbCtx,
231    /// `starts[i]` is the byte offset of token `i`; `starts[len]` is the total
232    /// length. Used to give syntax errors byte ranges.
233    starts: Vec<usize>,
234    pos: usize,
235    events: Vec<Event>,
236    errors: Vec<SyntaxError>,
237    /// Consecutive-peek budget for the stuck-loop guard ([`Self::step`]).
238    /// `Cell` because the lookahead primitives that tick it are `&self`.
239    steps: std::cell::Cell<u32>,
240    /// The cursor position at the last [`Self::step`] tick; the budget resets
241    /// whenever `pos` has advanced past it (i.e. real progress was made).
242    last_step_pos: std::cell::Cell<usize>,
243    /// Depth of lexically enclosing math bodies (`$…$`, `\[…\]`, `\(…\)`, math
244    /// environments). Unlike the `math` routing flags threaded through the
245    /// grammar, this *persists* into the text-mode body of an unknown
246    /// environment nested inside math (`\[ … \begin{myaligned} … \]`): the
247    /// grammar can't verify such a body is math, but the enclosing delimiters
248    /// are a static lexical fact, and optional-argument attachment uses it to
249    /// treat a spaced `[` as content (see [`Self::attach_arguments`]).
250    math_depth: usize,
251    /// Per-level flavor of the enclosing math bodies tracked by `math_depth`:
252    /// `true` for a `$…$`/`$$…$$` (dollar-delimited) level, `false` for
253    /// `\[…\]`/`\(…\)` and math environments. Pushed and popped in lockstep with
254    /// `math_depth`, so its last entry is the *innermost* enclosing math's
255    /// flavor. [`Self::bracket_closes_before_math_end`] reads it: inside dollar
256    /// math a `$` is the closer (a boundary), whereas inside `\[…\]` a `$` opens
257    /// a genuine nested inline region (`\inferrule*[right=$\Pi$-eq]`), so the two
258    /// must be scanned differently.
259    math_dollar: Vec<bool>,
260    /// True while parsing the attached arguments of a definition-body command
261    /// ([`is_definition_body_command`], issues #45/#55). Those groups are
262    /// macro-code definition bodies that need not self-balance
263    /// `\begin`/`\end`, so while set, `\begin`/`\end` parse as plain commands
264    /// ([`Self::element`], [`Self::math_atom`]) and stop being bail anchors for
265    /// an optional argument ([`Self::optional`]). Saved and restored around
266    /// [`Self::attach_arguments`] in [`Self::command`], so it covers the whole
267    /// definition subtree (nested groups included) and nothing after it.
268    in_def_body: bool,
269    /// Number of brace groups currently open around the cursor
270    /// ([`Self::group`] / [`Self::math_group`]). A `\end` reached inside one
271    /// has its `\begin` outside it, so it is macro code rather than a stray
272    /// (`\StopEventually{\end{document}}`, issue #71) — the `\end`-side twin of
273    /// [`Self::environment_escapes_group`].
274    group_depth: usize,
275    /// Environment names whose `\begin` the brace-group gate demoted to a plain
276    /// command ([`Self::environment_escapes_group`]). Their `\end` is then an
277    /// orphan by construction — the gate removed its partner, not the author — so
278    /// [`Self::end_orphans_a_demoted_begin`] demotes it in the same way instead of
279    /// letting it unwind (and falsely un-close) every enclosing environment.
280    demoted_envs: std::collections::HashSet<String>,
281    /// Names of the environments open around the cursor, outermost first. Read
282    /// only by [`Self::end_orphans_a_demoted_begin`], to tell an `\end` that
283    /// really does close something from one whose `\begin` was demoted.
284    open_envs: Vec<String>,
285    /// Token index of the `{` opening each currently-open brace group, innermost
286    /// last (the positional twin of [`Self::group_depth`]). Read by
287    /// [`Self::environment_escapes_group`] to tell a group the `.dtx`
288    /// *documentation* layer opened itself from one stranded by the code layer.
289    group_opens: Vec<usize>,
290    /// Inside a `.dtx` `macrocode` body: the token index of the terminating
291    /// frame `\end` (or `tokens.len()` when the frame is missing), pre-scanned
292    /// by [`Self::macrocode_body`]. `None` outside a macrocode body. The frame
293    /// is the *only* terminator of the chunk (docstrip is line-oriented), so
294    /// [`Self::at_block_end`] and the bracket/optional guards read it to keep
295    /// any construct from consuming past the frame.
296    macrocode_end: Option<usize>,
297    /// Brace tokens inside the current `macrocode` body with no match within
298    /// the chunk. A `macrocode` chunk is macro code: a definition regularly
299    /// opens a `{` in one chunk and closes it in a later one (`\def\foo#1{%` …
300    /// frame … `bar}`), so an unmatched brace is an ordinary token — no
301    /// `GROUP`, no unclosed/unmatched diagnostic. Matched pairs still parse as
302    /// groups. Computed per chunk by [`Self::macrocode_body`].
303    plain_braces: std::collections::HashSet<usize>,
304    /// expl3 catcode-mode toggle tokens, ascending: `(token index, state after
305    /// the toggle)`. The same fixed toggle set the lexer flips
306    /// ([`expl_toggle`]), pre-scanned once so [`Self::in_expl_region`] is a
307    /// binary search. An expl3 region is *code* — token lists pass
308    /// `\begin`/`\end` around as data (`\tl_set:Nn { \begin{longtable} … }`,
309    /// issue #60) — so inside one, `\begin`/`\end` parse as plain commands
310    /// exactly as in a definition body ([`Self::plain_env`]). `.dtx` doc-margin
311    /// lines are exempt: a region regularly spans macrocode chunks, and the
312    /// doc-layer markup between them (`\begin{macro}`, the frames) must keep
313    /// pairing.
314    expl_toggles: Vec<(usize, bool)>,
315}
316
317impl<'t> Parser<'t> {
318    fn new(tokens: &'t [Token], ctx: &'t VerbCtx) -> Self {
319        let mut starts = Vec::with_capacity(tokens.len() + 1);
320        let mut off = 0;
321        let mut expl_toggles = Vec::new();
322        for (i, t) in tokens.iter().enumerate() {
323            starts.push(off);
324            off += t.text.len();
325            if t.kind == SyntaxKind::CONTROL_WORD
326                && let Some(toggle) = expl_toggle(&t.text)
327            {
328                expl_toggles.push((i, toggle == ExplToggle::On));
329            }
330        }
331        starts.push(off);
332        Self {
333            tokens,
334            ctx,
335            starts,
336            pos: 0,
337            events: Vec::new(),
338            steps: std::cell::Cell::new(0),
339            last_step_pos: std::cell::Cell::new(0),
340            errors: Vec::new(),
341            math_depth: 0,
342            math_dollar: Vec::new(),
343            in_def_body: false,
344            group_depth: 0,
345            demoted_envs: std::collections::HashSet::new(),
346            open_envs: Vec::new(),
347            group_opens: Vec::new(),
348            macrocode_end: None,
349            plain_braces: std::collections::HashSet::new(),
350            expl_toggles,
351        }
352    }
353
354    /// True when token `idx` sits inside an expl3 region (after an
355    /// `\ExplSyntaxOn`/`\ProvidesExpl*` with no intervening `\ExplSyntaxOff`).
356    /// The toggle token itself is outside its own region.
357    fn in_expl_region(&self, idx: usize) -> bool {
358        let n = self.expl_toggles.partition_point(|&(i, _)| i < idx);
359        n > 0 && self.expl_toggles[n - 1].1
360    }
361
362    /// True when token `idx` lies on a `.dtx` doc-margin line (a `DOC_MARGIN`
363    /// opens its physical line). Walks back to the preceding `NEWLINE`; doc
364    /// lines are short, and the check runs only at `\begin`/`\end` tokens.
365    fn on_doc_margin_line(&self, idx: usize) -> bool {
366        self.tokens[..idx]
367            .iter()
368            .rev()
369            .take_while(|t| t.kind != SyntaxKind::NEWLINE)
370            .any(|t| t.kind == SyntaxKind::DOC_MARGIN)
371    }
372
373    /// Whether token `idx` is covered by the `.dtx` doc-margin exemption from the
374    /// brace-group gates ([`Self::environment_escapes_group`] and its `\end`-side
375    /// mirror): it sits on a documentation line *and* every group open around it
376    /// was opened by the code layer.
377    ///
378    /// The exemption exists for braces the *code* layer stranded — a
379    /// `\iffalse{\fi` editor-balance hack, a `` \char`{ `` constant, a
380    /// catcode-swapped region — which hold `group_depth` above zero for the rest
381    /// of the file and would otherwise unnest the whole doc layer behind them. A
382    /// group the documentation layer opened itself is not stranded: it is right
383    /// there on a doc line, so a `\begin`/`\end` inside it really is inside it
384    /// and the gates apply as they do in code (theorem.dtx's
385    /// `% \def\deflist#1{\begin{list}…}` / `% \def\enddeflist{\end{list}}`
386    /// split definition, issue #71).
387    fn doc_margin_exempt(&self, idx: usize) -> bool {
388        self.on_doc_margin_line(idx)
389            && !self
390                .group_opens
391                .last()
392                .is_some_and(|&brace| self.on_doc_margin_line(brace))
393    }
394
395    /// Whether the `\end` at `idx` is the orphaned partner of a `\begin` the
396    /// brace-group gate demoted: its name was gated somewhere earlier
397    /// ([`Self::demoted_envs`]) and no environment of that name is open here.
398    ///
399    /// The gate turns a `\begin` into a plain command, and a lone `\end` then
400    /// unwinds every enclosing environment on its way to the root — one gated
401    /// `\begin` inside a `\lowercase{…}` group un-closes the whole `document`
402    /// (amsldoc.tex, issue #71). Demoting the `\end` too keeps the gate's two
403    /// halves consistent. A genuine typo (`\end{itemiz}`) is untouched: nothing
404    /// demoted that name, so it stays a stray `\end`.
405    fn end_orphans_a_demoted_begin(&self, idx: usize) -> bool {
406        if self.demoted_envs.is_empty() {
407            return false;
408        }
409        peek_end_name(self.tokens, idx).is_some_and(|name| {
410            self.demoted_envs.contains(&name) && !self.open_envs.contains(&name)
411        })
412    }
413
414    /// True when token `idx` sits in *macro code*: inside a definition body
415    /// (issues #45/#55) or inside an expl3 region (issue #60; `.dtx` doc-margin
416    /// lines exempt, see [`Self::expl_toggles`]). There `\begin`/`\end` are
417    /// plain commands that need not pair, and an orphan `\]`/`\)` is data
418    /// (`AGENTS.md` decision #1).
419    fn in_macro_code(&self, idx: usize) -> bool {
420        self.in_def_body || (self.in_expl_region(idx) && !self.on_doc_margin_line(idx))
421    }
422
423    /// True when the cursor sits lexically inside a math body — including inside
424    /// a text-mode block (unknown environment, `\text{…}`-style group) nested in
425    /// one. See the `math_depth` field.
426    fn in_math(&self) -> bool {
427        self.math_depth > 0
428    }
429
430    // --- cursor primitives -------------------------------------------------
431
432    /// Tick the stuck-loop guard, called from every lookahead primitive. Resets
433    /// the budget whenever the cursor has advanced since the last tick (real
434    /// progress — via `bump` or the math-split fast path, both of which move
435    /// `pos`), so the surviving count is the number of *consecutive* peeks with no
436    /// token consumed. Exceeding [`PARSER_STEP_LIMIT`] means the parser is wedged
437    /// in a non-advancing loop; abort loudly rather than hang. This can only fire
438    /// on a grammar bug or pathological input, never on a real document, and the
439    /// async callers (the language server's worker + read pool) already recover
440    /// from a parse panic, degrading a wedged parse to a logged error.
441    #[inline]
442    fn step(&self) {
443        if self.pos != self.last_step_pos.get() {
444            self.last_step_pos.set(self.pos);
445            self.steps.set(0);
446        }
447        let steps = self.steps.get();
448        assert!(
449            steps < PARSER_STEP_LIMIT,
450            "parser exceeded {PARSER_STEP_LIMIT} peeks without consuming a token at position {} \
451             — non-advancing loop",
452            self.pos
453        );
454        self.steps.set(steps + 1);
455    }
456
457    fn kind(&self) -> Option<SyntaxKind> {
458        self.step();
459        self.tokens.get(self.pos).map(|t| t.kind)
460    }
461
462    fn nth_kind(&self, n: usize) -> Option<SyntaxKind> {
463        self.step();
464        self.tokens.get(self.pos + n).map(|t| t.kind)
465    }
466
467    fn text(&self) -> &str {
468        self.tokens
469            .get(self.pos)
470            .map(|t| t.text.as_str())
471            .unwrap_or("")
472    }
473
474    fn at_end(&self) -> bool {
475        self.pos >= self.tokens.len()
476    }
477
478    fn at_command(&self, name: &str) -> bool {
479        self.kind() == Some(SyntaxKind::CONTROL_WORD) && self.text() == name
480    }
481
482    /// True if the `\begin`/`\end` at token index `pos` reads as a LaTeX
483    /// environment delimiter: a `{` follows across trivia, without crossing a
484    /// blank line, and the name inside is name-shaped. Macro code uses the
485    /// bare TeX primitive and delimiter patterns (`\let\end\@@end`,
486    /// `\long\def\@gobble@nv#1\end#2{…}`, `\expandafter\end`, xparse's
487    /// `\begin \end {#3}` argument data — issue #60) at least as often as
488    /// prose omits the brace by mistake, so a brace-less `\begin`/`\end` is a
489    /// plain command everywhere: no environment, no diagnostic, and no
490    /// recovery anchor. Likewise a name group holding a parameter or control
491    /// word (`\end{#2}`, `\edef…{\noexpand\end{\reserved@a}}`) is computed
492    /// macro data — statically unpairable — so it too stays a plain command
493    /// (the group attaches as an ordinary argument).
494    fn env_name_follows(&self, pos: usize) -> bool {
495        let s = self.scan_trivia(pos + 1, CommentMode::Skip);
496        if s.saw_blank_line || s.next_kind != Some(SyntaxKind::L_BRACE) {
497            return false;
498        }
499        // Scan the name up to the closing `}` on the same line: a parameter
500        // (`#`), a control word/symbol, or a nested `{` before it is macro
501        // data, not a name. An *unterminated* name (line end or EOF first) is
502        // an in-progress edit — stay optimistic so `\begin{ali` still parses
503        // as a `BEGIN` + `NAME_GROUP` and environment-name completion sees it.
504        for t in &self.tokens[s.next + 1..] {
505            match t.kind {
506                SyntaxKind::R_BRACE | SyntaxKind::NEWLINE => return true,
507                SyntaxKind::HASH
508                | SyntaxKind::CONTROL_WORD
509                | SyntaxKind::CONTROL_SYMBOL
510                | SyntaxKind::L_BRACE => return false,
511                _ => {}
512            }
513        }
514        true
515    }
516
517    fn is_trivia(k: SyntaxKind) -> bool {
518        matches!(
519            k,
520            SyntaxKind::WHITESPACE
521                | SyntaxKind::NEWLINE
522                | SyntaxKind::COMMENT
523                | SyntaxKind::DOC_MARGIN
524                | SyntaxKind::GUARD
525        )
526    }
527
528    // --- event emission ----------------------------------------------------
529
530    fn bump(&mut self) {
531        debug_assert!(!self.at_end(), "bump past end of input");
532        self.events.push(Event::Tok(self.pos));
533        self.pos += 1;
534    }
535
536    fn open(&mut self, kind: SyntaxKind) {
537        self.events.push(Event::Start(kind));
538    }
539
540    fn close(&mut self) {
541        self.events.push(Event::Finish);
542    }
543
544    fn error(&mut self, message: impl Into<String>) {
545        let (start, end) = if self.at_end() {
546            let end = *self.starts.last().expect("starts is non-empty");
547            (end, end)
548        } else {
549            (self.starts[self.pos], self.starts[self.pos + 1])
550        };
551        self.errors.push(SyntaxError {
552            message: message.into(),
553            start,
554            end,
555        });
556    }
557
558    /// Report an error at an explicit byte range. Used for *unclosed*-delimiter
559    /// errors, which are detected at the closing anchor (a recovery token or EOF)
560    /// but belong on the *opener* (`{`, `$`, `\[`, `\left`, `\begin{…}`)—the
561    /// token the reader must fix. Pointing them at the detection site would land
562    /// every unclosed error on EOF (a zero-width span at end of file).
563    fn error_at(&mut self, range: (usize, usize), message: impl Into<String>) {
564        self.errors.push(SyntaxError {
565            message: message.into(),
566            start: range.0,
567            end: range.1,
568        });
569    }
570
571    /// Byte range of the token at `pos` (`[starts[pos], starts[pos + 1])`).
572    /// Captured at a construct's opener before it is consumed, so an unclosed
573    /// error can point back at it (see [`Self::error_at`]).
574    fn token_span(&self, pos: usize) -> (usize, usize) {
575        (self.starts[pos], self.starts[pos + 1])
576    }
577
578    fn skip_trivia(&mut self) {
579        while self.kind().is_some_and(Self::is_trivia) {
580            self.bump();
581        }
582    }
583
584    /// Scan the contiguous trivia run starting at `from`, classifying each token
585    /// so the blank-line and leading-comment-bind rules (AGENTS.md #9) can be
586    /// decided from one walk instead of five near-identical ones. `WHITESPACE`,
587    /// `DOC_MARGIN`, and `GUARD` float (a `.dtx` margin neither counts as a
588    /// newline nor resets the run, so a margin-only line `%\n%\n` still reads as a
589    /// blank line via its two `NEWLINE`s); `NEWLINE`s accumulate into the blank-line
590    /// (`≥2`) test; a `COMMENT` is handled per [`CommentMode`]. A `GUARD` floats
591    /// for `saw_blank_line` but breaks the run for
592    /// [`TriviaScan::saw_blank_line_outside_guards`]. Does not consume.
593    fn scan_trivia(&self, from: usize, comment_mode: CommentMode) -> TriviaScan {
594        let mut i = from;
595        let mut newlines = 0;
596        let mut guard_newlines = 0;
597        let mut saw_blank_line = false;
598        let mut saw_blank_line_outside_guards = false;
599        let mut comment_start = None;
600        while let Some(t) = self.tokens.get(i) {
601            match t.kind {
602                SyntaxKind::NEWLINE => {
603                    newlines += 1;
604                    guard_newlines += 1;
605                    if newlines >= 2 {
606                        saw_blank_line = true;
607                        // A blank line breaks a leading-comment bind: only a
608                        // comment *after* it can still bind, so drop any comment
609                        // seen before it.
610                        comment_start = None;
611                    }
612                    if guard_newlines >= 2 {
613                        saw_blank_line_outside_guards = true;
614                    }
615                }
616                SyntaxKind::WHITESPACE | SyntaxKind::DOC_MARGIN => {}
617                // A docstrip guard floats like a margin for the layout rules
618                // (`saw_blank_line`), but it is *content* on its line — and a
619                // line docstrip deletes outright when it strips the file, so a
620                // guard-only line is not a blank line separating what surrounds
621                // it. Constructs that only need to know whether their source
622                // ran out mid-shape read `saw_blank_line_outside_guards`
623                // instead (issue #71).
624                SyntaxKind::GUARD => guard_newlines = 0,
625                SyntaxKind::COMMENT if comment_mode == CommentMode::Stop => break,
626                // A comment occupies its own line: it is content, not blank space,
627                // so it resets the newline run (without undoing a blank line
628                // already seen) and, if it starts its line, opens a
629                // leading-comment bind.
630                SyntaxKind::COMMENT => {
631                    newlines = 0;
632                    guard_newlines = 0;
633                    if comment_start.is_none() && self.comment_starts_line(i) {
634                        comment_start = Some(i);
635                    }
636                }
637                _ => break,
638            }
639            i += 1;
640        }
641        TriviaScan {
642            next: i,
643            next_kind: self.tokens.get(i).map(|t| t.kind),
644            saw_blank_line,
645            saw_blank_line_outside_guards,
646            comment_start,
647        }
648    }
649
650    /// Peek the kind of the next non-trivia token and whether the intervening
651    /// trivia contains a paragraph break (a blank line, i.e. ≥2 newlines).
652    /// Does not consume.
653    fn peek_meaningful(&self) -> (Option<SyntaxKind>, bool) {
654        let s = self.scan_trivia(self.pos, CommentMode::Skip);
655        (s.next_kind, s.saw_blank_line)
656    }
657
658    /// Text of the next non-trivia token at/after `self.pos`, if any. Does not
659    /// consume. Used to distinguish a verbatim-argument `VERB` from a standalone
660    /// `\verb…` token (see `attach_arguments`).
661    fn peek_meaningful_text(&self) -> Option<&str> {
662        let mut i = self.pos;
663        while let Some(t) = self.tokens.get(i) {
664            if !Self::is_trivia(t.kind) {
665                return Some(t.text.as_str());
666            }
667            i += 1;
668        }
669        None
670    }
671
672    /// True if a paragraph break (blank line) begins at the current position.
673    fn at_paragraph_break(&self) -> bool {
674        self.scan_trivia(self.pos, CommentMode::Skip).saw_blank_line
675    }
676
677    /// [`Self::at_paragraph_break`], but blind to `.dtx` docstrip guard lines:
678    /// a `%<*dtx>`/`%</dtx>` pair on its own lines is not the blank line it
679    /// looks like, because docstrip deletes those lines outright. Used by the
680    /// bail-out anchors of constructs that legitimately span a guarded block —
681    /// `\ProvidesPackage{…}` and its `[…date…]` optional, split across
682    /// `%<package>`/`%<*dtx>` variants (rotating.dtx, issue #71).
683    fn at_paragraph_break_outside_guards(&self) -> bool {
684        self.scan_trivia(self.pos, CommentMode::Skip)
685            .saw_blank_line_outside_guards
686    }
687
688    /// True if the comment at `pos` starts its own line: scanning back over
689    /// inline whitespace only, the preceding token is a `NEWLINE` or the start of
690    /// input. A same-line trailing comment (`\foo % x`) returns `false` and never
691    /// binds forward (see [`Self::binding_run`]).
692    fn comment_starts_line(&self, pos: usize) -> bool {
693        let mut i = pos;
694        while i > 0 {
695            i -= 1;
696            match self.tokens[i].kind {
697                // A `.dtx` margin or guard is skipped like whitespace when deciding
698                // whether a comment owns its line (neither is itself the comment).
699                SyntaxKind::WHITESPACE | SyntaxKind::DOC_MARGIN | SyntaxKind::GUARD => {
700                    continue;
701                }
702                SyntaxKind::NEWLINE => return true,
703                _ => return false,
704            }
705        }
706        true
707    }
708
709    /// If the trivia run at `from` ends in a `%` comment run that binds *leading*
710    /// into a following documentable construct, return
711    /// `(comment_start, construct_pos, construct_kind)`:
712    /// - `comment_start` — index of the first own-line comment of the binding run
713    ///   (the maximal blank-line-free suffix; trivia before it floats),
714    /// - `construct_pos` — index of the construct's control word,
715    /// - `construct_kind` — `ENVIRONMENT` for `\begin`, otherwise `COMMAND`.
716    ///
717    /// Returns `None` when the run has no own-line comment, a blank line separates
718    /// the comment from the construct, or the next non-trivia token is not a
719    /// documentable construct. Mirrors rust-analyzer's `n_attached_trivias`
720    /// (AGENTS.md #9): comments bind forward to the item they annotate, a blank
721    /// line breaks the bind, and a same-line trailing comment never binds.
722    ///
723    /// One deliberate divergence: RA peeks *past* a blank line and keeps attaching
724    /// when the next comment is an outer doc comment (`///`/`//!`). LaTeX's single
725    /// `%` carries no such intent marker, so we always stop at the blank line (only
726    /// the maximal blank-line-free suffix binds). See AGENTS.md #9;
727    /// `comment_after_blank_line_still_binds` (`tests/parser.rs`) pins the divergence.
728    fn binding_run(&self, from: usize) -> Option<(usize, usize, SyntaxKind)> {
729        let s = self.scan_trivia(from, CommentMode::Skip);
730        let start = s.comment_start?;
731        if s.next_kind != Some(SyntaxKind::CONTROL_WORD) {
732            return None;
733        }
734        let kind = match self.tokens[s.next].text.as_str() {
735            BEGIN_CMD => SyntaxKind::ENVIRONMENT,
736            END_CMD => return None,
737            _ => SyntaxKind::COMMAND,
738        };
739        Some((start, s.next, kind))
740    }
741
742    // --- grammar -----------------------------------------------------------
743
744    fn document(&mut self) {
745        self.parse_block(Block::Document);
746    }
747
748    /// Parse a content region, grouping runs of content into `PARAGRAPH` nodes
749    /// delimited by blank lines (the TeX `\par` boundary). Blank-line trivia
750    /// (and any trailing trivia) sits between paragraphs as direct children of
751    /// the enclosing node, not inside a paragraph.
752    fn parse_block(&mut self, block: Block) {
753        loop {
754            if self.at_block_end(block) {
755                break;
756            }
757            // Separator trivia (blank lines / trailing whitespace) is emitted
758            // directly, never wrapped in a paragraph — except a trailing own-line
759            // comment run that binds into the construct after it: stop before that
760            // comment so the construct (next iteration) absorbs it as leading.
761            if self.kind().is_some_and(Self::is_trivia) && self.trivia_run_is_separator(block) {
762                let stop = self
763                    .binding_run(self.pos)
764                    .map_or(self.tokens.len(), |(comment_start, ..)| comment_start);
765                while self.pos < stop && self.kind().is_some_and(Self::is_trivia) {
766                    self.bump();
767                }
768                continue;
769            }
770            // Otherwise we're at paragraph content (guaranteed ≥1 token, so no
771            // empty paragraph and no infinite loop). Parse the run first, then
772            // splice in the `PARAGRAPH` wrapper afterwards (the `precede` idiom,
773            // cf. `math_scripted`) — unless the run's only non-trivia element is a
774            // lone block environment, which we leave bare. Block-ness is read from
775            // the built-in signature DB (`is_block_environment`).
776            let checkpoint = self.events.len();
777            let mut nontrivia_count = 0usize;
778            let mut lone_block_env = false;
779            loop {
780                if self.at_block_end(block) {
781                    break;
782                }
783                if self.kind().is_some_and(Self::is_trivia) && self.trivia_run_is_separator(block) {
784                    break;
785                }
786                // Leading comment-bind: an own-line `%` run immediately before a
787                // documentable construct attaches *leading* into it. Float any
788                // trivia before the comment run, then wrap the comments + construct
789                // in the construct's node (the `precede` idiom: the construct
790                // self-opens, then its `Start` is pulled back over the comments).
791                // The bound run itself is grouped into a `DOC_COMMENT` node — the
792                // named-trivia enrichment AGENTS.md #9 reserved — so downstream
793                // (LSP/formatter) sees the doc comment as one unit rather than
794                // bare leaves.
795                if let Some((comment_start, construct_pos, _)) = self.binding_run(self.pos) {
796                    while self.pos < comment_start {
797                        self.bump();
798                    }
799                    let checkpoint = self.events.len();
800                    self.open(SyntaxKind::DOC_COMMENT);
801                    while self.pos < construct_pos {
802                        self.bump();
803                    }
804                    self.close();
805                    let starts_block_env = self.tokens[construct_pos].text == BEGIN_CMD
806                        && peek_begin_name(self.tokens, construct_pos)
807                            .as_deref()
808                            .is_some_and(is_block_environment);
809                    let construct_start = self.events.len();
810                    self.element();
811                    if let Event::Start(kind) = self.events[construct_start] {
812                        self.events.remove(construct_start);
813                        self.events.insert(checkpoint, Event::Start(kind));
814                    }
815                    nontrivia_count += 1;
816                    lone_block_env = nontrivia_count == 1 && starts_block_env;
817                    continue;
818                }
819                let is_nontrivia = !self.kind().is_some_and(Self::is_trivia);
820                // Peek block-env status *before* consuming (the name is only
821                // available while still on the `\begin`).
822                let starts_block_env = self.at_command(BEGIN_CMD)
823                    && peek_begin_name(self.tokens, self.pos)
824                        .as_deref()
825                        .is_some_and(is_block_environment);
826                self.element();
827                if is_nontrivia {
828                    nontrivia_count += 1;
829                    lone_block_env = nontrivia_count == 1 && starts_block_env;
830                }
831            }
832            if !lone_block_env {
833                self.events
834                    .insert(checkpoint, Event::Start(SyntaxKind::PARAGRAPH));
835                self.close(); // matching Finish for PARAGRAPH
836            }
837        }
838    }
839
840    fn at_block_end(&self, block: Block) -> bool {
841        self.at_end()
842            || match block {
843                Block::Document => false,
844                Block::Environment => {
845                    self.at_command(END_CMD)
846                        && self.env_name_follows(self.pos)
847                        && !self.end_orphans_a_demoted_begin(self.pos)
848                }
849                // `>=` (not `==`): defensive against an element overshooting the
850                // pre-scanned terminator, so the loop still stops.
851                Block::Macrocode => self.macrocode_end.is_some_and(|end| self.pos >= end),
852            }
853    }
854
855    /// True if the contiguous trivia run at the current position should separate
856    /// paragraphs: it contains a blank line, or only trivia remains before the
857    /// block terminator (the `\end`, or EOF).
858    fn trivia_run_is_separator(&self, block: Block) -> bool {
859        let s = self.scan_trivia(self.pos, CommentMode::Skip);
860        if s.saw_blank_line {
861            return true;
862        }
863        // A macrocode body ends positionally at the frame terminator; trivia
864        // reaching it (the frame line's own margin and indent) is a separator.
865        if block == Block::Macrocode {
866            return s.next_kind.is_none() || self.macrocode_end.is_some_and(|end| s.next >= end);
867        }
868        match s.next_kind {
869            // Only trivia remains before the block terminator (`\end`, or EOF).
870            None => true,
871            Some(SyntaxKind::CONTROL_WORD) => {
872                block == Block::Environment
873                    && self.tokens[s.next].text == END_CMD
874                    && self.env_name_follows(s.next)
875            }
876            Some(_) => false,
877        }
878    }
879
880    /// One element in text mode. Always consumes at least one token.
881    fn element(&mut self) {
882        let Some(k) = self.kind() else { return };
883        match k {
884            SyntaxKind::WHITESPACE
885            | SyntaxKind::NEWLINE
886            | SyntaxKind::COMMENT
887            | SyntaxKind::DOC_MARGIN
888            | SyntaxKind::GUARD => self.bump(),
889            SyntaxKind::CONTROL_WORD => {
890                // Inside a definition body or an expl3 region, `\begin`/`\end`
891                // are plain commands: the two need not balance within one group
892                // (issues #45/#60), so neither opens an environment nor is
893                // stray. A brace-less `\begin`/`\end` is likewise a plain
894                // command (`env_name_follows`).
895                if !self.in_macro_code(self.pos)
896                    && self.at_command(BEGIN_CMD)
897                    && self.env_name_follows(self.pos)
898                {
899                    // Shape-gated like `\[`: an environment cannot outlive the
900                    // brace group it opened in, so one whose `\end` is not
901                    // reachable before that group closes is macro code — a
902                    // plain command, no diagnostic (issue #71).
903                    if self.environment_escapes_group(self.pos) {
904                        if let Some(name) = peek_end_name(self.tokens, self.pos) {
905                            self.demoted_envs.insert(name);
906                        }
907                        self.command();
908                    } else {
909                        self.environment();
910                    }
911                } else if !self.in_macro_code(self.pos)
912                    && self.at_command(END_CMD)
913                    && self.env_name_follows(self.pos)
914                {
915                    // The mirror case: reached inside a group, this `\end`'s
916                    // `\begin` is outside it, so it is macro code rather than
917                    // stray (`\StopEventually{\end{document}}`, issue #71).
918                    if (self.group_depth > 0 && !self.doc_margin_exempt(self.pos))
919                        || self.end_orphans_a_demoted_begin(self.pos)
920                    {
921                        self.command();
922                    } else {
923                        self.stray_end();
924                    }
925                } else {
926                    self.command();
927                }
928            }
929            SyntaxKind::CONTROL_SYMBOL => {
930                let sym = self.text().to_owned();
931                match sym.as_str() {
932                    // Shape-gated like `$` ([`Self::delim_math_closes`]): an
933                    // opener with no reachable closer is macro-code data
934                    // (`\expandafter\@tempa\[\@nil`, issue #65) — an ordinary
935                    // token, no math, no diagnostic.
936                    "\\[" => {
937                        if self.delim_math_closes(self.pos, "\\]") {
938                            self.delim_math(SyntaxKind::DISPLAY_MATH, "\\[", "\\]");
939                        } else {
940                            self.bump();
941                        }
942                    }
943                    "\\(" => {
944                        if self.delim_math_closes(self.pos, "\\)") {
945                            self.delim_math(SyntaxKind::INLINE_MATH, "\\(", "\\)");
946                        } else {
947                            self.bump();
948                        }
949                    }
950                    "\\]" | "\\)" => {
951                        // In macro code (a definition body, macrocode chunk,
952                        // or expl3 region) an orphan closer is data, not a
953                        // stray delimiter (`\char_set_catcode_letter:N \)`,
954                        // issue #60) — an ordinary token, no diagnostic. In
955                        // prose it still diagnoses, catching a `\[…\]` typo'd
956                        // across a paragraph break on its closer.
957                        if !self.in_macro_code(self.pos) {
958                            self.error(format!("unmatched `{sym}`"));
959                        }
960                        self.bump();
961                    }
962                    // `\\` line break, with its tightly-bound `*` / `[len]`.
963                    "\\\\" => self.line_break(),
964                    // Any other bare control symbol (`\,`, `\%`, `\;`, …). Surface
965                    // model: emit as a token; these take no arguments.
966                    _ => self.bump(),
967                }
968            }
969            // A brace unmatched within a `macrocode` chunk is an ordinary macro-
970            // code token (the definition it belongs to spans chunks): no `GROUP`,
971            // no diagnostic.
972            SyntaxKind::L_BRACE => {
973                if self.plain_braces.contains(&self.pos) {
974                    self.bump();
975                } else {
976                    self.group();
977                }
978            }
979            SyntaxKind::R_BRACE => {
980                if !self.plain_braces.contains(&self.pos) {
981                    self.error("unmatched `}`");
982                }
983                self.bump();
984            }
985            SyntaxKind::DOLLAR => {
986                let display = self.nth_kind(1) == Some(SyntaxKind::DOLLAR);
987                if self.dollar_closes(self.pos, display) {
988                    self.dollar_math();
989                } else {
990                    // No reachable closer: this dollar is macro-code data
991                    // (`>{$}`, `{ $ }`), not a math delimiter — an ordinary
992                    // token, no math, no diagnostic. Each `$` of an ungated
993                    // `$$` re-enters here and is gated independently.
994                    self.bump();
995                }
996            }
997            // WORD, brackets, & # ^ _ ~, ERROR: ordinary tokens in text mode.
998            _ => self.bump(),
999        }
1000    }
1001
1002    /// `\foo` followed by its greedily-attached argument groups.
1003    ///
1004    /// Arity is unknown without the semantic layer, so we attach every trailing
1005    /// `{…}` / `[…]` group (see `AGENTS.md`, Core decision #8, and
1006    /// [`Self::attach_arguments`] for the `[…]` shape gates). The one curated
1007    /// exception: a delimiter-size command (`\Big`, `\bigl`, …) never takes a
1008    /// `[…]` argument — its `[` is the delimiter it sizes (`\Big[ x \Big]`),
1009    /// mirroring the `\left`/`\right` special case.
1010    fn command(&mut self) {
1011        let bracket = if is_big_delimiter_command(self.text()) {
1012            BracketPolicy::Forbid
1013        } else {
1014            BracketPolicy::Greedy
1015        };
1016        // A definition-body command's attached groups are macro-code bodies
1017        // (issues #45/#55): flag them so `\begin`/`\end` inside parse as
1018        // plain commands. OR-ed with the saved flag so a definition nested in
1019        // another definition's body stays flagged; restored after the
1020        // arguments so following siblings are unaffected.
1021        let saved = self.in_def_body;
1022        self.in_def_body = saved || is_definition_body_command(self.text());
1023        let def_prefix = is_def_prefix_command(self.text());
1024        self.open(SyntaxKind::COMMAND);
1025        self.bump(); // the control word
1026        // A `\def`-family primitive's next token is the control sequence being
1027        // defined ([`is_def_prefix_command`]). A control-symbol name is
1028        // consumed here as a plain token so it is never misparsed as syntax
1029        // (`\def\[{…}` is not a math opener), and the attached body is then a
1030        // macro-code body: the stacks-project redefinition opens `trivlist` in
1031        // `\def\[`'s body and closes it in `\def\]`'s (issue #65), the same
1032        // no-balance fact as `is_definition_body_command`.
1033        if def_prefix {
1034            let scan = self.scan_trivia(self.pos, CommentMode::Skip);
1035            if scan.next_kind == Some(SyntaxKind::CONTROL_SYMBOL) && !scan.saw_blank_line {
1036                self.skip_trivia();
1037                self.bump(); // the defined name
1038                self.in_def_body = true;
1039            }
1040        }
1041        self.attach_arguments(bracket);
1042        self.in_def_body = saved;
1043        self.close();
1044    }
1045
1046    /// The `\\` line break and its tightly-bound modifiers: an optional `*`
1047    /// (no-page-break variant) and an optional `[length]` (`\\`, `\\*`,
1048    /// `\\[2ex]`, `\\*[2ex]`). These bind to the `\\` only when they *directly*
1049    /// abut it — no intervening trivia is crossed — so a lone `\\` at end of line
1050    /// stays bare and the modifiers are never pulled across a break. Grouping
1051    /// them into one `LINE_BREAK` node (rather than leaving loose tokens) is what
1052    /// lets the formatter treat `\\[2ex]` as one unit instead of stranding the
1053    /// `[2ex]` on the next line.
1054    ///
1055    /// Unlike `command`, this attaches *no* `{…}` arguments (`\\` takes none) and
1056    /// does not skip trivia. The `*` is recognized only as its own `WORD` token
1057    /// (the lexer glues `*` into following letters, so `\\*foo` keeps the star on
1058    /// the word — a vanishingly rare form we deliberately leave alone).
1059    fn line_break(&mut self) {
1060        self.open(SyntaxKind::LINE_BREAK);
1061        self.bump(); // \\
1062        if self.kind() == Some(SyntaxKind::WORD) && self.text() == "*" {
1063            self.bump(); // *
1064        }
1065        if self.kind() == Some(SyntaxKind::L_BRACKET) {
1066            self.optional(); // [length]
1067        }
1068        self.close();
1069    }
1070
1071    /// Greedily attach trailing `{…}` / `[…]` argument groups to the currently
1072    /// open node, allowing intervening trivia but stopping at a paragraph break.
1073    /// Shared by `\foo` commands and `\begin{env}` (see `AGENTS.md`, Core
1074    /// decision #8). Arity is unknown without the semantic layer.
1075    ///
1076    /// `[…]` attachment is additionally shape-gated (issue #43) — `[`/`]` are
1077    /// not real grouping in TeX, so a bracket is an argument only when it reads
1078    /// as one:
1079    /// - **Lexically inside math, only when it directly abuts.** Real math
1080    ///   optionals are written tight (`\sqrt[3]{x}`, `\\[2ex]`); a spaced `[`
1081    ///   is a delimiter or interval (`\bE [ x ]`). This uses [`Self::in_math`],
1082    ///   so it also covers text-mode bodies of unknown environments nested in
1083    ///   math (`\[ … \begin{myaligned} \Big [ … \]`).
1084    /// - **Inside math, only when [`Self::bracket_closes_before_math_end`]
1085    ///   finds its `]`**; otherwise it is left for the math loop as an ordinary
1086    ///   atom, so open-interval notation (`$]0;\num{0.5}[$`) does not swallow
1087    ///   the math closer as an optional-argument body.
1088    /// - **In text mode, only when [`Self::bracket_closes_in_text`] finds its
1089    ///   `]`** (issue #60): macro code tests for and re-emits lone brackets
1090    ///   (`\@ifnextchar [\@xmpar\@ympar`), so a `[` whose closer is not
1091    ///   reachable stays an ordinary token — no `OPTIONAL`, no diagnostic —
1092    ///   mirroring the `$` shape gate ([`Self::dollar_closes`]).
1093    /// - **Per the caller's [`BracketPolicy`]:** `Tight` (a curated math
1094    ///   environment's `\begin` — its math body starts right after, so a
1095    ///   detached `[` is content: `\begin{align}` + newline + `[a]_1`) demands
1096    ///   a directly-abutting `[` even outside math; `Forbid` (the
1097    ///   delimiter-size commands, [`Self::command`]) never attaches one.
1098    ///   `Greedy` — everything else — keeps decision #8's trivia-crossing
1099    ///   attachment, which the semantic layer legitimizes downstream (the
1100    ///   xparse-signature glue relies on a next-line `[Warning]` still
1101    ///   attaching to `\begin{note}`).
1102    fn attach_arguments(&mut self, bracket: BracketPolicy) {
1103        loop {
1104            let (next, paragraph_break) = self.peek_meaningful();
1105            if paragraph_break {
1106                break;
1107            }
1108            match next {
1109                Some(SyntaxKind::L_BRACE) => {
1110                    // A chunk-unmatched macrocode brace is a plain token, never
1111                    // an argument group (`\gdef\foo{%` … next chunk).
1112                    let scan = self.scan_trivia(self.pos, CommentMode::Skip);
1113                    if self.plain_braces.contains(&scan.next) {
1114                        break;
1115                    }
1116                    self.skip_trivia();
1117                    self.group();
1118                }
1119                Some(SyntaxKind::L_BRACKET) => {
1120                    if bracket == BracketPolicy::Forbid {
1121                        break;
1122                    }
1123                    let scan = self.scan_trivia(self.pos, CommentMode::Skip);
1124                    let tight_only = self.in_math() || bracket == BracketPolicy::Tight;
1125                    if tight_only && scan.next != self.pos {
1126                        break;
1127                    }
1128                    if self.in_math() && !self.bracket_closes_before_math_end(scan.next) {
1129                        break;
1130                    }
1131                    // In a macrocode body, a `[` is an argument only when its `]`
1132                    // closes inside the chunk: macro code uses bare brackets
1133                    // freely, and an optional must never consume the frame.
1134                    if self.macrocode_end.is_some()
1135                        && !self.bracket_closes_before_macrocode_end(scan.next)
1136                    {
1137                        break;
1138                    }
1139                    // In text mode, a `[` is an argument only when its `]` is
1140                    // reachable ([`Self::bracket_closes_in_text`]): macro code
1141                    // tests for and re-emits lone brackets at least as often as
1142                    // prose writes real optionals (`\@ifnextchar [\@xmpar\@ympar`,
1143                    // issue #60), so an unreachable closer means the bracket is
1144                    // data, not an argument.
1145                    if !self.in_math()
1146                        && self.macrocode_end.is_none()
1147                        && !self.bracket_closes_in_text(scan.next)
1148                    {
1149                        break;
1150                    }
1151                    self.skip_trivia();
1152                    self.optional();
1153                }
1154                // A verbatim-argument command's body (`\url{…}`, `\lstinline|…|`,
1155                // the final arg of `\mintinline{lang}{code}`) is lexed as a single
1156                // `VERB` token immediately following the command, so attach it as a
1157                // child like any other argument (decision #8) instead of leaving it
1158                // a sibling. A *standalone* `\verb…`/`\verb*…` token (its text starts
1159                // with `\`) is self-contained and belongs to no command — never
1160                // capture it. `lex_verbatim_command` emits its non-`\` `VERB`
1161                // *directly* after its own command tokens, so only a directly
1162                // abutting `VERB` attaches: a spaced one is a doc short-verb span
1163                // (`\emph{x} |y|`), a freestanding sibling that must keep its
1164                // interword space.
1165                Some(SyntaxKind::VERB)
1166                    if self.scan_trivia(self.pos, CommentMode::Skip).next == self.pos
1167                        && !self
1168                            .peek_meaningful_text()
1169                            .is_some_and(|t| t.starts_with('\\')) =>
1170                {
1171                    self.bump(); // the VERB argument
1172                }
1173                // A starred-variant marker `*` folds into the invocation so the
1174                // arguments that follow it still attach (`\section*{…}`,
1175                // `\inferrule*[…]`, `\\*[2pt]`).
1176                Some(SyntaxKind::WORD) if self.at_star_variant_marker() => {
1177                    self.bump(); // the `*`
1178                }
1179                _ => break,
1180            }
1181        }
1182    }
1183
1184    /// Whether the next token is a *starred-variant marker* to fold into the
1185    /// command invocation: a lone `*` tight to the command, itself followed by
1186    /// an argument opener (`[`/`{`). LaTeX's `\@ifstar` commands carry the star
1187    /// before their arguments (`\section*{…}`, mathpartir's `\inferrule*[…]`,
1188    /// the `\\*[2pt]` line break), so folding it lets those arguments attach
1189    /// (decision #8) instead of the `*` breaking the run. Gating on a *following
1190    /// argument* keeps a math operator (`\pi*r`, `\Gamma * x`) — a `*` with no
1191    /// argument after it — from being mistaken for a marker. The `*` must be a
1192    /// lone token tight to the command: a spaced `\foo *` is not a marker, and
1193    /// `\foo*bar` lexes the star into a single `*bar` word (text ≠ `*`), so
1194    /// neither folds. Does not consume.
1195    fn at_star_variant_marker(&self) -> bool {
1196        if self.scan_trivia(self.pos, CommentMode::Skip).next != self.pos {
1197            return false; // the star must be tight to the command
1198        }
1199        if self.tokens.get(self.pos).map(|t| (t.kind, t.text.as_str()))
1200            != Some((SyntaxKind::WORD, "*"))
1201        {
1202            return false;
1203        }
1204        matches!(
1205            self.scan_trivia(self.pos + 1, CommentMode::Skip).next_kind,
1206            Some(SyntaxKind::L_BRACKET | SyntaxKind::L_BRACE)
1207        )
1208    }
1209
1210    /// A brace group `{ … }`.
1211    fn group(&mut self) {
1212        debug_assert_eq!(self.kind(), Some(SyntaxKind::L_BRACE));
1213        let opener = self.token_span(self.pos);
1214        self.open(SyntaxKind::GROUP);
1215        self.bump(); // {
1216        self.group_depth += 1;
1217        self.group_opens.push(self.pos - 1);
1218        loop {
1219            match self.kind() {
1220                None => {
1221                    self.error_at(opener, "unclosed `{`");
1222                    break;
1223                }
1224                Some(SyntaxKind::R_BRACE) => {
1225                    self.bump();
1226                    break;
1227                }
1228                _ => self.element(),
1229            }
1230        }
1231        self.group_depth -= 1;
1232        self.group_opens.pop();
1233        self.close();
1234    }
1235
1236    /// An optional-argument group `[ … ]`.
1237    ///
1238    /// `[` and `]` are not real grouping in TeX, so this is heuristic: it ends
1239    /// at the first `]`, and bails defensively (rather than swallowing the
1240    /// document) on a `}`, a `\begin`/`\end`, a paragraph break, or EOF.
1241    fn optional(&mut self) {
1242        debug_assert_eq!(self.kind(), Some(SyntaxKind::L_BRACKET));
1243        let opener = self.token_span(self.pos);
1244        self.open(SyntaxKind::OPTIONAL);
1245        self.bump(); // [
1246        loop {
1247            match self.kind() {
1248                None | Some(SyntaxKind::R_BRACE) => {
1249                    self.error_at(opener, "unclosed `[`");
1250                    break;
1251                }
1252                Some(SyntaxKind::R_BRACKET) => {
1253                    self.bump();
1254                    break;
1255                }
1256                // In a definition body or expl3 region `\begin`/`\end` are
1257                // plain commands (issues #45/#60), so they don't signal a
1258                // runaway `[` — nor does a brace-less one (issue #60).
1259                Some(SyntaxKind::CONTROL_WORD)
1260                    if !self.in_macro_code(self.pos)
1261                        && (self.at_command(BEGIN_CMD) || self.at_command(END_CMD))
1262                        && self.env_name_follows(self.pos) =>
1263                {
1264                    self.error_at(opener, "unclosed `[`");
1265                    break;
1266                }
1267                _ => {
1268                    // The macrocode frame terminator is absolute: an optional
1269                    // still open there is abandoned, never consumes the frame.
1270                    if self.at_paragraph_break_outside_guards()
1271                        || self.macrocode_end.is_some_and(|end| self.pos >= end)
1272                    {
1273                        self.error_at(opener, "unclosed `[`");
1274                        break;
1275                    }
1276                    self.element();
1277                }
1278            }
1279        }
1280        self.close();
1281    }
1282
1283    /// True if the `[` at token index `open` is closed by a `]` before the
1284    /// current macrocode chunk's frame terminator. Depth-tracks only the braces
1285    /// that really form groups (chunk-matched ones — [`Self::plain_braces`] are
1286    /// plain tokens), and gives up at a *blank line* — the same paragraph-break
1287    /// bail as [`Self::optional`], so an optional the formatter has re-wrapped
1288    /// over several lines still attaches on the second pass. Keeps a code
1289    /// bracket (`\@tempcnta[` with no `]` in the chunk) an ordinary token
1290    /// instead of an optional that would swallow the frame.
1291    fn bracket_closes_before_macrocode_end(&self, open: usize) -> bool {
1292        let Some(end) = self.macrocode_end else {
1293            return true;
1294        };
1295        let mut depth = 0usize;
1296        let mut newline_run = 0;
1297        for (off, t) in self.tokens[open + 1..end.min(self.tokens.len())]
1298            .iter()
1299            .enumerate()
1300        {
1301            let idx = open + 1 + off;
1302            match t.kind {
1303                SyntaxKind::NEWLINE => {
1304                    newline_run += 1;
1305                    if newline_run >= 2 {
1306                        return false;
1307                    }
1308                    continue;
1309                }
1310                SyntaxKind::WHITESPACE => continue,
1311                SyntaxKind::L_BRACE if !self.plain_braces.contains(&idx) => depth += 1,
1312                SyntaxKind::R_BRACE if !self.plain_braces.contains(&idx) => {
1313                    if depth == 0 {
1314                        return false;
1315                    }
1316                    depth -= 1;
1317                }
1318                SyntaxKind::R_BRACKET if depth == 0 => return true,
1319                _ => {}
1320            }
1321            newline_run = 0;
1322        }
1323        false
1324    }
1325
1326    /// True if the `[` at token index `open` is closed by a `]` before a token
1327    /// that would end the enclosing math. Mirrors [`Self::optional`]'s bail
1328    /// anchors (an unbalanced `}`, `\begin`/`\end`, a paragraph break, EOF) and
1329    /// adds the delimited math closers (`\]`, `\)`), which `optional` cannot
1330    /// stop at in text mode (`\item[$x$]` is legit) but which inside math mean
1331    /// the `[` is not an argument at all — e.g. the open-interval notation
1332    /// `$]0;\num{0.5}[$`. A `]` counts only outside `{…}` nesting, matching how
1333    /// `optional` consumes whole groups via `element` — and only past the `]`s
1334    /// owed to intervening *command-abutting* `[`s: such a `[` is itself
1335    /// argument-shaped (or a `\left`/`\Big` delimiter) and will claim the next
1336    /// `]` when parsed, so that `]` cannot also satisfy the outer `[`
1337    /// (`\P[\gamma[0, \infty) \cap A = \emptyset]`, issue #55 — the lone `]`
1338    /// belongs to `\gamma[`, so `\P[` stays an ordinary atom). A `[` abutting
1339    /// anything else (`x[i]`, the interval `[0, \infty)`) parses as an ordinary
1340    /// atom and claims nothing, so it adds no nesting here either.
1341    ///
1342    /// How a `$` at brace depth 0 is read depends on the *innermost enclosing
1343    /// math's flavor* ([`Self::math_dollar`]):
1344    /// - **Enclosing `\[…\]`/`\(…\)` (or a math environment).** A `$` opens a
1345    ///   genuine nested inline region, so a balanced `$…$` pair inside the
1346    ///   bracket is *transparent*: the `$` toggles an inline region rather than
1347    ///   ending the search, and `]`/`[` inside it are math content, ignored
1348    ///   (`\[ \inferrule*[right=$\Pi$-eq]{A}{B} \]` — the `$\Pi$` label sits
1349    ///   inside the optional). An *unbalanced* `$` leaves the region open, no
1350    ///   `]` is ever accepted, and the scan falls through to `false`.
1351    /// - **Enclosing `$…$`/`$$…$$`.** TeX cannot nest a `$` inside dollar math,
1352    ///   so the first depth-0 `$` is this math's *closer*: a `]` beyond it lives
1353    ///   in a later math and cannot be this bracket's, so bail like `\]`/`\)`.
1354    ///   Without this a stray `[` in dollar math (`$\mathcal{N}[\mathcal{S}$`,
1355    ///   a missing `]`, stacks-project issue #99) would scan past the closing
1356    ///   `$` into following math and wrongly attach an optional that swallows
1357    ///   it. Does not consume.
1358    fn bracket_closes_before_math_end(&self, open: usize) -> bool {
1359        let enclosing_is_dollar = self.math_dollar.last().copied().unwrap_or(false);
1360        let mut depth = 0usize;
1361        let mut brackets = 0usize;
1362        let mut in_inline = false;
1363        let mut newlines = 0;
1364        let mut abuts_command = false;
1365        for (off, t) in self.tokens[open + 1..].iter().enumerate() {
1366            let idx = open + 1 + off;
1367            let prev_abuts_command = abuts_command;
1368            abuts_command = false;
1369            match t.kind {
1370                SyntaxKind::NEWLINE => {
1371                    newlines += 1;
1372                    if newlines >= 2 {
1373                        return false;
1374                    }
1375                    continue;
1376                }
1377                SyntaxKind::WHITESPACE | SyntaxKind::DOC_MARGIN | SyntaxKind::GUARD => continue,
1378                SyntaxKind::L_BRACE => depth += 1,
1379                SyntaxKind::R_BRACE => {
1380                    if depth == 0 {
1381                        return false;
1382                    }
1383                    depth -= 1;
1384                }
1385                SyntaxKind::DOLLAR if depth == 0 => {
1386                    if enclosing_is_dollar {
1387                        return false;
1388                    }
1389                    in_inline = !in_inline;
1390                }
1391                SyntaxKind::L_BRACKET if depth == 0 && !in_inline && prev_abuts_command => {
1392                    brackets += 1
1393                }
1394                SyntaxKind::R_BRACKET if depth == 0 && !in_inline => {
1395                    if brackets == 0 {
1396                        return true;
1397                    }
1398                    brackets -= 1;
1399                }
1400                SyntaxKind::CONTROL_SYMBOL if matches!(t.text.as_str(), "\\]" | "\\)") => {
1401                    return false;
1402                }
1403                SyntaxKind::CONTROL_WORD
1404                    if matches!(t.text.as_str(), BEGIN_CMD | END_CMD)
1405                        && self.env_name_follows(idx) =>
1406                {
1407                    return false;
1408                }
1409                SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL => abuts_command = true,
1410                _ => {}
1411            }
1412            newlines = 0;
1413        }
1414        false
1415    }
1416
1417    /// True if the `[` at token index `open` is closed by a `]` before a token
1418    /// that would make [`Self::optional`] bail in text mode. `[`/`]` are not
1419    /// real grouping in TeX, and macro code tests for and re-emits lone
1420    /// brackets (`\@ifnextchar [\@xmpar\@ympar`, `\def\@xfloat#1[#2]{…}`
1421    /// re-implementations — issue #60) at least as often as prose writes real
1422    /// optionals, so — like the `$` shape gate ([`Self::dollar_closes`]) — a
1423    /// bracket attaches only when it *reads* as an argument: its closer must be
1424    /// reachable. Mirrors `optional`'s bail anchors (an unbalanced `}`,
1425    /// `\begin`/`\end` outside a definition body, a paragraph break, EOF). A
1426    /// `]` counts only outside `{…}` nesting (matching how `optional` consumes
1427    /// whole groups via `element`) and only past the `]`s owed to intervening
1428    /// *command-abutting* `[`s, exactly as in
1429    /// [`Self::bracket_closes_before_math_end`] (issue #55). A gated bracket
1430    /// stays an ordinary token with **no diagnostic**: in code the shape is
1431    /// routine, so it is not statically an error. Does not consume.
1432    fn bracket_closes_in_text(&self, open: usize) -> bool {
1433        let mut depth = 0usize;
1434        let mut brackets = 0usize;
1435        let mut newlines = 0;
1436        let mut abuts_command = false;
1437        for (off, t) in self.tokens[open + 1..].iter().enumerate() {
1438            let idx = open + 1 + off;
1439            let prev_abuts_command = abuts_command;
1440            abuts_command = false;
1441            match t.kind {
1442                SyntaxKind::NEWLINE => {
1443                    newlines += 1;
1444                    if newlines >= 2 {
1445                        return false;
1446                    }
1447                    continue;
1448                }
1449                SyntaxKind::WHITESPACE | SyntaxKind::DOC_MARGIN | SyntaxKind::GUARD => continue,
1450                SyntaxKind::L_BRACE if !self.plain_braces.contains(&idx) => depth += 1,
1451                SyntaxKind::R_BRACE if !self.plain_braces.contains(&idx) => {
1452                    if depth == 0 {
1453                        return false;
1454                    }
1455                    depth -= 1;
1456                }
1457                SyntaxKind::L_BRACKET if depth == 0 && prev_abuts_command => brackets += 1,
1458                SyntaxKind::R_BRACKET if depth == 0 => {
1459                    if brackets == 0 {
1460                        return true;
1461                    }
1462                    brackets -= 1;
1463                }
1464                SyntaxKind::CONTROL_WORD
1465                    if !self.in_macro_code(idx)
1466                        && matches!(t.text.as_str(), BEGIN_CMD | END_CMD)
1467                        && self.env_name_follows(idx) =>
1468                {
1469                    return false;
1470                }
1471                SyntaxKind::CONTROL_WORD | SyntaxKind::CONTROL_SYMBOL => abuts_command = true,
1472                _ => {}
1473            }
1474            newlines = 0;
1475        }
1476        false
1477    }
1478
1479    /// Whether a paragraph break seen by the math shape gates
1480    /// ([`Self::dollar_closes`], [`Self::delim_math_closes`]) ends the math.
1481    ///
1482    /// Only at the math body's own level. Both gates must mirror the parse
1483    /// they guard, and `dollar_math`/`delim_math` test `at_paragraph_break`
1484    /// only between top-level atoms: once the body descends into a `{…}`
1485    /// group ([`Self::math_group`]) or a nested environment
1486    /// ([`Self::environment`]), blank lines are ordinary body trivia and the
1487    /// math runs on. Scanning them as blockers made the gate stricter than
1488    /// the parse, so a display equation built out of `tikzpicture` cells
1489    /// (`\[ \begin{array}… \begin{tikzpicture}<blank line>… \]`, issue #70)
1490    /// lost its math node and reported its own `\]` as unmatched.
1491    fn paragraph_break_blocks(depth: usize, envs: usize) -> bool {
1492        depth == 0 && envs == 0
1493    }
1494
1495    /// True if the `$` (or `$$`) opener at token index `open` is closed by a
1496    /// matching delimiter before a token that would end the math. `$`/`$$` are
1497    /// data in macro code at least as often as they are math delimiters (a
1498    /// tabular preamble's `>{$}`, an expl3 token list's `{ $ }`, catcode
1499    /// comparisons in `\def` bodies), so — like `[…]` attachment (issue #43) —
1500    /// a dollar opens math only when it *reads* as math: a closer must be
1501    /// reachable. Mirrors [`Self::dollar_math`]'s recovery anchors (an
1502    /// unbalanced `}`, an `\end` not owed to an intervening `\begin`, a
1503    /// paragraph break, EOF, the macrocode chunk end). A closing `$` counts
1504    /// only outside `{…}` nesting — [`Self::math_group`] consumes a nested
1505    /// dollar as an ordinary atom, never as the closer — and for `$$` a lone
1506    /// `$` is skipped exactly as `dollar_math` skips it (malformed but
1507    /// consumed). Likewise a paragraph break blocks only at the math body's
1508    /// own level ([`Self::paragraph_break_blocks`]). Inside a definition body
1509    /// `\begin`/`\end` are plain commands (issue #45), so neither anchors nor
1510    /// nests there. Does not consume.
1511    fn dollar_closes(&self, open: usize, display: bool) -> bool {
1512        let mut depth = 0usize;
1513        let mut envs = 0usize;
1514        let mut newlines = 0;
1515        let start = open + if display { 2 } else { 1 };
1516        let end = self
1517            .macrocode_end
1518            .unwrap_or(self.tokens.len())
1519            .min(self.tokens.len());
1520        let mut i = start;
1521        while i < end {
1522            let t = &self.tokens[i];
1523            match t.kind {
1524                SyntaxKind::NEWLINE => {
1525                    newlines += 1;
1526                    if newlines >= 2 && Self::paragraph_break_blocks(depth, envs) {
1527                        return false;
1528                    }
1529                    i += 1;
1530                    continue;
1531                }
1532                SyntaxKind::WHITESPACE | SyntaxKind::DOC_MARGIN | SyntaxKind::GUARD => {
1533                    i += 1;
1534                    continue;
1535                }
1536                SyntaxKind::L_BRACE if !self.plain_braces.contains(&i) => depth += 1,
1537                SyntaxKind::R_BRACE if !self.plain_braces.contains(&i) => {
1538                    if depth == 0 {
1539                        return false;
1540                    }
1541                    depth -= 1;
1542                }
1543                SyntaxKind::DOLLAR if depth == 0 => {
1544                    if !display
1545                        || self.tokens.get(i + 1).map(|t| t.kind) == Some(SyntaxKind::DOLLAR)
1546                    {
1547                        return true;
1548                    }
1549                }
1550                SyntaxKind::CONTROL_WORD if !self.in_macro_code(i) => {
1551                    if t.text.as_str() == BEGIN_CMD && self.env_name_follows(i) {
1552                        envs += 1;
1553                    } else if t.text.as_str() == END_CMD && self.env_name_follows(i) {
1554                        if envs == 0 {
1555                            return false;
1556                        }
1557                        envs -= 1;
1558                    }
1559                }
1560                _ => {}
1561            }
1562            newlines = 0;
1563            i += 1;
1564        }
1565        false
1566    }
1567
1568    /// The delimited-math twin of [`Self::dollar_closes`]: `\[`/`\(` opens
1569    /// math only when its `\]`/`\)` is reachable. Macro code passes the
1570    /// delimiters around as data tokens — stacks-project feeds `\[` to a
1571    /// splitter (`\expandafter\@tempa\[\@nil`, issue #65) — so an opener with
1572    /// no reachable closer is an ordinary token, no math, **no diagnostic**
1573    /// (the shape is routine in code, so it is not statically an error; a
1574    /// likely-typo unclosed `\[` in prose is linter territory, exactly as for
1575    /// `$`). Same blockers as `dollar_closes`, mirroring
1576    /// [`Self::delim_math`]'s recovery anchors: an unbalanced `}`, an `\end`
1577    /// not owed to an intervening `\begin`, a paragraph break, the macrocode
1578    /// chunk end, EOF. The closer counts only outside `{…}` nesting, and a
1579    /// paragraph break blocks only at the math body's own level
1580    /// ([`Self::paragraph_break_blocks`]).
1581    fn delim_math_closes(&self, open: usize, closer: &str) -> bool {
1582        let mut depth = 0usize;
1583        let mut envs = 0usize;
1584        let mut newlines = 0;
1585        let end = self
1586            .macrocode_end
1587            .unwrap_or(self.tokens.len())
1588            .min(self.tokens.len());
1589        let mut i = open + 1;
1590        while i < end {
1591            let t = &self.tokens[i];
1592            match t.kind {
1593                SyntaxKind::NEWLINE => {
1594                    newlines += 1;
1595                    if newlines >= 2 && Self::paragraph_break_blocks(depth, envs) {
1596                        return false;
1597                    }
1598                    i += 1;
1599                    continue;
1600                }
1601                SyntaxKind::WHITESPACE | SyntaxKind::DOC_MARGIN | SyntaxKind::GUARD => {
1602                    i += 1;
1603                    continue;
1604                }
1605                SyntaxKind::L_BRACE if !self.plain_braces.contains(&i) => depth += 1,
1606                SyntaxKind::R_BRACE if !self.plain_braces.contains(&i) => {
1607                    if depth == 0 {
1608                        return false;
1609                    }
1610                    depth -= 1;
1611                }
1612                SyntaxKind::CONTROL_SYMBOL if depth == 0 && t.text.as_str() == closer => {
1613                    return true;
1614                }
1615                SyntaxKind::CONTROL_WORD if !self.in_macro_code(i) => {
1616                    if t.text.as_str() == BEGIN_CMD && self.env_name_follows(i) {
1617                        envs += 1;
1618                    } else if t.text.as_str() == END_CMD && self.env_name_follows(i) {
1619                        if envs == 0 {
1620                            return false;
1621                        }
1622                        envs -= 1;
1623                    }
1624                }
1625                _ => {}
1626            }
1627            newlines = 0;
1628            i += 1;
1629        }
1630        false
1631    }
1632
1633    /// The `\left…\right` twin of [`Self::delim_math_closes`]: whether the
1634    /// `\left` at token index `open` has a matching `\right` reachable before a
1635    /// token that would end its body. `\left`/`\right` pair by *count* (nested
1636    /// pairs recurse in [`Self::left_right`]), so — unlike `$`/`\[` which are
1637    /// often data in code — an unclosed `\left` is genuinely malformed math, but
1638    /// it is still a *likely-typo* the linter should flag, never a parser error
1639    /// that blocks the whole file for the formatter (issue #77's
1640    /// `\left(1 …) …\left(…\right)` and `\left\bra …` with no `\right`). So it
1641    /// gets the same shape gate as `\[`: a `\left` whose `\right` is unreachable
1642    /// stays an ordinary command, **no diagnostic**. Mirrors [`Self::left_right`]'s
1643    /// recovery anchors — an unbalanced `}`, a closing `$`/`\]`/`\)`, an `\end`
1644    /// not owed to an intervening `\begin`, a paragraph break, EOF — with `\right`
1645    /// and the anchors counting only at the `\left`'s own brace/env/pair level.
1646    /// Does not consume.
1647    fn left_right_closes(&self, open: usize) -> bool {
1648        #[derive(PartialEq)]
1649        enum Ctx {
1650            Brace,
1651            Env,
1652            Left,
1653        }
1654        let mut stack: Vec<Ctx> = Vec::new();
1655        let mut newlines = 0;
1656        let end = self
1657            .macrocode_end
1658            .unwrap_or(self.tokens.len())
1659            .min(self.tokens.len());
1660        let mut i = open + 1;
1661        while i < end {
1662            let t = &self.tokens[i];
1663            // A brace group parses as [`Self::math_group`]: only its own braces
1664            // steer nesting; every other token inside it is ordinary content
1665            // (a stray `\right`/`\end` there is not our closer).
1666            if stack.last() == Some(&Ctx::Brace) {
1667                match t.kind {
1668                    SyntaxKind::L_BRACE if !self.plain_braces.contains(&i) => {
1669                        stack.push(Ctx::Brace)
1670                    }
1671                    SyntaxKind::R_BRACE if !self.plain_braces.contains(&i) => {
1672                        stack.pop();
1673                    }
1674                    _ => {}
1675                }
1676                i += 1;
1677                continue;
1678            }
1679            match t.kind {
1680                SyntaxKind::NEWLINE => {
1681                    newlines += 1;
1682                    // A paragraph break ends the body only at its own level
1683                    // (mirrors [`Self::left_right`]); inside a nested env/pair it
1684                    // is ordinary trivia.
1685                    if newlines >= 2 && stack.is_empty() {
1686                        return false;
1687                    }
1688                    i += 1;
1689                    continue;
1690                }
1691                SyntaxKind::WHITESPACE | SyntaxKind::DOC_MARGIN | SyntaxKind::GUARD => {
1692                    i += 1;
1693                    continue;
1694                }
1695                SyntaxKind::L_BRACE if !self.plain_braces.contains(&i) => stack.push(Ctx::Brace),
1696                SyntaxKind::R_BRACE if !self.plain_braces.contains(&i) => return false,
1697                SyntaxKind::DOLLAR => return false,
1698                SyntaxKind::CONTROL_SYMBOL if matches!(t.text.as_str(), "\\]" | "\\)") => {
1699                    return false;
1700                }
1701                // `\left`/`\right` are catcode-neutral math structure that pair by
1702                // *count* no matter what — [`Self::left_right`] consumes them
1703                // unconditionally — so they must be counted even inside macro code.
1704                // A `.dtx` `macrocode` chunk (which sets `in_def_body`) or a `\def`
1705                // body is exactly where package math like `$\left#2\right#4$`
1706                // (delarray.dtx) or `$\left(…\right)$` (ltmath.dtx's `\bordermatrix`)
1707                // lives; gating these on `!in_macro_code` left the `\right`
1708                // invisible to the scan, so the pair never opened and the closer
1709                // reported a spurious "`\right` without matching `\left`" that
1710                // blocked the whole file for the formatter (issue #95).
1711                SyntaxKind::CONTROL_WORD if t.text.as_str() == LEFT_CMD => stack.push(Ctx::Left),
1712                SyntaxKind::CONTROL_WORD if t.text.as_str() == RIGHT_CMD => match stack.last() {
1713                    None => return true,
1714                    Some(Ctx::Left) => {
1715                        stack.pop();
1716                    }
1717                    _ => return false,
1718                },
1719                // `\begin`/`\end` are plain, non-pairing commands in macro code, so
1720                // they stay gated (`AGENTS.md` decision #1).
1721                SyntaxKind::CONTROL_WORD if !self.in_macro_code(i) => match t.text.as_str() {
1722                    BEGIN_CMD if self.env_name_follows(i) => stack.push(Ctx::Env),
1723                    END_CMD if self.env_name_follows(i) => match stack.last() {
1724                        Some(Ctx::Env) => {
1725                            stack.pop();
1726                        }
1727                        _ => return false,
1728                    },
1729                    _ => {}
1730                },
1731                _ => {}
1732            }
1733            newlines = 0;
1734            i += 1;
1735        }
1736        false
1737    }
1738
1739    /// Inline `$ … $` or display `$$ … $$` math. The body's atoms are wrapped in
1740    /// a `MATH` node (the delimiters stay direct children of the math node); the
1741    /// atoms themselves are parsed in math mode (see [`Self::math_element`]).
1742    /// Entry is gated by [`Self::dollar_closes`]: the caller has already
1743    /// verified a closer is reachable, so the unclosed-math recovery paths
1744    /// below fire only for shapes the gate scan cannot see (they remain as
1745    /// belt-and-braces recovery, never the expected path).
1746    fn dollar_math(&mut self) {
1747        let display = self.nth_kind(1) == Some(SyntaxKind::DOLLAR);
1748        let (kind, label) = if display {
1749            (SyntaxKind::DISPLAY_MATH, "$$")
1750        } else {
1751            (SyntaxKind::INLINE_MATH, "$")
1752        };
1753        let opener = (
1754            self.starts[self.pos],
1755            self.starts[self.pos + if display { 2 } else { 1 }],
1756        );
1757        self.open(kind);
1758        self.bump(); // $
1759        if display {
1760            self.bump(); // second $
1761        }
1762        self.open(SyntaxKind::MATH);
1763        self.math_depth += 1;
1764        self.math_dollar.push(true);
1765        loop {
1766            match self.kind() {
1767                None => {
1768                    self.error_at(opener, format!("unclosed `{label}`"));
1769                    break;
1770                }
1771                // `}` and `\end` are recovery anchors: `$`-math cannot span a
1772                // group or environment boundary, so a `}` here closes the
1773                // enclosing group (a math subgroup would have entered via `{`)
1774                // and a `\end` belongs to an enclosing environment. Leave the
1775                // token for the caller and report the unclosed math.
1776                Some(SyntaxKind::R_BRACE) => {
1777                    self.error_at(opener, format!("unclosed `{label}`"));
1778                    break;
1779                }
1780                Some(SyntaxKind::CONTROL_WORD)
1781                    if self.at_command(END_CMD) && self.env_name_follows(self.pos) =>
1782                {
1783                    self.error_at(opener, format!("unclosed `{label}`"));
1784                    break;
1785                }
1786                Some(SyntaxKind::DOLLAR) => {
1787                    if display && self.nth_kind(1) != Some(SyntaxKind::DOLLAR) {
1788                        // A lone `$` inside `$$`: malformed; emit and continue.
1789                        self.bump();
1790                        continue;
1791                    }
1792                    // The closing delimiter belongs to the math node, not its
1793                    // body: break and bump it after closing `MATH`.
1794                    break;
1795                }
1796                _ => {
1797                    if self.at_paragraph_break() {
1798                        // Faithful to TeX: a blank line is a `\par`, and `\par`
1799                        // in math mode is "Missing $ inserted" — even inside an
1800                        // alignment cell (#35). Name the cause so the opener
1801                        // span isn't read as a bogus report.
1802                        self.error_at(
1803                            opener,
1804                            format!("unclosed `{label}` (a blank line ends math)"),
1805                        );
1806                        break;
1807                    }
1808                    self.math_element();
1809                }
1810            }
1811        }
1812        self.math_depth -= 1;
1813        self.math_dollar.pop();
1814        self.close(); // MATH
1815        if self.kind() == Some(SyntaxKind::DOLLAR) {
1816            self.bump(); // closing $
1817            if display {
1818                self.bump(); // second closing $
1819            }
1820        }
1821        self.close(); // INLINE_MATH / DISPLAY_MATH
1822    }
1823
1824    /// Delimited math: `\[ … \]` (display) or `\( … \)` (inline). As with
1825    /// [`Self::dollar_math`], the body's atoms are wrapped in a `MATH` node and
1826    /// parsed in math mode.
1827    fn delim_math(&mut self, kind: SyntaxKind, opener: &str, closer: &str) {
1828        let opener_span = self.token_span(self.pos);
1829        self.open(kind);
1830        self.bump(); // \[ or \(
1831        self.open(SyntaxKind::MATH);
1832        self.math_depth += 1;
1833        self.math_dollar.push(false);
1834        loop {
1835            match self.kind() {
1836                None => {
1837                    self.error_at(opener_span, format!("unclosed `{opener}`"));
1838                    break;
1839                }
1840                Some(SyntaxKind::CONTROL_SYMBOL) if self.text() == closer => {
1841                    // The closer belongs to the math node, not its body.
1842                    break;
1843                }
1844                // A `}` closes an enclosing group: it cannot belong to this
1845                // math (a subgroup would have entered via `{`). Leave it for
1846                // the caller and report the unclosed math.
1847                Some(SyntaxKind::R_BRACE) => {
1848                    self.error_at(opener_span, format!("unclosed `{opener}`"));
1849                    break;
1850                }
1851                Some(SyntaxKind::CONTROL_WORD)
1852                    if self.at_command(END_CMD) && self.env_name_follows(self.pos) =>
1853                {
1854                    self.error_at(opener_span, format!("unclosed `{opener}`"));
1855                    break;
1856                }
1857                _ => {
1858                    if self.at_paragraph_break() {
1859                        // Same rationale as in `dollar_math`: `\par` ends math.
1860                        self.error_at(
1861                            opener_span,
1862                            format!("unclosed `{opener}` (a blank line ends math)"),
1863                        );
1864                        break;
1865                    }
1866                    self.math_element();
1867                }
1868            }
1869        }
1870        self.math_depth -= 1;
1871        self.math_dollar.pop();
1872        self.close(); // MATH
1873        if self.kind() == Some(SyntaxKind::CONTROL_SYMBOL) && self.text() == closer {
1874            self.bump(); // \] or \)
1875        }
1876        self.close(); // INLINE_MATH / DISPLAY_MATH
1877    }
1878
1879    /// One element inside a math body. Trivia is emitted inline (for
1880    /// losslessness); everything else is an atom, possibly carrying `^`/`_`
1881    /// scripts (see [`Self::math_scripted`]). Callers guard the math closers and
1882    /// recovery anchors before invoking this, so the cursor is at body content.
1883    fn math_element(&mut self) {
1884        match self.kind() {
1885            Some(
1886                SyntaxKind::WHITESPACE
1887                | SyntaxKind::NEWLINE
1888                | SyntaxKind::COMMENT
1889                | SyntaxKind::DOC_MARGIN
1890                | SyntaxKind::GUARD,
1891            ) => self.bump(),
1892            _ => self.math_scripted(),
1893        }
1894    }
1895
1896    /// A base atom with any tightly-bound `^`/`_` scripts — the one sanctioned
1897    /// Pratt site (`AGENTS.md`, decision #3). Sub/superscripts are postfix with a
1898    /// single-atom right operand, so this is a base atom followed by a postfix
1899    /// loop, not full precedence climbing.
1900    ///
1901    /// We only wrap the base in a `SCRIPTED` node when a script actually
1902    /// attaches, so an unscripted atom stays a bare token/node (matching the
1903    /// `LINE_BREAK`-only-when-modifiers idiom). Because the base atom's extent is
1904    /// not known until parsed (a command greedily attaches its args), we parse it
1905    /// first and, if a script follows, retroactively splice a `SCRIPTED` start
1906    /// event in front of it — the event-stream analog of rust-analyzer's
1907    /// `precede`, done locally without touching the event layer.
1908    fn math_scripted(&mut self) {
1909        // A math `WORD` glued around operators (`a+2*1`) splits into separate
1910        // operand/operator atoms (`AGENTS.md`, decision #3). Only the trailing
1911        // piece is the scriptable base, so `a+2*1^5` binds `^5` to `1` (matching
1912        // TeX); the leading pieces are flat sibling atoms of the math body. This
1913        // is a byte-range split of the WORD's text, not a re-lex — see
1914        // [`split_math_word`].
1915        if self.kind() == Some(SyntaxKind::WORD)
1916            && let Some(pieces) = split_math_word(self.text())
1917        {
1918            let idx = self.pos;
1919            let (last, lead) = pieces.split_last().expect("split yields >= 2 pieces");
1920            for &(start, end) in lead {
1921                self.events.push(Event::SubTok { idx, start, end });
1922            }
1923            let checkpoint = self.events.len();
1924            self.events.push(Event::SubTok {
1925                idx,
1926                start: last.0,
1927                end: last.1,
1928            });
1929            self.pos += 1; // the whole WORD is consumed by its pieces
1930            self.math_scripts(checkpoint);
1931            return;
1932        }
1933        let checkpoint = self.events.len();
1934        self.math_atom();
1935        self.math_scripts(checkpoint);
1936    }
1937
1938    /// Attach any `^`/`_` scripts that follow the base atom emitted since
1939    /// `checkpoint`, retro-splicing a `SCRIPTED` wrapper in front of it (the
1940    /// event-stream analog of rust-analyzer's `precede`, done locally without
1941    /// touching the event layer). No script → the base stays a bare atom.
1942    fn math_scripts(&mut self, checkpoint: usize) {
1943        if !self.at_script() {
1944            return; // bare atom, no wrapper
1945        }
1946        self.events
1947            .insert(checkpoint, Event::Start(SyntaxKind::SCRIPTED));
1948        while self.at_script() {
1949            self.skip_trivia(); // trivia between base/scripts rides inside SCRIPTED
1950            let sub = self.kind() == Some(SyntaxKind::UNDERSCORE);
1951            self.open(if sub {
1952                SyntaxKind::SUBSCRIPT
1953            } else {
1954                SyntaxKind::SUPERSCRIPT
1955            });
1956            self.bump(); // `_` or `^`
1957            self.math_script_arg();
1958            self.close();
1959        }
1960        self.close(); // SCRIPTED
1961    }
1962
1963    /// True if a `^`/`_` script operator directly follows, skipping only
1964    /// `WHITESPACE`/`NEWLINE` (not a comment, which must end its line — so a
1965    /// script never binds across a comment) and not a blank line (a paragraph
1966    /// break ends the math).
1967    fn at_script(&self) -> bool {
1968        // `CommentMode::Stop`: a comment ends the line, so it stops the scan (and
1969        // is reported as the next meaningful token, which is not a script), rather
1970        // than being skipped as it is elsewhere. A blank line ends the math.
1971        let s = self.scan_trivia(self.pos, CommentMode::Stop);
1972        !s.saw_blank_line
1973            && matches!(
1974                s.next_kind,
1975                Some(SyntaxKind::CARET | SyntaxKind::UNDERSCORE)
1976            )
1977    }
1978
1979    /// A single base atom: a `{…}` group (parsed in math mode), a command with
1980    /// its greedily-attached arguments, an environment, a `\\` line break, or one
1981    /// ordinary token. Always consumes ≥1 token when the cursor is at content.
1982    fn math_atom(&mut self) {
1983        match self.kind() {
1984            Some(SyntaxKind::L_BRACE) => self.math_group(),
1985            Some(SyntaxKind::CONTROL_WORD) => {
1986                // Same definition-body/expl3-region and brace-less gates as
1987                // [`Self::element`] (issues #45/#60).
1988                if !self.in_macro_code(self.pos)
1989                    && self.at_command(BEGIN_CMD)
1990                    && self.env_name_follows(self.pos)
1991                {
1992                    self.environment();
1993                } else if !self.in_macro_code(self.pos)
1994                    && self.at_command(END_CMD)
1995                    && self.env_name_follows(self.pos)
1996                {
1997                    self.stray_end();
1998                } else if self.at_command(LEFT_CMD) && self.left_right_closes(self.pos) {
1999                    self.left_right();
2000                } else if self.at_command(RIGHT_CMD) {
2001                    self.stray_right();
2002                } else {
2003                    self.command();
2004                }
2005            }
2006            // `\\` line break (with its tightly-bound `*`/`[len]`) vs. a bare
2007            // control symbol (`\,`, `\;`, `\!`, spacing) — emit the latter as a
2008            // single token.
2009            Some(SyntaxKind::CONTROL_SYMBOL) if self.text() == "\\\\" => self.line_break(),
2010            // Any other single token (WORD, digit, `&`, `~`, `#`, brackets, a
2011            // bare control symbol, or a `^`/`_` with no base): one token, so the
2012            // loop always makes progress.
2013            Some(_) => self.bump(),
2014            None => {}
2015        }
2016    }
2017
2018    /// One script argument: a single atom (a `{…}` group, a command with its
2019    /// args, or one token). A missing argument (the next meaningful token is a
2020    /// closer, `\end`, a paragraph break, or EOF) is reported, not consumed —
2021    /// the closer must stay for the enclosing math loop.
2022    fn math_script_arg(&mut self) {
2023        if self.at_paragraph_break() {
2024            self.error("missing argument after `^`/`_`");
2025            return;
2026        }
2027        self.skip_trivia();
2028        let missing = match self.kind() {
2029            None | Some(SyntaxKind::R_BRACE | SyntaxKind::DOLLAR) => true,
2030            Some(SyntaxKind::CONTROL_SYMBOL) => matches!(self.text(), "\\]" | "\\)"),
2031            Some(SyntaxKind::CONTROL_WORD) => {
2032                self.at_command(END_CMD) && self.env_name_follows(self.pos)
2033            }
2034            _ => false,
2035        };
2036        if missing {
2037            self.error("missing argument after `^`/`_`");
2038            return;
2039        }
2040        self.math_atom();
2041    }
2042
2043    /// A brace group `{ … }` whose body is parsed in math mode (so `x^{a_b}`
2044    /// nests). Recovery mirrors [`Self::group`].
2045    fn math_group(&mut self) {
2046        debug_assert_eq!(self.kind(), Some(SyntaxKind::L_BRACE));
2047        let opener = self.token_span(self.pos);
2048        self.open(SyntaxKind::GROUP);
2049        self.bump(); // {
2050        self.group_depth += 1;
2051        self.group_opens.push(self.pos - 1);
2052        loop {
2053            match self.kind() {
2054                None => {
2055                    self.error_at(opener, "unclosed `{`");
2056                    break;
2057                }
2058                Some(SyntaxKind::R_BRACE) => {
2059                    self.bump();
2060                    break;
2061                }
2062                _ => self.math_element(),
2063            }
2064        }
2065        self.group_depth -= 1;
2066        self.group_opens.pop();
2067        self.close();
2068    }
2069
2070    /// A `\left<delim> … \right<delim>` matched delimiter pair (`AGENTS.md`,
2071    /// decision #3: the one precedence-climbing site — here just balanced
2072    /// matching by *count*, which is exactly how TeX pairs them, so a mismatched
2073    /// `\left( … \right]` still nests correctly). The `\left`/`\right` control
2074    /// words and their delimiter tokens are direct children (mirroring how `$` /
2075    /// `\[` delimiters stay direct children of the math node); the enclosed atoms
2076    /// are wrapped in a `MATH` body. Nested pairs recurse via [`Self::math_atom`].
2077    ///
2078    /// An unclosed `\left` recovers at the enclosing math/group/environment
2079    /// closer (the same anchors the surrounding math loop uses), leaving that
2080    /// token for the caller.
2081    fn left_right(&mut self) {
2082        debug_assert!(self.at_command(LEFT_CMD));
2083        let opener = self.token_span(self.pos);
2084        self.open(SyntaxKind::LEFT_RIGHT);
2085        self.bump(); // \left
2086        self.math_delim(LEFT_CMD);
2087        self.open(SyntaxKind::MATH);
2088        loop {
2089            match self.kind() {
2090                None => {
2091                    self.error_at(opener, "unclosed `\\left`");
2092                    break;
2093                }
2094                Some(SyntaxKind::CONTROL_WORD) if self.at_command(RIGHT_CMD) => break,
2095                // Enclosing-scope closers: `\left … \right` cannot span a group,
2096                // math, or environment boundary, so hand the token back.
2097                Some(SyntaxKind::R_BRACE | SyntaxKind::DOLLAR) => {
2098                    self.error_at(opener, "unclosed `\\left`");
2099                    break;
2100                }
2101                Some(SyntaxKind::CONTROL_SYMBOL) if matches!(self.text(), "\\]" | "\\)") => {
2102                    self.error_at(opener, "unclosed `\\left`");
2103                    break;
2104                }
2105                Some(SyntaxKind::CONTROL_WORD)
2106                    if self.at_command(END_CMD) && self.env_name_follows(self.pos) =>
2107                {
2108                    self.error_at(opener, "unclosed `\\left`");
2109                    break;
2110                }
2111                _ => {
2112                    if self.at_paragraph_break() {
2113                        self.error_at(opener, "unclosed `\\left`");
2114                        break;
2115                    }
2116                    self.math_element();
2117                }
2118            }
2119        }
2120        self.close(); // MATH
2121        if self.at_command(RIGHT_CMD) {
2122            self.bump(); // \right
2123            self.math_delim(RIGHT_CMD);
2124        }
2125        self.close(); // LEFT_RIGHT
2126    }
2127
2128    /// Consume the single delimiter token following `\left`/`\right`: skip inline
2129    /// trivia (it rides as a direct child of the pair for losslessness; the
2130    /// formatter drops it), then take one token. The lexer has already isolated a
2131    /// word-character delimiter (`(`, `|`, `.`, …) into its own token, so a single
2132    /// `bump` suffices. A missing delimiter — the next meaningful token is a
2133    /// closer, another `\left`/`\right`, `\end`, a paragraph break, or EOF — is
2134    /// reported, not consumed.
2135    fn math_delim(&mut self, after: &str) {
2136        self.skip_trivia();
2137        let missing = match self.kind() {
2138            None | Some(SyntaxKind::R_BRACE | SyntaxKind::DOLLAR) => true,
2139            Some(SyntaxKind::CONTROL_SYMBOL) => matches!(self.text(), "\\]" | "\\)"),
2140            Some(SyntaxKind::CONTROL_WORD) => {
2141                (self.at_command(END_CMD) && self.env_name_follows(self.pos))
2142                    || self.at_command(LEFT_CMD)
2143                    || self.at_command(RIGHT_CMD)
2144            }
2145            _ => false,
2146        };
2147        if missing {
2148            self.error(format!("missing delimiter after `{after}`"));
2149            return;
2150        }
2151        self.bump();
2152    }
2153
2154    /// A `\right` with no open `\left` (the math loop only reaches one here when
2155    /// it is unmatched). Report it and consume it with its delimiter so the parse
2156    /// stays lossless and makes progress.
2157    fn stray_right(&mut self) {
2158        debug_assert!(self.at_command(RIGHT_CMD));
2159        self.error("`\\right` without matching `\\left`");
2160        self.bump(); // \right
2161        self.math_delim(RIGHT_CMD);
2162    }
2163
2164    /// The environment twin of [`Self::delim_math_closes`]: whether the
2165    /// `\begin` at `open` is cut short by the closing brace of a group it sits
2166    /// *inside*, with no `\end` of its own reachable first.
2167    ///
2168    /// Brace groups are catcode-level structure while `\begin`/`\end` are only
2169    /// macros, so a `}` closing a group opened before the `\begin` always wins —
2170    /// the environment cannot span it. Package code leans on this constantly:
2171    /// the two halves sit in sibling groups
2172    /// (`\newcolumntype{w}[2]{>{\begin{lrbox}…}c<{\end{lrbox}…}}`, array.sty),
2173    /// in sibling macros (`\newcommand\BeginExample{…\begin{VerbatimOut}…}`
2174    /// paired with `\EndExample`, rotex.tex), or the `\begin` is prose in a
2175    /// message argument that never runs as structure
2176    /// (`\PackageError{amstex}{\string\begin{split} is not allowed…}`,
2177    /// amstex.sty — all issue #71). In each the `\begin` is an ordinary token:
2178    /// it opens no `ENVIRONMENT` and draws **no diagnostic**, the same shape
2179    /// gate `\[` already gets from [`Self::delim_math_closes`]. Without it the
2180    /// environment swallows the `}` and cascades into unmatched-brace noise
2181    /// that fails the whole file for the formatter.
2182    ///
2183    /// Only the *group boundary* suppresses the environment. A `\begin` that
2184    /// merely runs out of file still opens one, so the unclosed-environment
2185    /// diagnostic keeps firing on a genuinely forgotten `\end`. A `\end` of
2186    /// another name terminates the scan too, leaving the existing mismatch
2187    /// recovery in [`Self::finish_environment`] untouched. Does not consume.
2188    fn environment_escapes_group(&self, open: usize) -> bool {
2189        // Only a group the `\begin` is *actually* inside can cut it short. At
2190        // the outer level there is no such brace, and a later unbalanced `}`
2191        // is somebody else's business — notably a `.dtx` doc-line
2192        // `\begin{macro}`, whose intervening `macrocode` chunks split
2193        // definitions across braces on purpose ([`Self::plain_braces`], only
2194        // populated once that chunk is entered). Without this guard the scan
2195        // reads those as its own boundary and unnests the whole doc layer.
2196        if self.group_depth == 0 {
2197            return false;
2198        }
2199        // `.dtx` doc-margin lines are exempt, exactly as they are from the
2200        // expl3 carve-out ([`Self::expl_toggles`]): `\begin{macro}` and friends
2201        // are the *documentation* layer and must keep pairing across the
2202        // macrocode chunks between them. Those bodies routinely span code that
2203        // leaves a brace open on purpose — a `\iffalse}\fi` editor-balance
2204        // hack, a `` \char`} `` constant, a catcode-swapped region — which
2205        // strands `group_depth` above zero for the rest of the file and would
2206        // otherwise unnest the whole doc layer behind it. (A paragraph-break
2207        // bound cannot stand in here: a blank `.dtx` doc line is still a `%`
2208        // margin, so it never reads as a `\par`.)
2209        //
2210        // The exemption is about *stranded* braces, so it lifts when the
2211        // enclosing group opened on a doc-margin line too: that `{` is the
2212        // documentation layer's own, locally visible, and the `\begin` really is
2213        // inside it. `% \def\deflist#1{\begin{list}…}` paired with
2214        // `% \def\enddeflist{\end{list}}` (theorem.dtx, issue #71) is the split
2215        // environment definition the gate exists for, merely written as doc
2216        // prose.
2217        if self.doc_margin_exempt(open) {
2218            return false;
2219        }
2220        let mut depth = 0usize;
2221        let mut envs = 0usize;
2222        let end = self
2223            .macrocode_end
2224            .unwrap_or(self.tokens.len())
2225            .min(self.tokens.len());
2226        let mut i = open + 1;
2227        while i < end {
2228            let t = &self.tokens[i];
2229            match t.kind {
2230                // The `{name}` group of this very `\begin` nests and unnests
2231                // here, so the scan resumes at the environment's own level.
2232                SyntaxKind::L_BRACE if !self.plain_braces.contains(&i) => depth += 1,
2233                SyntaxKind::R_BRACE if !self.plain_braces.contains(&i) => {
2234                    if depth == 0 {
2235                        return true;
2236                    }
2237                    depth -= 1;
2238                }
2239                SyntaxKind::CONTROL_WORD if depth == 0 && !self.in_macro_code(i) => {
2240                    if t.text.as_str() == BEGIN_CMD && self.env_name_follows(i) {
2241                        envs += 1;
2242                    } else if t.text.as_str() == END_CMD && self.env_name_follows(i) {
2243                        if envs == 0 {
2244                            return false;
2245                        }
2246                        envs -= 1;
2247                    }
2248                }
2249                _ => {}
2250            }
2251            i += 1;
2252        }
2253        false
2254    }
2255
2256    /// `\begin{name} … \end{name}`, with environment-mismatch recovery.
2257    fn environment(&mut self) {
2258        self.open(SyntaxKind::ENVIRONMENT);
2259
2260        let begin_pos = self.pos;
2261        let begin_start = self.starts[self.pos];
2262        self.open(SyntaxKind::BEGIN);
2263        self.bump(); // \begin
2264        let name = self.name_group();
2265        // Span of the opener `\begin{name}` (before any trailing arguments), so
2266        // an unclosed environment points back at the `\begin`, not at EOF.
2267        let opener = (begin_start, self.starts[self.pos]);
2268        // A frame-lexed `.dtx` macrocode `\begin` (it rides a `DOC_MARGIN`, so
2269        // this never fires on a stray `\begin{macrocode}` in a plain document).
2270        // The frame line holds nothing but the name (`lex_macrocode_frame`), so
2271        // it takes *no* arguments — the next line's `{` is body macro code, not
2272        // an attachment — and the body routes to `macrocode_body` below.
2273        let macrocode_frame = name
2274            .as_deref()
2275            .is_some_and(|n| matches!(n, "macrocode" | "macrocode*"))
2276            && self.frame_margin_before(begin_pos);
2277        // `\begin{tabular}{ll}`, `[options]`, etc. A curated math environment's
2278        // body starts right after its `\begin`, so only a directly-abutting
2279        // `[t]`-style optional attaches; a detached bracket is body content
2280        // (`\begin{align}` + newline + `[\partial_\mu V]_1`, issue #43).
2281        let bracket = if name.as_deref().is_some_and(is_math_environment) {
2282            BracketPolicy::Tight
2283        } else {
2284            BracketPolicy::Greedy
2285        };
2286        if !macrocode_frame {
2287            self.attach_arguments(bracket);
2288        }
2289        self.close(); // BEGIN
2290
2291        if let Some(open) = name.as_deref() {
2292            self.open_envs.push(open.to_owned());
2293        }
2294        if name
2295            .as_deref()
2296            .is_some_and(|n| self.ctx.is_verbatim_environment(n))
2297        {
2298            self.verbatim_body(name.as_deref().expect("verbatim name"));
2299        } else if name.as_deref().is_some_and(is_math_environment) {
2300            self.math_environment_body();
2301        } else if macrocode_frame {
2302            // A frame-lexed macrocode body is macro code, not document
2303            // structure (see `macrocode_frame` above).
2304            self.macrocode_body(name.as_deref().expect("macrocode name"));
2305        } else {
2306            self.parse_block(Block::Environment);
2307        }
2308        if name.is_some() {
2309            self.open_envs.pop();
2310        }
2311        self.finish_environment(&name, opener);
2312    }
2313
2314    /// True if the token at `pos` sits on a `.dtx` frame line: walking back over
2315    /// inline whitespace, the preceding token is a `DOC_MARGIN`. Margins never
2316    /// occur *inside* a macrocode body (code lines own their `%`), so this
2317    /// fingerprint distinguishes the frame `\begin`/`\end{macrocode}` from any
2318    /// look-alike in the code. Pinned by
2319    /// `macrocode_frame_margins_sit_where_the_formatter_expects` (`tests/dtx.rs`).
2320    fn frame_margin_before(&self, pos: usize) -> bool {
2321        let mut i = pos;
2322        while i > 0 {
2323            i -= 1;
2324            match self.tokens[i].kind {
2325                SyntaxKind::WHITESPACE => continue,
2326                SyntaxKind::DOC_MARGIN => return true,
2327                _ => return false,
2328            }
2329        }
2330        false
2331    }
2332
2333    /// The body of a `.dtx` `macrocode`/`macrocode*` environment: macro code
2334    /// whose one true terminator is the frame line (`%    \end{macrocode}`),
2335    /// a line-oriented docstrip fact. TeX places no balance requirements on the
2336    /// chunk — a definition regularly opens a brace in one chunk and closes it
2337    /// several chunks later, and kernel code uses the `\end` primitive — so,
2338    /// like the definition bodies of decision #1 (issues #45/#55):
2339    /// - `\begin`/`\end` inside parse as plain commands ([`Self::in_def_body`]),
2340    /// - chunk-unmatched braces are plain tokens with no diagnostics
2341    ///   ([`Self::plain_braces`]; matched pairs still parse as `GROUP`s),
2342    /// - a `[` attaches as an optional only when it closes inside the chunk.
2343    ///
2344    /// The terminator is pre-scanned here (the first `\end` on a margin whose
2345    /// name matches — [`Self::frame_margin_before`]) and parsing stops
2346    /// positionally at it ([`Block::Macrocode`]); [`Self::finish_environment`]
2347    /// then consumes and name-checks it as usual. Nesting is impossible (the
2348    /// lexer never opens a frame inside a body), but state is saved/restored
2349    /// anyway so a malformed tree cannot leak it.
2350    fn macrocode_body(&mut self, name: &str) {
2351        let mut end = self.tokens.len();
2352        for i in self.pos..self.tokens.len() {
2353            if self.tokens[i].kind == SyntaxKind::CONTROL_WORD
2354                && self.tokens[i].text == END_CMD
2355                && self.frame_margin_before(i)
2356                && peek_end_name(self.tokens, i).as_deref() == Some(name)
2357            {
2358                end = i;
2359                break;
2360            }
2361        }
2362
2363        let saved_plain = std::mem::take(&mut self.plain_braces);
2364        let saved_end = self.macrocode_end;
2365        let saved_def = self.in_def_body;
2366
2367        let mut open_stack = Vec::new();
2368        for i in self.pos..end {
2369            match self.tokens[i].kind {
2370                SyntaxKind::L_BRACE => open_stack.push(i),
2371                SyntaxKind::R_BRACE if open_stack.pop().is_none() => {
2372                    self.plain_braces.insert(i);
2373                }
2374                _ => {}
2375            }
2376        }
2377        self.plain_braces.extend(open_stack);
2378        self.macrocode_end = Some(end);
2379        self.in_def_body = true;
2380
2381        self.parse_block(Block::Macrocode);
2382
2383        self.plain_braces = saved_plain;
2384        self.macrocode_end = saved_end;
2385        self.in_def_body = saved_def;
2386    }
2387
2388    /// Consume the matching `\end`, or recover. `parse_block` / `verbatim_body`
2389    /// leave the cursor at a `\end` or at EOF.
2390    fn finish_environment(&mut self, name: &Option<String>, opener: (usize, usize)) {
2391        match self.kind() {
2392            None => {
2393                self.error_at(
2394                    opener,
2395                    format!("unclosed environment `{}`", name.as_deref().unwrap_or("")),
2396                );
2397            }
2398            // The cursor is at a `\end` (the only non-EOF stop condition).
2399            Some(_) => {
2400                let end_name = peek_end_name(self.tokens, self.pos);
2401                if name.is_none() || *name == end_name {
2402                    // Matching \end: consume it as our END.
2403                    self.open(SyntaxKind::END);
2404                    self.bump(); // \end
2405                    self.name_group();
2406                    self.close();
2407                } else {
2408                    // Mismatched \end: it belongs to an enclosing environment.
2409                    // Close this one with a diagnostic and leave the \end for
2410                    // the caller (this unwinds the stack until some level
2411                    // matches, or it becomes a stray \end at the root).
2412                    self.error_at(
2413                        opener,
2414                        format!(
2415                            "unclosed environment `{}` (found `\\end{{{}}}`)",
2416                            name.as_deref().unwrap_or(""),
2417                            end_name.as_deref().unwrap_or("")
2418                        ),
2419                    );
2420                }
2421            }
2422        }
2423        self.close(); // ENVIRONMENT
2424    }
2425
2426    /// The body of a named math environment (`equation`, `align`, `gather`, …): its
2427    /// atoms wrapped in a `MATH` node and parsed in math mode, exactly as `\[…\]`
2428    /// (see [`Self::delim_math`]) — so `^`/`_` build `SCRIPTED` nodes, the operator
2429    /// split fires, and `\left…\right` pair. Routed here for environments the
2430    /// built-in signature DB flags `math` ([`is_math_environment`]).
2431    ///
2432    /// The terminator is the matching `\end` (or EOF), read via [`Self::at_block_end`]
2433    /// just like [`Self::parse_block`]; [`Self::finish_environment`] then consumes and
2434    /// name-checks it. Unlike `$`-math (where a `\end` is an *unclosed*-recovery
2435    /// anchor), `\end` is the normal, expected terminator here. A blank line inside the
2436    /// body stays trivia within the `MATH` node — no paragraph split — so losslessness
2437    /// holds. Progress is guaranteed: [`Self::math_element`] bumps trivia or descends
2438    /// into [`Self::math_scripted`], whose atom parser always consumes a token.
2439    fn math_environment_body(&mut self) {
2440        self.open(SyntaxKind::MATH);
2441        self.math_depth += 1;
2442        self.math_dollar.push(false);
2443        while !self.at_block_end(Block::Environment) {
2444            self.math_element();
2445        }
2446        self.math_depth -= 1;
2447        self.math_dollar.pop();
2448        self.close(); // MATH
2449    }
2450
2451    /// The raw body of a verbatim-like environment: consume tokens unstructured
2452    /// until the matching `\end{name}`. The lexer has already collapsed the body
2453    /// into a single `VERBATIM_BODY` token; this loop also serves as a fallback.
2454    fn verbatim_body(&mut self, name: &str) {
2455        loop {
2456            match self.kind() {
2457                None => break,
2458                Some(SyntaxKind::CONTROL_WORD)
2459                    if self.at_command(END_CMD)
2460                        && peek_end_name(self.tokens, self.pos).as_deref() == Some(name) =>
2461                {
2462                    break;
2463                }
2464                _ => self.bump(),
2465            }
2466        }
2467    }
2468
2469    /// A `\end` with no matching open environment at this level.
2470    fn stray_end(&mut self) {
2471        self.error("`\\end` without matching `\\begin`");
2472        self.open(SyntaxKind::END);
2473        self.bump(); // \end
2474        self.name_group();
2475        self.close();
2476    }
2477
2478    /// The `{name}` group following `\begin` / `\end`. Returns the trimmed name.
2479    fn name_group(&mut self) -> Option<String> {
2480        self.skip_trivia();
2481        if self.kind() != Some(SyntaxKind::L_BRACE) {
2482            self.error("expected `{` for environment name");
2483            return None;
2484        }
2485        self.open(SyntaxKind::NAME_GROUP);
2486        self.bump(); // {
2487        let mut name = String::new();
2488        loop {
2489            match self.kind() {
2490                None => {
2491                    self.error("unclosed environment name");
2492                    break;
2493                }
2494                Some(SyntaxKind::R_BRACE) => {
2495                    self.bump();
2496                    break;
2497                }
2498                _ => {
2499                    name.push_str(self.text());
2500                    self.bump();
2501                }
2502            }
2503        }
2504        self.close();
2505        Some(name.trim().to_owned())
2506    }
2507}
2508
2509/// Split a math `WORD`'s text at operator boundaries into `[start, end)` byte
2510/// ranges covering the whole text, or `None` when it holds no operator (a single
2511/// operand run needs no split). Operators are catcode-12 "other" characters that
2512/// glue into `WORD` (`a+2*1`); isolating them lets the math-aware parser and
2513/// formatter treat them as atoms (spacing, line breaks) without a catcode-carrying
2514/// lexer. The rule:
2515///
2516/// - `+ - * /`: each is its own single-char piece (so `2*-1` → `2`,`*`,`-`,`1`,
2517///   letting the formatter read a leading `-`/`+` as unary).
2518/// - `= < >`: a maximal run coalesces into one piece (`<=`, `>=`, `==` stay
2519///   together), but never merges with an adjacent sign (`=-` → `=`,`-`).
2520/// - anything else: a maximal operand run.
2521///
2522/// The pieces concatenate back to the input, preserving losslessness.
2523fn split_math_word(text: &str) -> Option<Vec<(usize, usize)>> {
2524    #[derive(PartialEq, Clone, Copy)]
2525    enum Cls {
2526        Operand,
2527        /// `+ - * /`: always its own single-char piece.
2528        Sign,
2529        /// `= < >`: coalescing relation run.
2530        Rel,
2531    }
2532    let classify = |c: char| match c {
2533        '+' | '-' | '*' | '/' => Cls::Sign,
2534        '=' | '<' | '>' => Cls::Rel,
2535        _ => Cls::Operand,
2536    };
2537    let mut pieces = Vec::new();
2538    let mut start = 0;
2539    let mut prev: Option<Cls> = None;
2540    for (i, c) in text.char_indices() {
2541        let cls = classify(c);
2542        // Break before this char when the class changed, or when either side is a
2543        // sign (each sign stands alone). A same-class run (operand/operand or
2544        // rel/rel) coalesces.
2545        let boundary = prev.is_some_and(|p| p != cls || cls == Cls::Sign);
2546        if boundary {
2547            pieces.push((start, i));
2548            start = i;
2549        }
2550        prev = Some(cls);
2551    }
2552    pieces.push((start, text.len()));
2553    (pieces.len() >= 2).then_some(pieces)
2554}
2555
2556/// Read the environment name from a `\begin{…}` at `begin_pos` without consuming.
2557/// Identical in shape to [`peek_end_name`] (skip the control word and trivia, then
2558/// read the `{name}` group); named separately for call-site clarity.
2559fn peek_begin_name(tokens: &[Token], begin_pos: usize) -> Option<String> {
2560    peek_end_name(tokens, begin_pos)
2561}
2562
2563/// Read the environment name from a `\end{…}` at `end_pos` without consuming.
2564fn peek_end_name(tokens: &[Token], end_pos: usize) -> Option<String> {
2565    let mut i = end_pos + 1; // past the \end control word
2566    while tokens.get(i).is_some_and(|t| Parser::is_trivia(t.kind)) {
2567        i += 1;
2568    }
2569    if tokens.get(i).map(|t| t.kind) != Some(SyntaxKind::L_BRACE) {
2570        return None;
2571    }
2572    i += 1;
2573    let mut name = String::new();
2574    while let Some(t) = tokens.get(i) {
2575        if t.kind == SyntaxKind::R_BRACE {
2576            break;
2577        }
2578        name.push_str(&t.text);
2579        i += 1;
2580    }
2581    Some(name.trim().to_owned())
2582}
2583
2584#[cfg(test)]
2585mod tests {
2586    use super::*;
2587    use crate::parser::lexer::lex;
2588
2589    /// The stuck-loop guard aborts once `PARSER_STEP_LIMIT` peeks accrue with no
2590    /// cursor advance, turning a hypothetical non-advancing loop into a loud
2591    /// panic instead of a hang.
2592    #[test]
2593    fn step_guard_trips_when_wedged() {
2594        let tokens = lex("x");
2595        let ctx = VerbCtx::default();
2596        let p = Parser::new(&tokens, &ctx);
2597        // Park one tick short of the ceiling with the cursor pinned, so no reset
2598        // fires on the next peeks.
2599        p.last_step_pos.set(p.pos);
2600        p.steps.set(PARSER_STEP_LIMIT - 1);
2601        p.step(); // reaches the ceiling exactly — still allowed
2602        let wedged = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| p.step()));
2603        assert!(wedged.is_err(), "the guard must abort a non-advancing loop");
2604    }
2605
2606    /// A real cursor advance resets the budget, so an arbitrarily long *advancing*
2607    /// parse never trips the guard.
2608    #[test]
2609    fn step_budget_resets_on_cursor_progress() {
2610        let tokens = lex("xx");
2611        let ctx = VerbCtx::default();
2612        let mut p = Parser::new(&tokens, &ctx);
2613        p.last_step_pos.set(p.pos);
2614        p.steps.set(PARSER_STEP_LIMIT - 1);
2615        // Advance the cursor as a real consume would; the next peek must reset.
2616        p.pos += 1;
2617        p.step();
2618        assert_eq!(p.steps.get(), 1, "progress should reset the peek budget");
2619    }
2620}