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