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