Skip to main content

badness_parser/parser/
lexer.rs

1//! A total, lossless lexer for LaTeX surface syntax.
2//!
3//! Every byte of the input ends up in exactly one token, so concatenating all
4//! token texts reproduces the input verbatim — the losslessness invariant. The
5//! lexer is mostly context-free, with a small set of statically recognizable modes:
6//!
7//! - **`\verb` / `\verb*`** inline verbatim: the delimited argument is consumed
8//!   as a single [`SyntaxKind::VERB`] token (otherwise the delimiters glue into
9//!   ordinary `WORD` runs and become un-splittable downstream).
10//! - **verbatim-like environments** (`verbatim`, `lstlisting`, `minted`, …): the
11//!   body between `\begin{name}` and `\end{name}` is one
12//!   [`SyntaxKind::VERBATIM_BODY`] token, so `%`, `$`, `\` inside are never
13//!   (mis)lexed as comments / math. For argument-taking ones the `\begin`
14//!   arguments are tokenized first (the built-in signature DB says where the raw
15//!   body starts); see [`lex_verbatim_environment`].
16//! - **`\makeatletter` / `\makeatother`**: toggles `@` into a letter so that
17//!   `\foo@bar` lexes as one control word.
18//! - **`\ExplSyntaxOn` / `\ExplSyntaxOff`** (also opened by `\ProvidesExplPackage`
19//!   / `\ProvidesExplClass` / `\ProvidesExplFile`): toggles `_` and `:` into
20//!   letters so expl3 names (`\seq_new:N`, `\__module_internal:nn`) lex as one
21//!   control word. Composes with `\makeatletter` for the `@@` module-prefix
22//!   convention (`\g_@@_frame_title_tl`).
23//! - **`\left` / `\right` delimiters**: the single delimiter that follows is
24//!   isolated as its own token, so a word-character delimiter (`(`, `)`, `|`,
25//!   `/`, `.`, `<`, `>`) does not glue into the following word run and become
26//!   un-splittable downstream (the same problem `\verb` has). Control-symbol /
27//!   control-word / bracket delimiters already lex as single tokens.
28//!
29//! None of these resolve macro meaning; they are surface lexing concerns (in
30//! TeX, catcodes genuinely change in these regions).
31
32use std::collections::{HashMap, HashSet};
33
34use smol_str::SmolStr;
35
36use crate::semantic::signature::{ArgKind, ArgSpec, EnvironmentSig, builtin};
37use crate::syntax::SyntaxKind;
38
39/// A single lexed token: its kind plus the exact source slice it covers.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct Token {
42    pub kind: SyntaxKind,
43    pub text: SmolStr,
44}
45
46/// The LaTeX file flavor, fixing the lexer's *initial* catcode regime. A
47/// document (`.tex`) starts in the ordinary regime; a package or class
48/// (`.sty`/`.cls`) is loaded under an implicit `\makeatletter`, so `@` is a
49/// letter from the first byte. A trailing explicit `\makeatother` still applies.
50#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
51pub enum LatexFlavor {
52    /// A `.tex` document: ordinary catcodes at the start.
53    #[default]
54    Document,
55    /// A `.sty`/`.cls` package or class: `@` is a letter from the start.
56    Package,
57}
58
59impl LatexFlavor {
60    /// Whether the lexer should begin with `@` already a letter (the implicit
61    /// `\makeatletter` of a package/class load).
62    fn letter_mode_start(self) -> bool {
63        matches!(self, LatexFlavor::Package)
64    }
65}
66
67/// The lexer's per-parse mode. [`flavor`](Self::flavor) fixes the *initial*
68/// catcode regime (a `.sty`/`.cls` starts under an implicit `\makeatletter`),
69/// while [`dtx`](Self::dtx) is an orthogonal axis: when set, the lexer runs the
70/// bounded line-oriented docstrip mode for a `.dtx` file — line-leading `%`
71/// margins become [`DOC_MARGIN`](SyntaxKind::DOC_MARGIN) trivia, line-leading
72/// `%<…>` guards become [`GUARD`](SyntaxKind::GUARD) trivia, and `macrocode`
73/// bodies lex as ordinary code (`AGENTS.md` decision #1). The two axes are
74/// independent because a `.dtx`'s catcode regime varies *by layer* (its
75/// documentation is `Document`-flavored, its `macrocode` `Package`-flavored), so
76/// `dtx` cannot be folded into a [`LatexFlavor`] variant.
77#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
78pub struct LexConfig {
79    /// The initial catcode regime.
80    pub flavor: LatexFlavor,
81    /// Run the docstrip (`.dtx`) line-oriented lexer mode.
82    pub dtx: bool,
83}
84
85impl From<LatexFlavor> for LexConfig {
86    /// A plain (non-`.dtx`) config of the given flavor — the common case, so a
87    /// bare [`LatexFlavor`] coerces into a [`LexConfig`] at call sites.
88    fn from(flavor: LatexFlavor) -> Self {
89        Self { flavor, dtx: false }
90    }
91}
92
93/// Per-parse context carrying the facts the parser can only learn by first
94/// scanning the file's own definitions ([`crate::semantic::define`]) — the
95/// sanctioned second pass described in `parser::core`. Empty for the first pass;
96/// populated for the second when the document defines any. Both the lexer and the
97/// grammar read it, so the two can never disagree about what a name is.
98///
99/// It carries two families of fact, each read from static definition surface only
100/// (no macro meaning, per `AGENTS.md` Core decision #1):
101///
102/// 1. *User-defined verbatim constructs* — those a document declares with catcode
103///    manipulation (`\@makeother\$`, …). The lexer consults these (alongside the
104///    built-in DB) to capture a verbatim *command*'s final argument as one `VERB`
105///    token, and a verbatim *environment*'s body as one `VERBATIM_BODY` token.
106/// 2. *Environment aliases* — a command whose definition body is exactly
107///    `\begin{X}`/`\end{X}`, so `\bea … \eea` pairs as an `ENVIRONMENT` of `X`
108///    (issue #109). The grammar consults these; the lexer does not.
109///
110/// A command entry maps a name (no leading `\`) to its *leading*, non-verbatim
111/// argument shape, the verbatim argument itself being implicit — matching the built-in
112/// convention. An environment entry maps a name to its full argument shape (an
113/// environment's args are all leading; its body follows the `\begin{…}` arguments), so
114/// presence in `environments` means the environment is verbatim.
115///
116/// `suppressed` names the inverse case: commands the current file *redefines* to an
117/// ordinary (non-verbatim) macro whose name collides with a built-in raw-argument
118/// command (`\code`, `\url`, `\href`, …). A local definition shadows the built-in, so
119/// [`lex_verbatim_command`] must lex `\code{…}` as an ordinary group rather than capture
120/// the built-in `VERB` (follow-up to issue #53). We read only static definition facts (a
121/// visible `\newcommand`/`\def` with no catcode signal), never macro meaning.
122/// `PartialEq` is load-bearing rather than incidental: `parser::core` decides
123/// whether the second pass has anything to do by comparing the scanned context
124/// against the declaration seed it started from, which stays correct as fields
125/// are added in a way a hand-maintained "did anything change" flag would not.
126#[derive(Debug, Default, Clone, PartialEq, Eq)]
127pub struct ParseCtx {
128    commands: HashMap<SmolStr, Vec<ArgSpec>>,
129    environments: HashMap<SmolStr, Vec<ArgSpec>>,
130    suppressed: HashSet<SmolStr>,
131    /// Environment-alias openers: command name (no leading `\`) → target
132    /// environment. See [`crate::semantic::signature::SignatureDb::env_begin_alias`]
133    /// for the admission rules that decide what lands here.
134    begin_aliases: HashMap<SmolStr, SmolStr>,
135    /// The closer mirror of [`begin_aliases`](Self::begin_aliases).
136    end_aliases: HashMap<SmolStr, SmolStr>,
137    /// Environment signatures a project *declared*
138    /// ([`crate::declarations`]), whole rather than reduced to the one fact a
139    /// map above records: body routing reads several flags (`math`,
140    /// `verbatim_body`, `block`), and a declaration is authoritative for every
141    /// one of them at once.
142    declared_environments: HashMap<SmolStr, EnvironmentSig>,
143}
144
145/// The former name of [`ParseCtx`], kept so the published crate's API does not
146/// break. It carries environment aliases as well as verbatim facts now.
147pub type VerbCtx = ParseCtx;
148
149impl ParseCtx {
150    /// Whether the context names nothing at all — no user verbatim constructs, no
151    /// suppressions, and no environment aliases — so the second parse pass can be
152    /// skipped entirely (the common case).
153    ///
154    /// Every map must be accounted for here: a file that defines an alias but no
155    /// verbatim construct would otherwise never reach pass 2, and its aliases would
156    /// silently do nothing.
157    pub fn is_empty(&self) -> bool {
158        self.commands.is_empty()
159            && self.environments.is_empty()
160            && self.suppressed.is_empty()
161            && self.begin_aliases.is_empty()
162            && self.end_aliases.is_empty()
163            && self.declared_environments.is_empty()
164    }
165
166    /// Overlay a project's [declarations](crate::declarations) onto this
167    /// context, taking precedence over anything already recorded.
168    ///
169    /// Declared beats scanned because a declaration is the user explicitly
170    /// correcting an inference (`AGENTS.md` decision #12), which is why this is
171    /// an overlay applied *after* the scan rather than a seed the scan writes
172    /// over.
173    ///
174    /// Two families cross over: the declared environment signatures, which
175    /// every body-routing predicate here then answers from, and the delimiter
176    /// spellings. The alias entries skip the "is it called anywhere" filter
177    /// `parser::core::parse_ctx` applies to scanned ones: that filter exists to
178    /// avoid buying a *second* pass for an alias no call site uses, and a
179    /// declaration is already in hand before the first.
180    pub fn overlay_declarations(&mut self, declared: &crate::declarations::ResolvedDeclarations) {
181        let db = declared.as_db();
182        for name in db.environment_names() {
183            if let Some(sig) = db.environment(name) {
184                self.declared_environments
185                    .insert(SmolStr::new(name), sig.clone());
186            }
187        }
188        for (name, target) in db.env_begin_aliases() {
189            self.insert_begin_alias(SmolStr::new(name), SmolStr::new(target));
190        }
191        for (name, target) in db.env_end_aliases() {
192            self.insert_end_alias(SmolStr::new(name), SmolStr::new(target));
193        }
194    }
195
196    /// Record that `name` is a verbatim-argument command with the given `leading`
197    /// (non-verbatim) argument shape.
198    pub(crate) fn insert(&mut self, name: SmolStr, leading: Vec<ArgSpec>) {
199        self.commands.insert(name, leading);
200    }
201
202    /// Record that `name` — a built-in raw-argument command — is redefined
203    /// non-verbatim in this file, so its built-in verbatim capture is suppressed.
204    pub(crate) fn suppress(&mut self, name: SmolStr) {
205        self.suppressed.insert(name);
206    }
207
208    /// Whether `name`'s built-in verbatim capture is suppressed by a local redefinition.
209    fn is_suppressed(&self, name: &str) -> bool {
210        self.suppressed.contains(name)
211    }
212
213    /// Record that environment `name` is verbatim, with the given argument shape (all
214    /// leading; the raw body follows the arguments).
215    pub(crate) fn insert_environment(&mut self, name: SmolStr, args: Vec<ArgSpec>) {
216        self.environments.insert(name, args);
217    }
218
219    /// The leading argument shape of `name` if it is a known user verbatim command.
220    fn leading_args(&self, name: &str) -> Option<&[ArgSpec]> {
221        self.commands.get(name).map(Vec::as_slice)
222    }
223
224    /// The argument shape of `name` if it is a user-defined or declared verbatim
225    /// environment — what the lexer needs to find where the raw body begins.
226    fn verbatim_environment_args(&self, name: &str) -> Option<&[ArgSpec]> {
227        if let Some(sig) = self.declared_environment(name) {
228            return sig.verbatim_body.then(|| &*sig.args);
229        }
230        self.environments.get(name).map(Vec::as_slice)
231    }
232
233    /// Is `name` a verbatim-like environment — one whose body the parser must route to
234    /// its raw-body branch, per `AGENTS.md` Core decision #1? A user-defined one (from
235    /// this context) or a built-in one ([`builtin`]). Both the lexer (to find where the
236    /// raw body begins) and the structural parser (`grammar.rs`) ask this question, so
237    /// one lookup keeps them in lockstep. We read only static argument-shape data; no
238    /// macro meaning is resolved, so this stays within decision #1's sanctioned modes.
239    ///
240    /// Deliberately consults [`builtin`] only, never the bulk CWL tier
241    /// ([`crate::semantic::signature::cwl`]): routing a body to the raw-verbatim
242    /// branch is lossy if wrong, so this behavior decision rests solely on curated
243    /// data (the CWL tier carries `verbatim_body == false` for every entry anyway).
244    pub(crate) fn is_verbatim_environment(&self, name: &str) -> bool {
245        match self.declared_environment(name) {
246            Some(sig) => sig.verbatim_body,
247            None => {
248                self.environments.contains_key(name)
249                    || builtin()
250                        .environment(name)
251                        .is_some_and(|env| env.verbatim_body)
252            }
253        }
254    }
255
256    /// Record that command `name` (no leading `\`) opens environment `target`.
257    pub(crate) fn insert_begin_alias(&mut self, name: SmolStr, target: SmolStr) {
258        self.begin_aliases.insert(name, target);
259    }
260
261    /// Record that command `name` closes environment `target`.
262    pub(crate) fn insert_end_alias(&mut self, name: SmolStr, target: SmolStr) {
263        self.end_aliases.insert(name, target);
264    }
265
266    /// The environment `name` opens, if it is a known alias opener.
267    pub(crate) fn begin_alias(&self, name: &str) -> Option<&str> {
268        self.begin_aliases.get(name).map(SmolStr::as_str)
269    }
270
271    /// The environment `name` closes, if it is a known alias closer.
272    pub(crate) fn end_alias(&self, name: &str) -> Option<&str> {
273        self.end_aliases.get(name).map(SmolStr::as_str)
274    }
275
276    /// Every environment some alias *opens*, so the pre-scan can recognize the
277    /// literal `\end{X}` that closes it (issue #117). Names repeat when an
278    /// environment has several opener spellings; callers collect into a set.
279    pub(crate) fn begin_alias_targets(&self) -> impl Iterator<Item = &str> {
280        self.begin_aliases.values().map(SmolStr::as_str)
281    }
282
283    /// The signature a project *declared* for environment `name`, if any.
284    ///
285    /// A declared entry is **authoritative** for its name: every predicate below
286    /// answers from it alone rather than falling back to the built-in, because a
287    /// declaration is the user correcting what badness would otherwise infer
288    /// (`AGENTS.md` decision #12). Declaring `myenv` to be `like = "align"` when
289    /// the file also `\newenvironment`s it verbatim means the declaration wins,
290    /// not that the two answers are merged.
291    fn declared_environment(&self, name: &str) -> Option<&EnvironmentSig> {
292        self.declared_environments.get(name)
293    }
294
295    /// Is `name` a block/display environment — one whose lone occurrence the
296    /// parser should leave unwrapped rather than nest in a redundant
297    /// `PARAGRAPH`? A declared one, or a curated built-in.
298    ///
299    /// The parser runs before any per-file `\newenvironment` scan, so a *scanned*
300    /// environment's block-ness is unknown at parse time and an unknown
301    /// environment stays wrapped — the conservative, lossless-safe default. The
302    /// bulk CWL tier is not consulted (it carries no `block` flag, and parser
303    /// layout decisions stay on curated data).
304    pub(crate) fn is_block_environment(&self, name: &str) -> bool {
305        match self.declared_environment(name) {
306            Some(sig) => sig.block,
307            None => builtin().environment(name).is_some_and(|env| env.block),
308        }
309    }
310
311    /// Is `name` a math environment — one whose body the parser should parse in
312    /// math mode, wrapping it in a `MATH` node exactly as `\[…\]` does (so
313    /// scripts become `SCRIPTED`, operators split, and `\left…\right` pair)? A
314    /// declared one, or a curated built-in.
315    ///
316    /// Never the bulk CWL tier, for the same reason as
317    /// [`is_block_environment`](Self::is_block_environment) and
318    /// [`is_verbatim_environment`](Self::is_verbatim_environment): routing a body
319    /// into math mode is a structural (lossless-preserving but shape-changing)
320    /// decision, so it rests solely on curated data — which a declaration is,
321    /// since `like` copies a curated entry and resolves against nothing else.
322    /// This stays a sanctioned static-fact mode (`AGENTS.md` decision #1): no
323    /// macro meaning is resolved, only the `math` flag is read.
324    pub(crate) fn is_math_environment(&self, name: &str) -> bool {
325        match self.declared_environment(name) {
326            Some(sig) => sig.math,
327            None => builtin().environment(name).is_some_and(|env| env.math),
328        }
329    }
330
331    /// Is `name` a statement-body environment — one whose body holds
332    /// `;`-terminated statements (the TikZ/pgf picture family), so the parser
333    /// wraps each run up to a top-level `;` in a `STATEMENT` node? A declared
334    /// one, or a curated built-in.
335    ///
336    /// Never the bulk CWL tier or the definition scan, for the same reason as
337    /// [`is_math_environment`](Self::is_math_environment): wrapping statements
338    /// is a structural decision, so it rests solely on curated data — which a
339    /// declaration is, since `like` copies a curated entry. The `;` terminator
340    /// carries no special catcode; what makes this a sanctioned static-fact
341    /// mode (`AGENTS.md` decision #1) is that recognition is retrospective pure
342    /// shape (a top-level `;`-carrying WORD) and a run that never reaches one
343    /// stays plain paragraph content.
344    pub(crate) fn is_statement_environment(&self, name: &str) -> bool {
345        match self.declared_environment(name) {
346            Some(sig) => sig.statement_body,
347            None => builtin()
348                .environment(name)
349                .is_some_and(|env| env.statement_body),
350        }
351    }
352
353    /// Whether any environment alias is recorded — the cheap guard the grammar
354    /// checks before building its per-token opener/closer index.
355    ///
356    /// Both maps are read, so this can never disagree with
357    /// [`is_empty`](Self::is_empty) about whether the second pass has alias work
358    /// to do. `parser::core::parse_ctx` additionally drops a closer whose target
359    /// has no live opener, so in practice the maps are non-empty together.
360    pub(crate) fn has_env_aliases(&self) -> bool {
361        !self.begin_aliases.is_empty() || !self.end_aliases.is_empty()
362    }
363}
364
365/// Whether `text` (a `CONTROL_WORD`, leading `\` included) is a command-definition
366/// keyword whose immediately-following name must not be lexed as a verbatim call.
367/// Covers the LaTeX2e and xparse families the definition scanner recognizes plus the
368/// primitive `\def` family; `\let` is included since it too binds a following name.
369/// Reads only the static keyword, no macro meaning.
370pub(crate) fn is_definition_keyword(text: &str) -> bool {
371    matches!(
372        text,
373        "\\newcommand"
374            | "\\renewcommand"
375            | "\\providecommand"
376            | "\\DeclareRobustCommand"
377            | "\\NewDocumentCommand"
378            | "\\RenewDocumentCommand"
379            | "\\ProvideDocumentCommand"
380            | "\\DeclareDocumentCommand"
381            | "\\def"
382            | "\\edef"
383            | "\\gdef"
384            | "\\xdef"
385            | "\\let"
386    )
387}
388
389/// How many immediately-following control words `text` (a `CONTROL_WORD`, leading
390/// `\` included) claims as *names* rather than calls — `0` when it is not a
391/// definition keyword at all.
392///
393/// `\let` claims **two**: the definee and the meaning it is given, so
394/// `\let\oldbea\bea` mentions `\bea` without calling it. A bare "the next word is
395/// a definee" boolean would let that source operand read as a live call, which for
396/// the environment-alias index means a `\let` operand can pair with a later closer
397/// and wrap the text between them in an environment nobody wrote. Mirrors the
398/// `("let", 2)` entry in [`crate::parser::conditional`]'s operand table, which
399/// subtracts the same slots for the same reason.
400pub(crate) fn definition_name_slots(text: &str) -> u8 {
401    match text {
402        "\\let" => 2,
403        _ if is_definition_keyword(text) => 1,
404        _ => 0,
405    }
406}
407
408/// Whether `text` (a `CONTROL_WORD`, leading `\` included) is a TeX primitive
409/// that grabs the *next token* without expanding it, so a following character
410/// keeps its literal shape. Only the short-verb capture reads this: an active
411/// `|` after `\string` is the token being printed, not a `\verb`-style opener
412/// (`\meta{first\texttt{\string|}last}`, lthooks.dtx). A closed curated set,
413/// read from the static keyword alone — no macro meaning.
414fn is_literal_token_command(text: &str) -> bool {
415    matches!(
416        text,
417        "\\string" | "\\noexpand" | "\\meaning" | "\\expandafter" | "\\show"
418    )
419}
420
421/// Whether `text` (a `CONTROL_WORD`, leading `\` included) is a TeX primitive
422/// that opens a numeric context, where a following number is conventionally
423/// written in backtick char-constant notation (`` \char`$ ``, `` \catcode`\%=12 ``,
424/// `` \number`\[ ``): after it, a backtick makes the next character *data*, never
425/// syntax. A closed curated set; reads only the static keyword, no macro meaning.
426/// The number-*producing* primitives (`\number`/`\the`/`\romannumeral`) and the
427/// numeric conditionals (`\ifnum`/`\ifodd`/`\ifdim`) are included alongside the
428/// codetables because their operand is just as routinely a backtick constant.
429fn is_char_constant_command(text: &str) -> bool {
430    matches!(
431        text,
432        "\\char"
433            | "\\catcode"
434            | "\\lccode"
435            | "\\uccode"
436            | "\\sfcode"
437            | "\\mathcode"
438            | "\\delcode"
439            | "\\number"
440            | "\\the"
441            | "\\romannumeral"
442            | "\\numexpr"
443            | "\\dimexpr"
444            | "\\ifnum"
445            | "\\ifodd"
446            | "\\ifdim"
447    )
448}
449
450/// An expl3 catcode-mode toggle recognized purely by its control-word spelling.
451/// Shared by the lexer (which flips its `expl_syntax` flag) and the formatter's
452/// region pre-pass (the `badness-formatter` crate recomputes in-region byte spans), so the
453/// two read the *same* fixed toggle set and can never drift.
454#[derive(Debug, Clone, Copy, PartialEq, Eq)]
455pub enum ExplToggle {
456    /// `\ExplSyntaxOn`, or `\ProvidesExplPackage`/`Class`/`File` (which open expl3
457    /// syntax for the rest of the file).
458    On,
459    /// `\ExplSyntaxOff`.
460    Off,
461}
462
463/// Classify a control word's text as an expl3 catcode-mode toggle, if any. Only
464/// meaningful on [`SyntaxKind::CONTROL_WORD`] text: a `\ExplSyntaxOn` inside a
465/// `\verb`/comment lexes as a `VERB`/`COMMENT` token and so never reaches here.
466pub fn expl_toggle(text: &str) -> Option<ExplToggle> {
467    match text {
468        "\\ExplSyntaxOn"
469        | "\\ProvidesExplPackage"
470        | "\\ProvidesExplClass"
471        | "\\ProvidesExplFile" => Some(ExplToggle::On),
472        "\\ExplSyntaxOff" => Some(ExplToggle::Off),
473        _ => None,
474    }
475}
476
477/// True when a `.dtx` file carries a *static expl3 signal* even though it never
478/// runs an in-file toggle: a line-leading `%<@@=…>` docstrip module-prefix guard,
479/// or a `\ProvidesExpl{Package,Class,File}` declaration anywhere. Real expl3
480/// package sources declare expl3 in the parent `.dtx`/build and set the module
481/// prefix `@@` with a `%<@@=mod>` guard, so their `macrocode` bodies are expl3
482/// code with no `\ExplSyntaxOn` to see (`ltx-talk-structure.dtx`, TODO.md).
483///
484/// Scans the raw text before lexing, so it cannot reuse [`expl_toggle`] (which
485/// classifies already-lexed token text). Deliberately coarse and name-only, like
486/// the lexer's other expl handling: it sees the whole file — prose and verbatim
487/// examples included — so a `\ProvidesExpl*` mentioned as text also trips it. That
488/// is acceptable (`AGENTS.md` decision #1): a false positive only *joins* `_`/`:`
489/// into a control word (lossless), and reading the whole file keeps the signal
490/// order-independent, so a body *above* the declaration is flagged too.
491pub(crate) fn dtx_has_expl_signal(input: &str) -> bool {
492    input.contains("\\ProvidesExpl")
493        || input
494            .lines()
495            .any(|l| l.starts_with("%<@@=") && l[5..].contains('>'))
496}
497
498/// Lex `input` into a flat, lossless token stream, consulting only the built-in
499/// signature DB for verbatim commands/environments. The entry used by the first
500/// parse pass; [`lex_with`] adds user-defined verbatim commands. Uses the
501/// [`Document`](LatexFlavor::Document) flavor (ordinary starting catcodes).
502pub fn lex(input: &str) -> Vec<Token> {
503    lex_with(input, &ParseCtx::default(), LexConfig::default())
504}
505
506/// Lex `input` like [`lex`], additionally treating the user-defined verbatim
507/// commands in `ctx` as verbatim (their final argument captured as one `VERB`
508/// token). Used by the second parse pass once definition scanning has discovered
509/// catcode-othering commands. `config` fixes the initial catcode regime (a
510/// [`Package`](LatexFlavor::Package) flavor starts with `@` already a letter) and
511/// whether to run the `.dtx` docstrip mode.
512pub fn lex_with(input: &str, ctx: &ParseCtx, config: LexConfig) -> Vec<Token> {
513    Lexer::new(input, ctx, config, None).run()
514}
515
516/// Lex `input` like [`lex_with`], forcing the `.dtx` implicit-expl regime.
517///
518/// Only for incremental reparse tiers that relex a fragment under the base parse's
519/// full-file lexer facts.
520pub(crate) fn lex_with_implicit_expl(
521    input: &str,
522    ctx: &ParseCtx,
523    config: LexConfig,
524    implicit_expl: bool,
525) -> Vec<Token> {
526    Lexer::new(input, ctx, config, Some(implicit_expl)).run()
527}
528
529/// The lexer's one-shot lookahead mode: a state the token just lexed arms, which
530/// changes how the *next* one reads. The four arming command sets are mutually
531/// exclusive — `\left`/`\right`, the definition keywords, the char-constant
532/// primitives, and the literal-token primitives are disjoint — so a single slot
533/// holds them all faithfully, and a construct that consumes the awaited token
534/// clears the slot wholesale rather than a hand-picked subset.
535#[derive(Debug, Clone, Copy, PartialEq, Eq)]
536enum Pending {
537    /// After `\left`/`\right`: the delimiter that follows is isolated as a single
538    /// token, so a word-character delimiter does not glue into the following run.
539    Delim,
540    /// After a definition keyword (`\newcommand\foo…`, `\NewDocumentCommand{\foo}…`,
541    /// `\def\foo…`): the next control word is the *name being defined*, so it must
542    /// not be lexed as a verbatim *call* — at a definition site the trailing `{…}`
543    /// are the signature/body, not the command's argument. Without this, a command
544    /// flagged verbatim in pass 1 would have its own definition's first group
545    /// captured as a `VERB` in pass 2.
546    Def,
547    /// After a `\char`/`\catcode`-family primitive, where a backtick opens TeX's
548    /// char-constant number notation: the character after the backtick is data
549    /// (`` \char`$ ``, `` \char`} ``), never a math opener or group brace. The doc
550    /// layer writes the notation in prose (issue #60), so without this the hidden
551    /// `$`/`{` cascade into unclosed-math and unclosed-group diagnostics.
552    CharConstant,
553    /// After a primitive that consumes the *next token* unexpanded
554    /// ([`is_literal_token_command`]), where a short-verb character is that token
555    /// rather than a capture opener (`\string|`, lthooks.dtx, issue #71).
556    LiteralToken,
557}
558
559/// The catcode state a `macrocode` chunk suspends. A body runs under
560/// `\makeatletter` (and, in an implicit-expl3 `.dtx`, under `\ExplSyntaxOn`), and
561/// its end frame restores what the documentation layer had. Held as one `Option`,
562/// so "inside a body" and "what to restore" are the same fact and cannot disagree.
563#[derive(Debug, Clone, Copy)]
564struct MacrocodeSave {
565    at_letter: bool,
566    expl_syntax: bool,
567}
568
569/// The lexer's state machine over one input. [`run`](Lexer::run) is a short loop
570/// over the `try_*` probes, each of which either consumes a whole construct
571/// (returning `true`) or declines; whatever no probe claims is lexed as one
572/// ordinary token by [`lex_token`](Lexer::lex_token).
573struct Lexer<'a> {
574    input: &'a str,
575    ctx: &'a ParseCtx,
576    config: LexConfig,
577    /// Implicit expl3: a toggle-less `.dtx` whose static signal (a `%<@@=mod>`
578    /// module guard or a `\ProvidesExpl*` anywhere) marks its `macrocode` bodies
579    /// as expl3 code. When set, `expl_syntax` is forced on inside every macrocode
580    /// body and restored on exit, alongside the `at_letter` save. Only `.dtx`
581    /// files have macrocode bodies, so this is gated on `config.dtx`.
582    implicit_expl: bool,
583    out: Vec<Token>,
584    pos: usize,
585    /// `\makeatletter` state: while true, `@` is a catcode-11 letter.
586    at_letter: bool,
587    /// `\ExplSyntaxOn` state: while true, `_` and `:` are catcode-11 letters, so
588    /// expl3 names (`\seq_new:N`, `\__module_internal:nn`) lex as single control
589    /// words. Toggled by `\ExplSyntaxOn`/`\ExplSyntaxOff` and turned on by the
590    /// `\ProvidesExpl*` package/class/file declarations (a sanctioned static lexer
591    /// mode, `AGENTS.md` decision #1). Independent of `at_letter`; the two compose.
592    expl_syntax: bool,
593    /// True at the start of a physical line (start of input or just after a
594    /// `NEWLINE`), so a line-leading `%` can be recognized as a `.dtx`
595    /// documentation margin. Any token — including whitespace — clears it,
596    /// matching docstrip's rule that only a `%` in *column 0* is a margin.
597    at_line_start: bool,
598    /// True while lexing the remainder of a `.dtx` documentation line (a line whose
599    /// column-0 `%` was emitted as a `DOC_MARGIN`). On such lines the ltxdoc/l3doc
600    /// `` \catcode`\^^A=14 `` convention applies, so a literal `^^A` reads as a
601    /// comment to end of line. Cleared at every physical line boundary.
602    in_doc_line: bool,
603    /// doc-package short-verb characters (`\MakeShortVerb{\|}`): while a char is
604    /// enabled, `<c>…<c>` on one line captures as a single opaque `VERB` token,
605    /// exactly like `\verb<c>…<c>`. A sanctioned static lexer mode (`AGENTS.md`
606    /// decision #1): the toggles are the explicit `\MakeShortVerb`/
607    /// `\DeleteShortVerb` calls (left-to-right, like `\makeatletter`), plus the
608    /// curated doc classes that enable `|` themselves ([`doc_class_enables_bar`]).
609    /// The `.dtx` documentation layer gets `|` from the start — dtx files are
610    /// typeset under `ltxdoc`, and the driver holding the `\documentclass` may
611    /// live in a separate file.
612    short_verbs: Vec<char>,
613    /// `Some` while inside a `macrocode`/`macrocode*` environment body (between its
614    /// frame lines), holding the catcode state to restore on exit. There, code
615    /// lines carry no margin, a line-leading `%` is an ordinary code comment (not a
616    /// margin), and `@` is a letter (`macrocode` runs under `\makeatletter`).
617    macrocode: Option<MacrocodeSave>,
618    /// The one-shot mode the previous token armed, if any.
619    pending: Option<Pending>,
620    /// Number of brace groups open at the cursor, counted over every token emitted
621    /// so far (the `try_*` probes push braces of their own, so `out` is the one
622    /// place that sees them all). Read by the char-constant probe: inside a group
623    /// TeX has already claimed a `{`/`}` as balanced-text structure, so a backtick
624    /// there cannot hide it. Saturating, so an unbalanced file never underflows.
625    brace_depth: usize,
626    /// How far into `out` [`sync_brace_depth`](Lexer::sync_brace_depth) has folded.
627    brace_counted: usize,
628}
629
630impl<'a> Lexer<'a> {
631    fn new(
632        input: &'a str,
633        ctx: &'a ParseCtx,
634        config: LexConfig,
635        implicit_expl_override: Option<bool>,
636    ) -> Self {
637        let implicit_expl = if config.dtx {
638            implicit_expl_override.unwrap_or_else(|| dtx_has_expl_signal(input))
639        } else {
640            false
641        };
642        Self {
643            input,
644            ctx,
645            config,
646            implicit_expl,
647            out: Vec::new(),
648            pos: 0,
649            at_letter: config.flavor.letter_mode_start(),
650            expl_syntax: false,
651            at_line_start: true,
652            in_doc_line: false,
653            short_verbs: if config.dtx { vec!['|'] } else { Vec::new() },
654            macrocode: None,
655            pending: None,
656            brace_depth: 0,
657            brace_counted: 0,
658        }
659    }
660
661    /// Lex the whole input. The probe order is load-bearing — an earlier probe
662    /// wins the bytes outright — so it mirrors the layering the modes assume: the
663    /// `.dtx` line-oriented trivia first (only they may claim column 0), then the
664    /// constructs that swallow a span whole (verbatim environments before verbatim
665    /// commands, since `\begin` is itself a command), then the one-shot modes.
666    fn run(mut self) -> Vec<Token> {
667        while self.pos < self.input.len() {
668            self.sync_brace_depth();
669            if self.try_macrocode_frame()
670                || self.try_guard()
671                || self.try_doc_margin()
672                || self.try_verbatim_environment()
673                || self.try_verbatim_arg_environment()
674            {
675                continue;
676            }
677            // The control word's letter run is scanned once here and handed to both
678            // the verbatim-command probe and the ordinary classification below,
679            // which otherwise ask the same question about the same bytes twice.
680            let word_len = control_word_len(self.rest(), self.at_letter, self.expl_syntax);
681            if self.try_verbatim_command(word_len)
682                || self.try_short_verb()
683                || self.try_char_constant()
684                || self.try_doc_comment()
685            {
686                continue;
687            }
688            self.lex_token(word_len);
689        }
690        self.out
691    }
692
693    /// The unlexed remainder of the input.
694    fn rest(&self) -> &'a str {
695        &self.input[self.pos..]
696    }
697
698    fn push(&mut self, kind: SyntaxKind, text: &str) {
699        self.out.push(Token {
700            kind,
701            text: SmolStr::new(text),
702        });
703    }
704
705    /// Consume `len` bytes of a construct a `try_*` probe claimed whole: the cursor
706    /// lands mid-line, and any armed one-shot mode is spent — the construct either
707    /// *was* the token the mode was waiting for or is an ordinary token that ends
708    /// the wait, and no mode outlives a construct it did not fire on.
709    fn consume(&mut self, len: usize) {
710        self.pos += len;
711        self.at_line_start = false;
712        self.pending = None;
713    }
714
715    /// Fold every token pushed since the last call into `brace_depth`.
716    fn sync_brace_depth(&mut self) {
717        while self.brace_counted < self.out.len() {
718            match self.out[self.brace_counted].kind {
719                SyntaxKind::L_BRACE => self.brace_depth += 1,
720                SyntaxKind::R_BRACE => self.brace_depth = self.brace_depth.saturating_sub(1),
721                _ => {}
722            }
723            self.brace_counted += 1;
724        }
725    }
726
727    /// `.dtx` `macrocode` frame line. A `%␣*\begin{macrocode}` line opens a code
728    /// region; its `%␣*\end{macrocode}` terminator closes it. Both lex as a
729    /// margin + indent + `\begin`/`\end{macrocode}` so the ordinary environment
730    /// grammar pairs them, but the *body* in between lexes as real code, under the
731    /// package regime (`@` a letter) with no margin stripping. We look for a begin
732    /// frame outside the body and the end frame inside it; anything else on a `%`
733    /// line inside the body is an ordinary code comment.
734    fn try_macrocode_frame(&mut self) -> bool {
735        if !(self.config.dtx && self.at_line_start) {
736            return false;
737        }
738        let rest = self.rest();
739        let want_begin = self.macrocode.is_none();
740        let Some(consumed) = lex_macrocode_frame(rest, want_begin, &mut self.out) else {
741            return false;
742        };
743        match self.macrocode.take() {
744            Some(saved) => {
745                self.at_letter = saved.at_letter;
746                self.expl_syntax = saved.expl_syntax;
747            }
748            None => {
749                self.macrocode = Some(MacrocodeSave {
750                    at_letter: self.at_letter,
751                    expl_syntax: self.expl_syntax,
752                });
753                self.at_letter = true;
754                if self.implicit_expl {
755                    self.expl_syntax = true;
756                }
757            }
758        }
759        self.consume(consumed);
760        true
761    }
762
763    /// `.dtx` docstrip guard: a line-leading `%<…>` is a docstrip guard expression
764    /// (`%<*tag>`/`%</tag>` block delimiters or an inline `%<tag>` prefix), not a
765    /// comment. Emit the `%<…>` (through the closing `>`) as a single `GUARD`
766    /// trivia leaf; code after an inline guard's `>` lexes normally. Guards nest on
767    /// the docstrip axis, orthogonal to LaTeX nesting, so this is a flat floating
768    /// leaf (no block node), like a margin. Recognized at line start only (column-0
769    /// rule) but in *any* layer — guards punctuate `macrocode` bodies too — so it
770    /// is not gated on being outside one. A `%<` with no closing `>` before the
771    /// line ends is not a guard; it falls through to an ordinary comment. Trivia,
772    /// so the [`Pending`] mode carries across.
773    fn try_guard(&mut self) -> bool {
774        let rest = self.rest();
775        if !(self.config.dtx && self.at_line_start && rest.starts_with("%<")) {
776            return false;
777        }
778        let Some(rel) = rest[2..].find(['>', '\n', '\r']) else {
779            return false;
780        };
781        if rest.as_bytes()[2 + rel] != b'>' {
782            return false;
783        }
784        let len = 2 + rel + 1;
785        self.push(SyntaxKind::GUARD, &rest[..len]);
786        self.pos += len;
787        self.at_line_start = false;
788        true
789    }
790
791    /// `.dtx` documentation margin: a line-leading `%` (but not a `%<…>` guard,
792    /// which lexes as a `GUARD` above) is a documentation line's comment *margin*,
793    /// not a comment. Emit it as a `DOC_MARGIN` trivia token — one byte, never the
794    /// following space — so the rest of the line lexes (and parses) as ordinary
795    /// LaTeX and the margin floats like whitespace. Only the line-leading `%` is a
796    /// margin; a later `%` on the same line stays a `COMMENT`. Inside a `macrocode`
797    /// body there is no margin (code lines own their `%`). The margin is trivia, so
798    /// it carries the [`Pending`] mode across unchanged (like whitespace).
799    fn try_doc_margin(&mut self) -> bool {
800        let rest = self.rest();
801        if !(self.config.dtx
802            && self.at_line_start
803            && self.macrocode.is_none()
804            && rest.starts_with('%')
805            && !rest.starts_with("%<"))
806        {
807            return false;
808        }
809        self.push(SyntaxKind::DOC_MARGIN, "%");
810        self.pos += 1;
811        self.at_line_start = false;
812        self.in_doc_line = true;
813        true
814    }
815
816    /// Verbatim-like environment: emit `\begin{name}` then a raw body token.
817    fn try_verbatim_environment(&mut self) -> bool {
818        let (rest, ctx) = (self.rest(), self.ctx);
819        let Some(consumed) = lex_verbatim_environment(rest, ctx, &mut self.out) else {
820            return false;
821        };
822        self.consume(consumed);
823        true
824    }
825
826    /// l3doc `v`-type name argument in delimited form (`\begin{macro}+…+`): capture
827    /// the span as one opaque `VERB` token so its unbalanced braces stay data.
828    /// Gated off inside a `macrocode` body, where a `\begin` is plain macro code,
829    /// not an l3doc environment.
830    fn try_verbatim_arg_environment(&mut self) -> bool {
831        if self.macrocode.is_some() {
832            return false;
833        }
834        let rest = self.rest();
835        let Some(consumed) = lex_verbatim_arg_environment(rest, &mut self.out) else {
836            return false;
837        };
838        self.consume(consumed);
839        true
840    }
841
842    /// Verbatim-argument command (`\url{…}`, `\code{…}`, `\lstinline|…|`, …): emit
843    /// the control word and any leading args, then a raw argument token.
844    /// `\verb`/`\verb*` are handled separately in [`lex_control`] (delimiter only),
845    /// so they fall through here. Suppressed at a definition site ([`Pending::Def`]),
846    /// where the following groups are the signature/body.
847    fn try_verbatim_command(&mut self, word_len: Option<usize>) -> bool {
848        if self.pending == Some(Pending::Def) {
849            return false;
850        }
851        let (rest, ctx) = (self.rest(), self.ctx);
852        let Some(consumed) = lex_verbatim_command(
853            rest,
854            word_len,
855            ctx,
856            self.config.dtx && self.in_doc_line,
857            &mut self.out,
858        ) else {
859            return false;
860        };
861        self.consume(consumed);
862        true
863    }
864
865    /// Short-verb span (`|…|` under doc's `\MakeShortVerb{\|}`): capture the
866    /// delimited run as one opaque `VERB` token, same-line only (like `\verb`).
867    /// Gated off inside a `macrocode` body (a code layer, where `|` is an ordinary
868    /// catcode-12 character) and after `\left`/`\right` (whose next character is a
869    /// delimiter, `\left|x\right|`). With no closing delimiter on the line, decline:
870    /// the word-run truncation in [`lex_token`](Lexer::lex_token) still emits the
871    /// lone character as its own token. Also gated off after a primitive that takes
872    /// the next token unexpanded ([`Pending::LiteralToken`]): `\string|` prints the
873    /// bar, it does not open a capture that would run to the next `|` and swallow
874    /// the intervening braces (lthooks.dtx's
875    /// `\meta{first\texttt{\string|}last}\verb|):|`, issue #71).
876    fn try_short_verb(&mut self) -> bool {
877        if self.short_verbs.is_empty()
878            || self.macrocode.is_some()
879            || matches!(self.pending, Some(Pending::Delim | Pending::LiteralToken))
880        {
881            return false;
882        }
883        let rest = self.rest();
884        if !rest
885            .chars()
886            .next()
887            .is_some_and(|c| self.short_verbs.contains(&c))
888        {
889            return false;
890        }
891        let Some(len) = delimited_len(rest) else {
892            return false;
893        };
894        self.push(SyntaxKind::VERB, &rest[..len]);
895        self.consume(len);
896        true
897    }
898
899    /// TeX char-constant backtick notation: after a `\char`/`\catcode`-family
900    /// primitive ([`Pending::CharConstant`]), a backtick makes the next character
901    /// data (`` \char`$ ``, `` \char`} ``), so emit the backtick and that character
902    /// as one plain `WORD` token — a `$`/`{` there must not open math or a group.
903    /// The escaped single-character form (`` \number`\[ ``) is captured the same
904    /// way, backtick plus the whole control symbol: a `\[`/`\]` there is the
905    /// *character* `[`/`]`, not a math delimiter (encguide.tex's char-code table,
906    /// issue #71). The same reading is statically certain when the escaped form
907    /// occupies a whole alignment cell (`` `\X& ``): the alignment template can
908    /// supply that cell to `\char#`, as in TeX by Topic's character-code tables
909    /// (issue #144). Requiring the immediate `&` keeps this local shape from
910    /// claiming an ordinary backtick before live `\[…\]` math.
911    ///
912    /// A *bare* `{`/`}` is the exception, and only at brace depth 0. Inside a group
913    /// the brace has already been claimed as structure by whichever balanced-text
914    /// scan opened it — a `\def` body or a macro argument, both of which count brace
915    /// *tokens* long before `\char` ever runs — so the `}` in `` \def\v{\char`} ``
916    /// (longtable.dtx) and the `` \ifnum`}=0\fi `` brace-balance idiom
917    /// (longtable/amsmath) closes its group and is not data. At depth 0 there is no
918    /// such scan and the constant reading stands (`a close-group character is
919    /// written \char`} in running text`). The *escaped* form `` `\} `` is
920    /// unaffected: a control symbol is never a group delimiter, so it stays data at
921    /// any depth (issue #71).
922    fn try_char_constant(&mut self) -> bool {
923        let numeric_context = self.pending == Some(Pending::CharConstant);
924        let rest = self.rest();
925        let Some(after) = rest.strip_prefix('`') else {
926            return false;
927        };
928        let Some(c) = after.chars().next() else {
929            return false;
930        };
931        if matches!(c, '\n' | '\r') || (self.brace_depth > 0 && matches!(c, '{' | '}')) {
932            return false;
933        }
934        let len = if c == '\\' {
935            // `` `\X ``: backtick, backslash, and one escaped character; a bare
936            // `` `\ `` at line end has no character and falls through.
937            match after[1..]
938                .chars()
939                .next()
940                .filter(|e| !matches!(e, '\n' | '\r'))
941            {
942                Some(e) => 2 + e.len_utf8(),
943                None => return false,
944            }
945        } else {
946            1 + c.len_utf8()
947        };
948        let alignment_cell = self.pending.is_none() && c == '\\' && rest[len..].starts_with('&');
949        if !numeric_context && !alignment_cell {
950            return false;
951        }
952        self.push(SyntaxKind::WORD, &rest[..len]);
953        self.consume(len);
954        true
955    }
956
957    /// `.dtx` `^^A` comment: ltxdoc/l3doc set `` \catcode`\^^A=14 ``, and the doc
958    /// layer leans on it for editor-balance hacks in prose (`^^A{` paired with a
959    /// verb `|}|`, a commented-out `^^A\end{function}`), so on a doc-margin line the
960    /// literal `^^A` sequence is a comment to end of line — a bounded static fact
961    /// like the on-by-default `|` short verb (`AGENTS.md` decision #1). Scoped to
962    /// doc lines only: inside a `macrocode` body `^^A` is live code
963    /// (``\char_set_catcode:nn { `\^^A }`` must not swallow its line), and
964    /// unmargined driver lines keep ordinary lexing.
965    fn try_doc_comment(&mut self) -> bool {
966        let rest = self.rest();
967        if !(self.in_doc_line && rest.starts_with("^^A")) {
968            return false;
969        }
970        let len = run_len(rest, |c| c != '\n' && c != '\r');
971        self.push(SyntaxKind::COMMENT, &rest[..len]);
972        self.consume(len);
973        true
974    }
975
976    /// Lex the one ordinary token at the cursor: classify it, apply the truncations
977    /// an armed mode or an enabled short-verb character imposes, run whatever
978    /// catcode toggle its text carries, and advance. `word_len` is the pre-scanned
979    /// control-word length at the cursor ([`control_word_len`]).
980    fn lex_token(&mut self, word_len: Option<usize>) {
981        let rest = self.rest();
982        let (kind, mut len) = next_token(rest, word_len, self.expl_syntax);
983        // A `\left`/`\right` delimiter that lexes as a word run: keep only its
984        // first character so it does not glue into the following text.
985        if self.pending == Some(Pending::Delim) && kind == SyntaxKind::WORD {
986            len = rest.chars().next().expect("rest is non-empty").len_utf8();
987        }
988        // An enabled short-verb char never joins a word run: split it off so a
989        // mid-word `x|y|` still opens a capture on the next iteration, and an
990        // unclosed `|` stands alone rather than gluing into the following text.
991        if kind == SyntaxKind::WORD
992            && !self.short_verbs.is_empty()
993            && let Some((i, c)) = rest[..len]
994                .char_indices()
995                .find(|(_, c)| self.short_verbs.contains(c))
996        {
997            len = if i == 0 { c.len_utf8() } else { i };
998        }
999        debug_assert!(len > 0, "lexer made no progress at byte {}", self.pos);
1000        let text = &rest[..len];
1001        if kind == SyntaxKind::CONTROL_WORD {
1002            self.apply_toggles(text, &rest[len..]);
1003        }
1004        self.pending = next_pending(self.pending, kind, text);
1005        self.push(kind, text);
1006        // A new physical line begins right after a `NEWLINE` — or after any token
1007        // that swallows its trailing line break, like the `\<newline>` control
1008        // symbol (`… \LaTeX\` at end of line): the next byte is column 0 either
1009        // way, so a `.dtx` margin there must still be recognized. Any other token
1010        // (whitespace included) leaves the cursor mid-line.
1011        self.at_line_start =
1012            kind == SyntaxKind::NEWLINE || text.ends_with('\n') || text.ends_with('\r');
1013        if self.at_line_start {
1014            self.in_doc_line = false;
1015        }
1016        self.pos += len;
1017    }
1018
1019    /// Apply the catcode / short-verb toggle a control word carries, if any.
1020    /// `after` is the text following it, from which the toggles that take a
1021    /// character or class argument read it.
1022    fn apply_toggles(&mut self, text: &str, after: &str) {
1023        match text {
1024            "\\makeatletter" => self.at_letter = true,
1025            "\\makeatother" => self.at_letter = false,
1026            // doc's short-verb toggles: `\MakeShortVerb{\|}` (or the `*` and
1027            // unbraced forms) enables the char, `\DeleteShortVerb{\|}` disables it.
1028            // Read as static facts left-to-right; a definition site
1029            // (`\def\MakeShortVerb{…`) never matches the `\c` argument shape, so it
1030            // does not toggle.
1031            "\\MakeShortVerb" => {
1032                if let Some(c) = short_verb_char(after)
1033                    && !self.short_verbs.contains(&c)
1034                {
1035                    self.short_verbs.push(c);
1036                }
1037            }
1038            "\\DeleteShortVerb" => {
1039                if let Some(c) = short_verb_char(after) {
1040                    self.short_verbs.retain(|&x| x != c);
1041                }
1042            }
1043            // The curated doc classes make `|` a short verb themselves
1044            // ([`BAR_SHORT_VERB_CLASSES`]), so loading one enables `|`.
1045            "\\documentclass" | "\\LoadClass" => {
1046                if doc_class_enables_bar(after) && !self.short_verbs.contains(&'|') {
1047                    self.short_verbs.push('|');
1048                }
1049            }
1050            // `\ExplSyntaxOn`/`Off`, and the `\ProvidesExpl*` declarations which
1051            // open expl3 syntax for the rest of the file (they appear at the top of
1052            // an expl3 package/class) so left-to-right they act as an On.
1053            _ => {
1054                if let Some(toggle) = expl_toggle(text) {
1055                    self.expl_syntax = matches!(toggle, ExplToggle::On);
1056                }
1057            }
1058        }
1059    }
1060}
1061
1062/// Whether a control word makes the lexer read the *raw text that follows it*,
1063/// beyond the ordinary token scan — so a later token's own text can decide how the
1064/// rest of the file lexes.
1065///
1066/// Two families, and between them this is the whole set. [`apply_toggles`] reads a
1067/// following argument for the short-verb and document-class toggles (the
1068/// `\makeatletter` and expl3 toggles read only the control word itself, so they are
1069/// not here). And [`next_pending`] arms the one-shot lookahead, which changes how
1070/// the *next* token lexes; asking it rather than restating its four sets is what
1071/// keeps this from drifting when a fifth is added.
1072///
1073/// Exists for [`crate::parser::reparse`]'s token tier, which may not splice a leaf
1074/// whose text one of these reads: the tier's soundness rests on the token *kind*
1075/// vector being unchanged, and these are the lexer's way of making one token's text
1076/// change another token's kind.
1077pub(crate) fn reads_following_text(text: &str) -> bool {
1078    matches!(
1079        text,
1080        "\\MakeShortVerb" | "\\DeleteShortVerb" | "\\documentclass" | "\\LoadClass"
1081    ) || next_pending(None, SyntaxKind::CONTROL_WORD, text).is_some()
1082}
1083
1084/// The one-shot mode in force after lexing a token of `kind`/`text`: newly armed
1085/// by a command that takes one, carried across the trivia the awaited token may
1086/// sit behind, and otherwise spent.
1087///
1088/// Each variant carries across exactly the trivia TeX skips before *its* token.
1089/// Spaces always; a line break additionally for [`Pending::Delim`] (TeX scans for
1090/// the delimiter across lines) and for [`Pending::Def`], whose braced form
1091/// `\newcommand{\foo}` also interposes the `{`. A char constant and a
1092/// literal-token grab conventionally stay on their line.
1093fn next_pending(pending: Option<Pending>, kind: SyntaxKind, text: &str) -> Option<Pending> {
1094    if kind == SyntaxKind::CONTROL_WORD {
1095        // The four arming sets are disjoint, so the order of these tests is
1096        // immaterial; any other control word — the defined name itself included —
1097        // spends whatever was armed.
1098        return if text == "\\left" || text == "\\right" {
1099            Some(Pending::Delim)
1100        } else if is_definition_keyword(text) {
1101            Some(Pending::Def)
1102        } else if is_char_constant_command(text) {
1103            Some(Pending::CharConstant)
1104        } else if is_literal_token_command(text) {
1105            Some(Pending::LiteralToken)
1106        } else {
1107            None
1108        };
1109    }
1110    match pending? {
1111        p @ (Pending::Delim | Pending::Def)
1112            if matches!(kind, SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE) =>
1113        {
1114            Some(p)
1115        }
1116        Pending::Def if kind == SyntaxKind::L_BRACE => Some(Pending::Def),
1117        p @ (Pending::CharConstant | Pending::LiteralToken) if kind == SyntaxKind::WHITESPACE => {
1118            Some(p)
1119        }
1120        _ => None,
1121    }
1122}
1123
1124/// Byte length of the control word at the start of `rest` — the backslash plus its
1125/// maximal letter run — or `None` when `rest` does not start one (no backslash, or
1126/// no letter behind it). Scanned once per cursor position and threaded to every
1127/// consumer, since the letter run is the same bytes under the same catcode regime.
1128fn control_word_len(rest: &str, at_letter: bool, expl_syntax: bool) -> Option<usize> {
1129    let after = rest.strip_prefix('\\')?;
1130    let letters = run_len(after, |c| is_letter(c, at_letter, expl_syntax));
1131    (letters > 0).then_some(1 + letters)
1132}
1133
1134/// Classify the token at the start of `rest` and return its `(kind, byte_len)`.
1135/// `word_len` is the pre-scanned [`control_word_len`] at `rest`.
1136fn next_token(rest: &str, word_len: Option<usize>, expl_syntax: bool) -> (SyntaxKind, usize) {
1137    let c = rest.chars().next().expect("rest is non-empty");
1138    match c {
1139        '\\' => lex_control(rest, word_len),
1140        '%' => (
1141            SyntaxKind::COMMENT,
1142            run_len(rest, |c| c != '\n' && c != '\r'),
1143        ),
1144        '{' => (SyntaxKind::L_BRACE, 1),
1145        '}' => (SyntaxKind::R_BRACE, 1),
1146        '[' => (SyntaxKind::L_BRACKET, 1),
1147        ']' => (SyntaxKind::R_BRACKET, 1),
1148        '$' => (SyntaxKind::DOLLAR, 1),
1149        '&' => (SyntaxKind::AMPERSAND, 1),
1150        '#' => (SyntaxKind::HASH, 1),
1151        '^' => (SyntaxKind::CARET, 1),
1152        // Under `\ExplSyntaxOn`, `_` is a catcode-11 letter, not a subscript: a
1153        // bare `_` joins the surrounding word run (handled by the default arm).
1154        '_' if !expl_syntax => (SyntaxKind::UNDERSCORE, 1),
1155        '~' => (SyntaxKind::TILDE, 1),
1156        '\n' => (SyntaxKind::NEWLINE, 1),
1157        '\r' => {
1158            let len = if rest.as_bytes().get(1) == Some(&b'\n') {
1159                2
1160            } else {
1161                1
1162            };
1163            (SyntaxKind::NEWLINE, len)
1164        }
1165        ' ' | '\t' => (
1166            SyntaxKind::WHITESPACE,
1167            run_len(rest, |c| c == ' ' || c == '\t'),
1168        ),
1169        _ => (
1170            SyntaxKind::WORD,
1171            run_len(rest, |c| is_word_char(c) || (expl_syntax && c == '_')),
1172        ),
1173    }
1174}
1175
1176/// Lex a control sequence: `rest` is known to start with `\`, and `word_len` is
1177/// its pre-scanned [`control_word_len`] — `Some` for a control word (backslash
1178/// plus one or more letters, `@` too under `\makeatletter`, `_`/`:` too under
1179/// `\ExplSyntaxOn`), `None` for a control symbol.
1180fn lex_control(rest: &str, word_len: Option<usize>) -> (SyntaxKind, usize) {
1181    match word_len {
1182        Some(word_len) => {
1183            // `\verb` / `\verb*`: swallow the delimited argument as one token.
1184            if &rest[..word_len] == "\\verb"
1185                && let Some(arg_len) = verb_len(&rest[word_len..])
1186            {
1187                return (SyntaxKind::VERB, word_len + arg_len);
1188            }
1189            (SyntaxKind::CONTROL_WORD, word_len)
1190        }
1191        // Control symbol: backslash + exactly one other character — or a lone
1192        // trailing backslash at end of input. CRLF is one physical line ending,
1193        // so consume it atomically just as the ordinary newline lexer does.
1194        None => {
1195            let after = &rest[1..];
1196            let symbol_len = if after.starts_with("\r\n") {
1197                2
1198            } else {
1199                after.chars().next().map_or(0, char::len_utf8)
1200            };
1201            (SyntaxKind::CONTROL_SYMBOL, 1 + symbol_len)
1202        }
1203    }
1204}
1205
1206/// Length in bytes of a `\verb` argument: an optional `*`, then a delimited run.
1207/// Returns `None` if malformed (no delimiter, or it spans a line break).
1208fn verb_len(after: &str) -> Option<usize> {
1209    match after.strip_prefix('*') {
1210        Some(rest) => Some(1 + delimited_len(rest)?),
1211        None => delimited_len(after),
1212    }
1213}
1214
1215/// Length in bytes of a `\verb`-style delimited run: a delimiter character, then
1216/// everything up to and including its next occurrence. Returns `None` if the
1217/// delimiter is whitespace or the run spans a line break.
1218fn delimited_len(after: &str) -> Option<usize> {
1219    let mut chars = after.chars();
1220    let delim = chars.next()?;
1221    if delim.is_whitespace() {
1222        return None;
1223    }
1224    let mut consumed = delim.len_utf8();
1225    for c in chars {
1226        if c == '\n' || c == '\r' {
1227            return None;
1228        }
1229        consumed += c.len_utf8();
1230        if c == delim {
1231            return Some(consumed);
1232        }
1233    }
1234    None
1235}
1236
1237/// The character argument of `\MakeShortVerb`/`\DeleteShortVerb`, read from the
1238/// text following the control word: an optional `*`, inline whitespace, then
1239/// `{\c}` or a bare `\c`. Returns `None` when the shape does not match (e.g. at
1240/// the command's own definition site, `\def\MakeShortVerb{…`), so a non-call
1241/// never toggles. Same-line only — the argument conventionally abuts the call.
1242fn short_verb_char(after: &str) -> Option<char> {
1243    let s = skip_inline_ws(after.strip_prefix('*').unwrap_or(after));
1244    let (body, braced) = match s.strip_prefix('{') {
1245        Some(inner) => (skip_inline_ws(inner), true),
1246        None => (s, false),
1247    };
1248    let arg = body.strip_prefix('\\')?;
1249    let c = arg.chars().next()?;
1250    if c == '\n' || c == '\r' {
1251        return None;
1252    }
1253    if braced && !skip_inline_ws(&arg[c.len_utf8()..]).starts_with('}') {
1254        return None;
1255    }
1256    Some(c)
1257}
1258
1259/// The documentation classes that make `|` a short verb themselves, so loading one
1260/// enables the short-verb capture with no `\MakeShortVerb` in the file. `ltxdoc`
1261/// and `l3doc` call `\MakeShortVerb` on `\|`; `ltxguide`, `ltnews`, and `amsldoc`
1262/// define the equivalent active `|` (`\gdef|{\protect\activevert{}}`, amsldoc.cls).
1263/// Curated and closed — a class outside it leaves `|` alone (issue #71).
1264const BAR_SHORT_VERB_CLASSES: [&str; 5] = ["ltxdoc", "ltxguide", "ltnews", "l3doc", "amsldoc"];
1265
1266/// Whether the `{name}` argument following `\documentclass`/`\LoadClass` names one
1267/// of [`BAR_SHORT_VERB_CLASSES`]. A leading `[options]` group is skipped; a
1268/// trailing `[date]` is ignored.
1269fn doc_class_enables_bar(after: &str) -> bool {
1270    let mut s = skip_inline_ws(after);
1271    if let Some(rest) = s.strip_prefix('[') {
1272        match rest.find(']') {
1273            Some(i) => s = rest[i + 1..].trim_start_matches([' ', '\t', '\n', '\r']),
1274            None => return false,
1275        }
1276    }
1277    let Some(rest) = s.strip_prefix('{') else {
1278        return false;
1279    };
1280    let Some(close) = rest.find('}') else {
1281        return false;
1282    };
1283    BAR_SHORT_VERB_CLASSES.contains(&rest[..close].trim())
1284}
1285
1286/// If `rest` starts with `\begin{name}` for a verbatim-like `name`, emit the
1287/// `\begin{name}` tokens, then any environment arguments as ordinary tokens, and
1288/// finally a single raw body token, returning the bytes consumed (through the body,
1289/// up to the closing `\end{name}`).
1290///
1291/// Arguments are lexed *before* the body because the raw body begins only after
1292/// them: in `\begin{minted}{python}`, `{python}` is a structured argument, not body
1293/// text. The built-in signature ([`builtin`]) bounds how many leading groups count
1294/// as arguments, so a body that legitimately starts with `[` (an option-free
1295/// `lstlisting` whose first code line is `[1,2,3]`) is not mistaken for one.
1296fn lex_verbatim_environment(rest: &str, ctx: &ParseCtx, out: &mut Vec<Token>) -> Option<usize> {
1297    let (name, prefix_len) = begin_name(rest)?;
1298    // A user-defined catcode-verbatim environment (from `ctx`) wins over the built-in
1299    // DB; either way we read only the static leading-argument shape, never macro
1300    // meaning. The verbatim args are all leading — the raw body follows them.
1301    let args: &[ArgSpec] = match ctx.verbatim_environment_args(name) {
1302        Some(args) => args,
1303        None => {
1304            &builtin()
1305                .environment(name)
1306                .filter(|e| e.verbatim_body)?
1307                .args
1308        }
1309    };
1310
1311    push_env_delimiter(out, "\\begin", name);
1312
1313    // Locate the argument span, then tokenize it normally. It holds no nested
1314    // verbatim-begin, so the ordinary token loop is safe and lets the parser build
1315    // the usual OPTIONAL/GROUP argument nodes.
1316    let args_region = &rest[prefix_len..];
1317    let args_len = scan_verbatim_args(args_region, args);
1318    lex_into(&args_region[..args_len], out);
1319
1320    let body_region = &args_region[args_len..];
1321    let body_len = verbatim_body_len(body_region, name);
1322    if body_len > 0 {
1323        out.push(Token {
1324            kind: SyntaxKind::VERBATIM_BODY,
1325            text: SmolStr::new(&body_region[..body_len]),
1326        });
1327    }
1328    Some(prefix_len + args_len + body_len)
1329}
1330
1331/// Byte offset within `body` of the `\end{name}` that terminates it, or `body`'s
1332/// full length when the environment is never closed (the raw body then runs to end
1333/// of input, which keeps the lex lossless either way).
1334///
1335/// Matched by scanning for the fixed `\end{` lead and comparing the name in place,
1336/// rather than searching for a per-environment `\end{name}` string — the latter
1337/// allocates once per verbatim environment in the file for a comparison the borrow
1338/// already supports.
1339fn verbatim_body_len(body: &str, name: &str) -> usize {
1340    const LEAD: &str = "\\end{";
1341    let mut from = 0;
1342    while let Some(rel) = body[from..].find(LEAD) {
1343        let at = from + rel;
1344        let after = &body[at + LEAD.len()..];
1345        if let Some(tail) = after.strip_prefix(name)
1346            && tail.starts_with('}')
1347        {
1348            return at;
1349        }
1350        from = at + LEAD.len();
1351    }
1352    body.len()
1353}
1354
1355/// If `rest` starts with `\begin{name}` for an environment whose name argument is
1356/// xparse `v`-type (`verbatim_arg` in the curated DB: l3doc's `macro`/`function`/
1357/// `variable`, declared `{ O{} +v }`), emit the `\begin{name}` tokens, a leading
1358/// `[…]` optional as ordinary tokens, and the name argument as one opaque `VERB`
1359/// token, returning the bytes consumed. Both argument forms capture:
1360/// - The *delimited* form (`\begin{macro}+\@@_compile_{:+`) captures the whole
1361///   delimited span as the `VERB`. Upstream chooses this form precisely when the
1362///   name holds unbalanced braces (`\@@_compile_}:`), which would otherwise
1363///   corrupt group pairing for the rest of the file. The delimiter must directly
1364///   abut and be punctuation that cannot open another argument shape (never `\`,
1365///   a brace or bracket, `%`, `*`, or `$`), so an ordinary `\begin{macro}`
1366///   followed by prose or code never captures.
1367/// - The *braced* form (`\begin{macro}{\]}`) keeps its `{`/`}` as ordinary brace
1368///   tokens (the parser still builds the usual name `GROUP`) with the balanced
1369///   content between them as the `VERB`: the content is raw data, so a `\]`,
1370///   `\(`, or `$` in a name never opens math or draws an orphan-closer
1371///   diagnostic (issue #60). Balance tracking skips escaped braces (`\{`, `\}`
1372///   are part of a name, not group delimiters).
1373///
1374/// Same-line only, like `\verb`, in both forms. The parser attaches the abutting
1375/// `VERB` or name group into the `BEGIN` node like any verbatim command argument
1376/// (`attach_arguments`).
1377fn lex_verbatim_arg_environment(rest: &str, out: &mut Vec<Token>) -> Option<usize> {
1378    let (name, prefix_len) = begin_name(rest)?;
1379    builtin().environment(name).filter(|e| e.verbatim_arg)?;
1380
1381    // A leading `[…]` optional (the `O{}` slot, `\begin{macro}[EXP]+…+`) is
1382    // structured, not verbatim; it lexes normally below. Same-line, unnested.
1383    let region = &rest[prefix_len..];
1384    let mut args_len = 0;
1385    if let Some(after) = region.strip_prefix('[') {
1386        let i = after.find([']', '\n', '\r'])?;
1387        if after.as_bytes()[i] != b']' {
1388            return None;
1389        }
1390        args_len = 1 + i + 1;
1391    }
1392    let arg_region = &region[args_len..];
1393    let delim = arg_region.chars().next()?;
1394    let braced_content_len = if delim == '{' {
1395        Some(braced_verb_content_len(&arg_region[1..])?)
1396    } else {
1397        if !delim.is_ascii_punctuation()
1398            || matches!(delim, '\\' | '}' | '[' | ']' | '%' | '*' | '$')
1399        {
1400            return None;
1401        }
1402        None
1403    };
1404
1405    push_env_delimiter(out, "\\begin", name);
1406    lex_into(&region[..args_len], out);
1407    let verb_len = match braced_content_len {
1408        // Braced form: `{` VERB(content) `}` — the braces stay real tokens so
1409        // the parser builds the ordinary name `GROUP`.
1410        Some(content_len) => {
1411            out.push(Token {
1412                kind: SyntaxKind::L_BRACE,
1413                text: SmolStr::new("{"),
1414            });
1415            out.push(Token {
1416                kind: SyntaxKind::VERB,
1417                text: SmolStr::new(&arg_region[1..1 + content_len]),
1418            });
1419            out.push(Token {
1420                kind: SyntaxKind::R_BRACE,
1421                text: SmolStr::new("}"),
1422            });
1423            1 + content_len + 1
1424        }
1425        None => {
1426            let verb_len = delimited_len(arg_region)?;
1427            out.push(Token {
1428                kind: SyntaxKind::VERB,
1429                text: SmolStr::new(&arg_region[..verb_len]),
1430            });
1431            verb_len
1432        }
1433    };
1434    Some(prefix_len + args_len + verb_len)
1435}
1436
1437/// Length of the brace-balanced content of a braced `v`-type name argument,
1438/// starting just past the opening `{`. Same-line only; escaped braces (`\{`,
1439/// `\}`) are name characters, not delimiters. `None` when the closing `}` is
1440/// not on the line (falls back to normal lexing) or the content is empty
1441/// (nothing to capture; a bare `{}` lexes normally).
1442fn braced_verb_content_len(content: &str) -> Option<usize> {
1443    let mut depth = 1usize;
1444    let mut chars = content.char_indices();
1445    while let Some((i, c)) = chars.next() {
1446        match c {
1447            '\\' => {
1448                chars.next()?;
1449            }
1450            '{' => depth += 1,
1451            '}' => {
1452                depth -= 1;
1453                if depth == 0 {
1454                    return (i > 0).then_some(i);
1455                }
1456            }
1457            '\n' | '\r' => return None,
1458            _ => {}
1459        }
1460    }
1461    None
1462}
1463
1464/// A `.dtx` `macrocode` frame line, at a line start: `%␣*\begin{macrocode}` (when
1465/// `want_begin`) or `%␣*\end{macrocode}` (otherwise), with the `*` variant
1466/// accepted. On a match, emit the frame tokens — the `%` margin, the indent
1467/// whitespace, the `\begin`/`\end` control word, and the `{macrocode}` name group —
1468/// and return the bytes consumed (through the closing `}`; the trailing newline
1469/// lexes normally). Returns `None` when `rest` is not the requested frame.
1470///
1471/// Unlike a verbatim environment, the body is *not* captured here: it lexes as
1472/// ordinary code in the main loop (under the package regime). The frame line must
1473/// hold nothing but trailing whitespace after the name group, so a stray
1474/// `\begin{macrocode}{x}` is not mistaken for a frame. The *end* frame also
1475/// tolerates a trailing `%` comment (`%    \end{macrocode}%`, a guard against a
1476/// stray trailing space): doc.sty's terminator is a delimited match on the
1477/// `%    \end{macrocode}` string, so anything after it on the line is doc-layer
1478/// material. A begin frame stays strict — same-line text there is captured into
1479/// the body by `\xmacro@code`, not doc prose.
1480///
1481/// A *begin* frame additionally tolerates indentation before the `%`. In the
1482/// documentation layer `\DocInput` runs under `\MakePercentIgnore`
1483/// (`` \catcode`\%=9 ``, doc.dtx), so a `%` there is an *ignored* character at any
1484/// column and `␣*%␣*\begin{macrocode}` opens a chunk exactly like the column-0
1485/// spelling (multicol.dtx, latex-lab-block.dtx — issue #71). The indent rides as a
1486/// `WHITESPACE` token before the margin, so the line stays lossless and the
1487/// formatter re-pins the frame at column 0. The *end* frame stays column-0 strict:
1488/// inside the body `%` is a comment again, and doc.sty terminates on a delimited
1489/// match against the literal `%    \end{macrocode}` line.
1490fn lex_macrocode_frame(rest: &str, want_begin: bool, out: &mut Vec<Token>) -> Option<usize> {
1491    let indent = if want_begin { inline_ws_len(rest) } else { 0 };
1492    let after_pct = rest[indent..].strip_prefix('%')?;
1493    let ws_len = inline_ws_len(after_pct);
1494    let body = &after_pct[ws_len..];
1495    let (control, open) = if want_begin {
1496        ("\\begin", "\\begin{")
1497    } else {
1498        ("\\end", "\\end{")
1499    };
1500    let after_open = body.strip_prefix(open)?;
1501    let close = after_open.find('}')?;
1502    let name = &after_open[..close];
1503    if name != "macrocode" && name != "macrocode*" {
1504        return None;
1505    }
1506    // The frame line carries nothing but trailing whitespace after `}` — plus,
1507    // on an end frame, an optional `%` comment tail (lexed as an ordinary
1508    // `COMMENT` by the main loop).
1509    let after_close = &after_open[close + 1..];
1510    let tail = skip_inline_ws(after_close);
1511    let comment_tail = !want_begin && tail.starts_with('%');
1512    if !(tail.is_empty() || tail.starts_with('\n') || tail.starts_with('\r') || comment_tail) {
1513        return None;
1514    }
1515
1516    if indent > 0 {
1517        out.push(Token {
1518            kind: SyntaxKind::WHITESPACE,
1519            text: SmolStr::new(&rest[..indent]),
1520        });
1521    }
1522    out.push(Token {
1523        kind: SyntaxKind::DOC_MARGIN,
1524        text: SmolStr::new("%"),
1525    });
1526    if ws_len > 0 {
1527        out.push(Token {
1528            kind: SyntaxKind::WHITESPACE,
1529            text: SmolStr::new(&after_pct[..ws_len]),
1530        });
1531    }
1532    push_env_delimiter(out, control, name);
1533    Some(indent + 1 + ws_len + control.len() + 1 + name.len() + 1)
1534}
1535
1536/// If `rest` starts with a verbatim-argument command (`\url`, `\href`,
1537/// `\lstinline`, …), emit its control word, any leading ordinary arguments, and
1538/// one raw [`SyntaxKind::VERB`] token; return the bytes consumed. A whole-command
1539/// capture treats the raw argument as an implicit final slot, while a positional
1540/// capture stops at its marked slot and leaves later arguments for the ordinary
1541/// lexer. Returns `None` when no complete raw argument follows.
1542///
1543/// The verbatim argument's form is decided by its first non-blank character,
1544/// matching how these commands actually parse: a brace introduces a balanced
1545/// `{…}` group (`\code{…}`, `\url{…}`); any other character is a `\verb`-style
1546/// delimiter run (`\lstinline|…|`), but only for built-ins whose signature
1547/// grants the delimiter form (`verbatim_delimited`). For braced-only commands —
1548/// `\code`, `\path`, and every scanner-discovered user command — a non-brace
1549/// follower means this occurrence is not a verbatim argument (the name may be an
1550/// unrelated user macro: `\code` as a math operator, TikZ's `\path (0,0)`), so
1551/// we return `None` and lex normally; a missed capture is benign where a wrong
1552/// delimiter capture swallows text across the line. `\verb`/`\verb*` are
1553/// deliberately excluded — they are delimiter-only and handled in
1554/// [`lex_control`]. Like the verbatim environment path, this reads only static
1555/// signature data (decision #1).
1556///
1557/// `word_len` is the pre-scanned [`control_word_len`] at `rest`, so the command's
1558/// letter run is not re-scanned here and again when the caller falls through to
1559/// ordinary lexing.
1560fn lex_verbatim_command(
1561    rest: &str,
1562    word_len: Option<usize>,
1563    ctx: &ParseCtx,
1564    on_dtx_doc_line: bool,
1565    out: &mut Vec<Token>,
1566) -> Option<usize> {
1567    let word_len = word_len?;
1568    let name = &rest[1..word_len];
1569    // `\verb` keeps its dedicated delimiter-only path.
1570    if name == "verb" {
1571        return None;
1572    }
1573    // A user-defined catcode-verbatim command (from `ctx`) wins over the built-in DB.
1574    // Otherwise only the curated tier may establish either the legacy implicit-final
1575    // capture or a positional raw slot. Discovered commands are `\newcommand`-style
1576    // braced definitions, so they never get the delimiter form.
1577    let (leading, delimited): (&[ArgSpec], bool) = match ctx.leading_args(name) {
1578        Some(args) => (args, false),
1579        None => {
1580            // A visible non-verbatim redefinition in this file shadows the built-in, so
1581            // don't capture — lex the braced argument as an ordinary group (issue #53).
1582            if ctx.is_suppressed(name) {
1583                return None;
1584            }
1585            let sig = builtin().command(name)?;
1586            if sig.verbatim {
1587                (&sig.args, sig.verbatim_delimited)
1588            } else {
1589                let raw = sig.args.iter().position(|arg| arg.verbatim)?;
1590                // Positional raw arguments are brace-delimited. Delimiter runs remain
1591                // the legacy implicit-final command facet because they have no GROUP
1592                // slot in the CST or signature model.
1593                if sig.args[raw].kind != ArgKind::Brace {
1594                    return None;
1595                }
1596                (&sig.args[..raw], false)
1597            }
1598        }
1599    };
1600
1601    // Leading arguments precede the verbatim one (e.g. `\mintinline{lang}{code}`).
1602    let after_word = &rest[word_len..];
1603    let args_len = scan_verbatim_args(after_word, leading);
1604
1605    // A braced-only argument is an ordinary TeX argument and may begin on the
1606    // next line. Delimiter-style verbatim remains same-line: its closing delimiter
1607    // cannot cross a line break.
1608    let region = &after_word[args_len..];
1609    let dtx_gap = (!delimited && on_dtx_doc_line)
1610        .then(|| dtx_doc_argument_gap_len(region))
1611        .flatten();
1612    let ws_len = if let Some(len) = dtx_gap {
1613        len
1614    } else if delimited {
1615        inline_ws_len(region)
1616    } else {
1617        tex_whitespace_len(region)
1618    };
1619    let arg_region = &region[ws_len..];
1620    let arg_len = match arg_region.bytes().next() {
1621        Some(b'{') => balanced_group_len(arg_region, b'}')?,
1622        // A `\verb`-style delimiter run: the first character delimits, and the
1623        // argument may not span a line break.
1624        Some(_) if delimited => delimited_len(arg_region)?,
1625        _ => return None,
1626    };
1627
1628    out.push(Token {
1629        kind: SyntaxKind::CONTROL_WORD,
1630        text: SmolStr::new(&rest[..word_len]),
1631    });
1632    lex_into(&after_word[..args_len], out);
1633    if ws_len > 0 {
1634        if dtx_gap.is_some() {
1635            lex_dtx_doc_argument_gap(&region[..ws_len], out);
1636        } else {
1637            out.push(Token {
1638                kind: SyntaxKind::WHITESPACE,
1639                text: SmolStr::new(&region[..ws_len]),
1640            });
1641        }
1642    }
1643    out.push(Token {
1644        kind: SyntaxKind::VERB,
1645        text: SmolStr::new(&arg_region[..arg_len]),
1646    });
1647    Some(word_len + args_len + ws_len + arg_len)
1648}
1649
1650/// Byte length of the argument span that precedes a verbatim body, given the
1651/// environment's declared `args`. For each argument in order, consume any inline
1652/// whitespace (spaces/tabs, never a line break — an argument never crosses a
1653/// newline, so a bracket on the next line is body text) followed by the balanced
1654/// group of the expected delimiter when present. A missing optional or required
1655/// argument is skipped; a malformed (unbalanced) group is left to the body, so the
1656/// scan never runs past the input and losslessness is preserved.
1657fn scan_verbatim_args(region: &str, args: &[ArgSpec]) -> usize {
1658    let bytes = region.as_bytes();
1659    let mut pos = 0;
1660    for arg in args {
1661        let probe = pos + inline_ws_len(&region[pos..]);
1662        let (open, close) = match arg.kind {
1663            ArgKind::Bracket => (b'[', b']'),
1664            ArgKind::Brace => (b'{', b'}'),
1665        };
1666        if bytes.get(probe) != Some(&open) {
1667            // Argument absent; the skipped whitespace belongs to the body.
1668            continue;
1669        }
1670        match balanced_group_len(&region[probe..], close) {
1671            Some(len) => pos = probe + len,
1672            None => break, // unbalanced: treat the remainder as body
1673        }
1674    }
1675    pos
1676}
1677
1678/// Length in bytes of the balanced group starting at `s[0]` (an `[` or `{`), up to
1679/// and including its matching closer. Brace and bracket nesting is tracked with a
1680/// delimiter stack, so a `]` inside `{…}` (or vice versa) is treated as literal; a
1681/// `\`-escaped delimiter is skipped. Returns `None` if the group never closes.
1682fn balanced_group_len(s: &str, close: u8) -> Option<usize> {
1683    let bytes = s.as_bytes();
1684    let mut stack = vec![close];
1685    let mut i = 1;
1686    while i < bytes.len() {
1687        match bytes[i] {
1688            b'\\' => {
1689                // Skip the escaped byte; a delimiter loses its meaning.
1690                i += 2;
1691                continue;
1692            }
1693            b'{' => stack.push(b'}'),
1694            b'[' => stack.push(b']'),
1695            c @ (b'}' | b']') if stack.last() == Some(&c) => {
1696                stack.pop();
1697                if stack.is_empty() {
1698                    return Some(i + 1);
1699                }
1700            }
1701            // A non-matching closer is literal text; ignore it.
1702            _ => {}
1703        }
1704        i += 1;
1705    }
1706    None
1707}
1708
1709/// Tokenize `region` with the ordinary, context-free token loop, appending to
1710/// `out`. Used for the argument span of a verbatim-like environment, which carries
1711/// no `\makeatletter` or nested verbatim-begin context.
1712fn lex_into(region: &str, out: &mut Vec<Token>) {
1713    let mut pos = 0;
1714    while pos < region.len() {
1715        let rest = &region[pos..];
1716        let (kind, len) = next_token(rest, control_word_len(rest, false, false), false);
1717        debug_assert!(len > 0, "lexer made no progress in verbatim args");
1718        out.push(Token {
1719            kind,
1720            text: SmolStr::new(&region[pos..pos + len]),
1721        });
1722        pos += len;
1723    }
1724}
1725
1726/// The environment name of a `\begin{name}` at the start of `rest`, together with
1727/// the byte length of the whole `\begin{name}` prefix. `None` when `rest` does not
1728/// open one, or when the name group never closes.
1729fn begin_name(rest: &str) -> Option<(&str, usize)> {
1730    let after = rest.strip_prefix("\\begin{")?;
1731    let close = after.find('}')?;
1732    Some((&after[..close], "\\begin{".len() + close + 1))
1733}
1734
1735/// Emit the four tokens of an environment delimiter — `\begin`/`\end`, `{`, the
1736/// name, `}` — so the ordinary environment grammar sees the shape it expects even
1737/// where the lexer claimed the surrounding line itself (a verbatim `\begin`, a
1738/// `.dtx` `macrocode` frame).
1739fn push_env_delimiter(out: &mut Vec<Token>, control: &str, name: &str) {
1740    out.push(Token {
1741        kind: SyntaxKind::CONTROL_WORD,
1742        text: SmolStr::new(control),
1743    });
1744    out.push(Token {
1745        kind: SyntaxKind::L_BRACE,
1746        text: SmolStr::new("{"),
1747    });
1748    out.push(Token {
1749        kind: SyntaxKind::WORD,
1750        text: SmolStr::new(name),
1751    });
1752    out.push(Token {
1753        kind: SyntaxKind::R_BRACE,
1754        text: SmolStr::new("}"),
1755    });
1756}
1757
1758/// Number of leading bytes of `s` that are inline whitespace — spaces and tabs,
1759/// never a line break. An argument never crosses a newline, and a `.dtx` frame
1760/// line's indent is likewise same-line, so every scan in this module that steps
1761/// over blanks means exactly this.
1762fn inline_ws_len(s: &str) -> usize {
1763    s.bytes().take_while(|&b| b == b' ' || b == b'\t').count()
1764}
1765
1766/// Number of leading ASCII whitespace bytes TeX may skip before a braced argument.
1767fn tex_whitespace_len(s: &str) -> usize {
1768    s.bytes()
1769        .take_while(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r'))
1770        .count()
1771}
1772
1773/// A verbatim command on a `.dtx` documentation line may take its braced argument
1774/// on the next margined line. Return the gap through that line's indentation.
1775fn dtx_doc_argument_gap_len(s: &str) -> Option<usize> {
1776    let inline = inline_ws_len(s);
1777    let rest = &s[inline..];
1778    let newline = if rest.starts_with("\r\n") {
1779        2
1780    } else if rest.starts_with(['\n', '\r']) {
1781        1
1782    } else {
1783        return None;
1784    };
1785    let after_newline = &rest[newline..];
1786    let after_margin = after_newline.strip_prefix('%')?;
1787    Some(inline + newline + 1 + inline_ws_len(after_margin))
1788}
1789
1790fn lex_dtx_doc_argument_gap(gap: &str, out: &mut Vec<Token>) {
1791    let inline = inline_ws_len(gap);
1792    if inline > 0 {
1793        out.push(Token {
1794            kind: SyntaxKind::WHITESPACE,
1795            text: SmolStr::new(&gap[..inline]),
1796        });
1797    }
1798    let rest = &gap[inline..];
1799    let newline = if rest.starts_with("\r\n") { 2 } else { 1 };
1800    out.push(Token {
1801        kind: SyntaxKind::NEWLINE,
1802        text: SmolStr::new(&rest[..newline]),
1803    });
1804    out.push(Token {
1805        kind: SyntaxKind::DOC_MARGIN,
1806        text: SmolStr::new("%"),
1807    });
1808    let trailing = &rest[newline + 1..];
1809    if !trailing.is_empty() {
1810        out.push(Token {
1811            kind: SyntaxKind::WHITESPACE,
1812            text: SmolStr::new(trailing),
1813        });
1814    }
1815}
1816
1817/// `s` past its leading [`inline_ws_len`].
1818fn skip_inline_ws(s: &str) -> &str {
1819    &s[inline_ws_len(s)..]
1820}
1821
1822/// Number of leading bytes of `s` whose chars all satisfy `pred`.
1823fn run_len(s: &str, pred: impl Fn(char) -> bool) -> usize {
1824    let mut len = 0;
1825    for c in s.chars() {
1826        if pred(c) {
1827            len += c.len_utf8();
1828        } else {
1829            break;
1830        }
1831    }
1832    len
1833}
1834
1835/// A control-word continuation character: a letter, `@` under `\makeatletter`,
1836/// or `_`/`:` under `\ExplSyntaxOn` (where they are catcode-11 letters).
1837fn is_letter(c: char, at_letter: bool, expl_syntax: bool) -> bool {
1838    c.is_ascii_alphabetic() || (at_letter && c == '@') || (expl_syntax && (c == '_' || c == ':'))
1839}
1840
1841/// Could `name` (without its leading `\`) lex as a single [control
1842/// word](control_word_len) in *some* catcode regime?
1843///
1844/// The most permissive regime is the bar on purpose: a name is checked here
1845/// against `\makeatletter` *and* `\ExplSyntaxOn` letters at once, because a
1846/// declaration does not say which file it will be read in. Shares
1847/// [`is_letter`] with the lexer rather than restating the letter set, for the
1848/// same reason the expl3 toggle names are one set: a name the lexer would split
1849/// into two tokens can never match a declaration, so accepting one would be a
1850/// silent no-op (see [`crate::declarations`]).
1851pub fn is_control_word_name(name: &str) -> bool {
1852    !name.is_empty() && name.chars().all(|c| is_letter(c, true, true))
1853}
1854
1855/// Ordinary text: anything that is not whitespace, a line break, or one of the
1856/// characters the lexer treats specially.
1857pub fn is_word_char(c: char) -> bool {
1858    !matches!(
1859        c,
1860        '\\' | '%'
1861            | '{'
1862            | '}'
1863            | '['
1864            | ']'
1865            | '$'
1866            | '&'
1867            | '#'
1868            | '^'
1869            | '_'
1870            | '~'
1871            | ' '
1872            | '\t'
1873            | '\n'
1874            | '\r'
1875    )
1876}
1877
1878#[cfg(test)]
1879mod tests {
1880    use super::*;
1881
1882    /// The lexer is total and lossless: concatenated token text == input.
1883    fn assert_lossless(input: &str) {
1884        let joined: String = lex(input).iter().map(|t| t.text.as_str()).collect();
1885        assert_eq!(joined, input);
1886    }
1887
1888    #[test]
1889    fn the_pending_arming_sets_are_disjoint() {
1890        // `Pending` is one slot, which is faithful only because no control word
1891        // arms two modes. `next_pending` tests the four in a fixed order, so an
1892        // overlap would silently make one set shadow the other rather than fail
1893        // to compile. Every name currently in any set is checked here; a name
1894        // added to a *second* set trips this.
1895        for name in [
1896            "\\left",
1897            "\\right",
1898            "\\newcommand",
1899            "\\renewcommand",
1900            "\\providecommand",
1901            "\\DeclareRobustCommand",
1902            "\\NewDocumentCommand",
1903            "\\RenewDocumentCommand",
1904            "\\ProvideDocumentCommand",
1905            "\\DeclareDocumentCommand",
1906            "\\def",
1907            "\\edef",
1908            "\\gdef",
1909            "\\xdef",
1910            "\\let",
1911            "\\char",
1912            "\\catcode",
1913            "\\lccode",
1914            "\\uccode",
1915            "\\sfcode",
1916            "\\mathcode",
1917            "\\delcode",
1918            "\\number",
1919            "\\the",
1920            "\\romannumeral",
1921            "\\numexpr",
1922            "\\dimexpr",
1923            "\\ifnum",
1924            "\\ifodd",
1925            "\\ifdim",
1926            "\\string",
1927            "\\noexpand",
1928            "\\meaning",
1929            "\\expandafter",
1930            "\\show",
1931        ] {
1932            let armed = [
1933                name == "\\left" || name == "\\right",
1934                is_definition_keyword(name),
1935                is_char_constant_command(name),
1936                is_literal_token_command(name),
1937            ];
1938            assert_eq!(
1939                armed.iter().filter(|&&x| x).count(),
1940                1,
1941                "{name} arms {armed:?} — the `Pending` slot needs disjoint sets"
1942            );
1943        }
1944    }
1945
1946    #[test]
1947    fn a_claimed_construct_spends_the_armed_char_constant_mode() {
1948        // Every construct a `try_*` probe claims whole clears the one-shot slot,
1949        // so an armed mode never survives an unrelated capture. Directly after
1950        // `\char` a backtick still opens the char constant…
1951        let direct = lex("\\char `\\%");
1952        assert!(
1953            direct
1954                .iter()
1955                .any(|t| t.kind == SyntaxKind::WORD && t.text == "`\\%")
1956        );
1957        // …but with a short-verb capture in between, the mode is spent and the
1958        // backtick is ordinary text. (Before the four flags collapsed into one
1959        // slot this branch cleared a hand-picked subset that left the
1960        // char-constant mode armed indefinitely.)
1961        let intervened = lex("\\MakeShortVerb{\\|} \\char |a| `\\%");
1962        assert!(
1963            intervened
1964                .iter()
1965                .any(|t| t.kind == SyntaxKind::VERB && t.text == "|a|")
1966        );
1967        assert!(
1968            !intervened
1969                .iter()
1970                .any(|t| t.kind == SyntaxKind::WORD && t.text == "`\\%")
1971        );
1972        assert_lossless("\\MakeShortVerb{\\|} \\char |a| `\\%");
1973    }
1974
1975    #[test]
1976    fn block_environment_classification() {
1977        let ctx = ParseCtx::default();
1978        assert!(ctx.is_block_environment("figure"));
1979        assert!(ctx.is_block_environment("itemize")); // derived via `list`
1980        assert!(!ctx.is_block_environment("myenv")); // unknown
1981    }
1982
1983    #[test]
1984    fn lossless_on_assorted_inputs() {
1985        for input in [
1986            "",
1987            "plain text",
1988            r"\section{Hi}[x]",
1989            "$a^2_b$",
1990            "a%c\n\nb",
1991            "café ∑ \\\\ \\{ \\,",
1992            "tab\tand  spaces",
1993            "trailing\\",
1994            r"\verb|$x$|",
1995            "\\begin{verbatim}\n$x$ %not a comment\n\\end{verbatim}",
1996            "\\begin{lstlisting}[language=C]\nint a[3];  % raw\n\\end{lstlisting}",
1997            "\\begin{minted}[frame=single]{python}\nprint(\"$x$\")\n\\end{minted}",
1998            "\\begin{lstlisting}\n[1,2,3]\n\\end{lstlisting}",
1999            r"\makeatletter\a@b\makeatother\a@b",
2000            r"\ExplSyntaxOn\seq_new:N \g_@@_x_tl a_b\ExplSyntaxOff\seq_new:N",
2001            r"$\left(x+y\right)^2 \left.\frac{a}{b}\right|_0$",
2002        ] {
2003            assert_lossless(input);
2004        }
2005    }
2006
2007    #[test]
2008    fn control_word_stops_at_non_letter() {
2009        let toks = lex(r"\alpha2");
2010        assert_eq!(toks[0].kind, SyntaxKind::CONTROL_WORD);
2011        assert_eq!(toks[0].text, "\\alpha");
2012        assert_eq!(toks[1].kind, SyntaxKind::WORD);
2013        assert_eq!(toks[1].text, "2");
2014    }
2015
2016    #[test]
2017    fn double_backslash_is_one_control_symbol() {
2018        let toks = lex(r"\\");
2019        assert_eq!(toks.len(), 1);
2020        assert_eq!(toks[0].kind, SyntaxKind::CONTROL_SYMBOL);
2021        assert_eq!(toks[0].text, r"\\");
2022    }
2023
2024    #[test]
2025    fn comment_stops_before_newline() {
2026        let toks = lex("% hi\nx");
2027        assert_eq!(toks[0].kind, SyntaxKind::COMMENT);
2028        assert_eq!(toks[0].text, "% hi");
2029        assert_eq!(toks[1].kind, SyntaxKind::NEWLINE);
2030    }
2031
2032    #[test]
2033    fn crlf_is_a_single_newline() {
2034        let toks = lex("a\r\nb");
2035        assert_eq!(toks[1].kind, SyntaxKind::NEWLINE);
2036        assert_eq!(toks[1].text, "\r\n");
2037    }
2038
2039    #[test]
2040    fn control_symbol_swallows_the_whole_line_ending() {
2041        for ending in ["\n", "\r", "\r\n"] {
2042            let input = format!("\\{ending}");
2043            let toks = lex(&input);
2044            assert_eq!(toks.len(), 1, "split line ending {ending:?}");
2045            assert_eq!(toks[0].kind, SyntaxKind::CONTROL_SYMBOL);
2046            assert_eq!(toks[0].text, input);
2047        }
2048    }
2049
2050    #[test]
2051    fn verb_inline_is_one_token() {
2052        let toks = lex(r"\verb|$x$|");
2053        assert_eq!(toks.len(), 1);
2054        assert_eq!(toks[0].kind, SyntaxKind::VERB);
2055        assert_eq!(toks[0].text, r"\verb|$x$|");
2056    }
2057
2058    #[test]
2059    fn verb_star_with_plus_delimiter() {
2060        let toks = lex(r"a\verb*+b+c");
2061        assert_eq!(toks[1].kind, SyntaxKind::VERB);
2062        assert_eq!(toks[1].text, r"\verb*+b+");
2063        assert_eq!(toks[2].text, "c");
2064    }
2065
2066    #[test]
2067    fn verb_without_closing_delimiter_is_a_plain_control_word() {
2068        let toks = lex(r"\verb|x");
2069        assert_eq!(toks[0].kind, SyntaxKind::CONTROL_WORD);
2070        assert_eq!(toks[0].text, r"\verb");
2071    }
2072
2073    #[test]
2074    fn left_right_isolate_word_delimiter() {
2075        // `(` would normally glue into `(x+y` as one word; after `\left` it is
2076        // its own one-character token, and `\right)`'s `)` likewise.
2077        let toks = lex(r"\left(x+y\right)");
2078        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2079        assert_eq!(
2080            seen,
2081            [
2082                (SyntaxKind::CONTROL_WORD, "\\left"),
2083                (SyntaxKind::WORD, "("),
2084                (SyntaxKind::WORD, "x+y"),
2085                (SyntaxKind::CONTROL_WORD, "\\right"),
2086                (SyntaxKind::WORD, ")"),
2087            ]
2088        );
2089    }
2090
2091    #[test]
2092    fn left_delimiter_carries_across_whitespace() {
2093        // TeX skips spaces before the delimiter; the mode persists so `(` is
2094        // still isolated.
2095        let toks = lex(r"\left ( a");
2096        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2097        assert_eq!(
2098            seen,
2099            [
2100                (SyntaxKind::CONTROL_WORD, "\\left"),
2101                (SyntaxKind::WHITESPACE, " "),
2102                (SyntaxKind::WORD, "("),
2103                (SyntaxKind::WHITESPACE, " "),
2104                (SyntaxKind::WORD, "a"),
2105            ]
2106        );
2107    }
2108
2109    #[test]
2110    fn left_non_word_delimiters_are_untouched() {
2111        // A control-symbol (`\{`), control-word (`\langle`), or bracket delimiter
2112        // already lexes as a single token, so the mode changes nothing.
2113        for input in [r"\left\{", r"\left\langle", r"\left["] {
2114            assert_lossless(input);
2115        }
2116        let toks = lex(r"\left\langle x \right\rangle");
2117        assert!(toks.iter().any(|t| t.text == "\\langle"));
2118        assert!(toks.iter().any(|t| t.text == "\\rangle"));
2119    }
2120
2121    #[test]
2122    fn leftarrow_is_not_left() {
2123        // The maximal letter run keeps `\leftarrow` one control word, so the
2124        // delimiter mode never triggers.
2125        let toks = lex(r"\leftarrow(x)");
2126        assert_eq!(toks[0].text, "\\leftarrow");
2127        // `(x)` glues normally — the mode did not fire.
2128        assert_eq!(toks[1].text, "(x)");
2129    }
2130
2131    #[test]
2132    fn makeatletter_makes_at_a_letter() {
2133        let toks = lex(r"\makeatletter\foo@bar\makeatother\foo@bar");
2134        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2135        // Under \makeatletter, `\foo@bar` is one control word…
2136        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo@bar")));
2137        // …after \makeatother it splits into `\foo` + `@bar`.
2138        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
2139    }
2140
2141    #[test]
2142    fn expl_syntax_makes_underscore_and_colon_letters() {
2143        let toks = lex(r"\ExplSyntaxOn\seq_new:N\ExplSyntaxOff\seq_new:N");
2144        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2145        // Under \ExplSyntaxOn, `\seq_new:N` is one control word…
2146        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
2147        // …after \ExplSyntaxOff it stops at the first `_`.
2148        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq")));
2149    }
2150
2151    #[test]
2152    fn expl_syntax_lexes_internal_double_underscore_name() {
2153        let toks = lex(r"\ExplSyntaxOn\__module_internal:nn");
2154        assert_eq!(toks[1].kind, SyntaxKind::CONTROL_WORD);
2155        assert_eq!(toks[1].text, "\\__module_internal:nn");
2156    }
2157
2158    #[test]
2159    fn provides_expl_package_turns_on_expl_syntax() {
2160        let toks = lex(r"\ProvidesExplPackage{p}{2026/01/01}{1.0}{d}\tl_set:Nn");
2161        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2162        // The `\ProvidesExplPackage` declaration opens expl3 syntax, so the later
2163        // `\tl_set:Nn` lexes as one control word.
2164        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\tl_set:Nn")));
2165    }
2166
2167    #[test]
2168    fn expl_syntax_composes_with_makeatletter() {
2169        // The `@@` module-prefix convention needs both `@` and `_`/`:` as letters.
2170        let toks = lex(r"\makeatletter\ExplSyntaxOn\g_@@_frame_title_tl");
2171        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2172        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\g_@@_frame_title_tl")));
2173    }
2174
2175    #[test]
2176    fn expl_syntax_makes_bare_underscore_a_word_not_subscript() {
2177        let toks = lex(r"\ExplSyntaxOn a_b");
2178        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2179        // Under expl3, `_` is a catcode-11 letter: `a_b` is one word, no UNDERSCORE.
2180        assert!(seen.contains(&(SyntaxKind::WORD, "a_b")));
2181        assert!(!seen.iter().any(|(k, _)| *k == SyntaxKind::UNDERSCORE));
2182    }
2183
2184    /// Lex `input` under the docstrip (`.dtx`) config, the regime in which
2185    /// implicit expl3 applies.
2186    fn lex_dtx(input: &str) -> Vec<Token> {
2187        lex_with(
2188            input,
2189            &ParseCtx::default(),
2190            LexConfig {
2191                flavor: LatexFlavor::Document,
2192                dtx: true,
2193            },
2194        )
2195    }
2196
2197    #[test]
2198    fn implicit_expl_module_guard_makes_macrocode_body_expl3() {
2199        // A toggle-less `.dtx` with only a `%<@@=mod>` module guard: its macrocode
2200        // body is expl3 code, so `\seq_new:N` lexes as one control word.
2201        let toks = lex_dtx(
2202            "%<@@=mod>\n\
2203             %    \\begin{macrocode}\n\
2204             \\seq_new:N\n\
2205             %    \\end{macrocode}\n",
2206        );
2207        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2208        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
2209    }
2210
2211    #[test]
2212    fn no_expl_signal_leaves_macrocode_body_plain() {
2213        // The same shape without a signal: `.dtx` macrocode is plain code, so
2214        // `\seq_new:N` stops at the first `_` (the feature is opt-in).
2215        let toks = lex_dtx(
2216            "%    \\begin{macrocode}\n\
2217             \\seq_new:N\n\
2218             %    \\end{macrocode}\n",
2219        );
2220        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2221        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq")));
2222        assert!(!seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
2223    }
2224
2225    #[test]
2226    fn implicit_expl_provides_expl_flags_every_body_regardless_of_order() {
2227        // `\ProvidesExplPackage` is a whole-file signal, so a macrocode body
2228        // *above* the declaration is expl3 too — the property left-to-right
2229        // toggling misses.
2230        let toks = lex_dtx(
2231            "%    \\begin{macrocode}\n\
2232             \\seq_new:N\n\
2233             %    \\end{macrocode}\n\
2234             % \\ProvidesExplPackage{p}{2026/01/01}{1.0}{d}\n\
2235             %    \\begin{macrocode}\n\
2236             \\tl_set:Nn\n\
2237             %    \\end{macrocode}\n",
2238        );
2239        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2240        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
2241        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\tl_set:Nn")));
2242    }
2243
2244    #[test]
2245    fn implicit_expl_is_body_only_doc_layer_stays_plain() {
2246        // Implicit expl3 is forced inside the macrocode body and restored on exit,
2247        // so the doc-margin line between/around bodies is ordinary LaTeX: `a_b`
2248        // joins in the body but splits on the doc line.
2249        let toks = lex_dtx(
2250            "%<@@=mod>\n\
2251             % a_b\n\
2252             %    \\begin{macrocode}\n\
2253             c_d\n\
2254             %    \\end{macrocode}\n",
2255        );
2256        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2257        // Body: `_` is a letter, one word.
2258        assert!(seen.contains(&(SyntaxKind::WORD, "c_d")));
2259        // Doc layer: `_` stays a subscript, so `a_b` splits.
2260        assert!(seen.iter().any(|(k, _)| *k == SyntaxKind::UNDERSCORE));
2261    }
2262
2263    #[test]
2264    fn implicit_expl_explicit_off_wins_then_next_body_re_enters() {
2265        // An explicit `\ExplSyntaxOff` inside an implicit body turns expl off for
2266        // the rest of that body; the next body still re-enters expl (the
2267        // save/restore restores the pre-body state, not the toggled-off one).
2268        let toks = lex_dtx(
2269            "%<@@=mod>\n\
2270             %    \\begin{macrocode}\n\
2271             \\seq_new:N\n\
2272             \\ExplSyntaxOff\n\
2273             a_b\n\
2274             %    \\end{macrocode}\n\
2275             %    \\begin{macrocode}\n\
2276             \\tl_set:Nn\n\
2277             %    \\end{macrocode}\n",
2278        );
2279        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2280        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
2281        // After the explicit off, `a_b` splits.
2282        assert!(seen.iter().any(|(k, _)| *k == SyntaxKind::UNDERSCORE));
2283        // The second body re-enters expl despite the earlier off.
2284        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\tl_set:Nn")));
2285    }
2286
2287    #[test]
2288    fn implicit_expl_gated_off_outside_dtx() {
2289        // The signal only fires under `.dtx` mode: a `.sty` with the same bytes
2290        // must not enable implicit expl (there are no macrocode bodies anyway).
2291        let toks = lex_with(
2292            "%<@@=mod>\n\\seq_new:N",
2293            &ParseCtx::default(),
2294            LexConfig {
2295                flavor: LatexFlavor::Package,
2296                dtx: false,
2297            },
2298        );
2299        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2300        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq")));
2301        assert!(!seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
2302    }
2303
2304    #[test]
2305    fn package_flavor_starts_in_letter_mode() {
2306        // A `.sty`/`.cls` is loaded under an implicit `\makeatletter`, so `@` is a
2307        // letter from the first byte — `\foo@bar` is one control word with no
2308        // explicit `\makeatletter`.
2309        let toks = lex_with(
2310            r"\foo@bar",
2311            &ParseCtx::default(),
2312            LatexFlavor::Package.into(),
2313        );
2314        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2315        assert_eq!(seen, vec![(SyntaxKind::CONTROL_WORD, "\\foo@bar")]);
2316    }
2317
2318    #[test]
2319    fn package_flavor_respects_trailing_makeatother() {
2320        // Letter-mode starts on, but an explicit `\makeatother` still turns it off.
2321        let toks = lex_with(
2322            r"\foo@bar\makeatother\foo@bar",
2323            &ParseCtx::default(),
2324            LatexFlavor::Package.into(),
2325        );
2326        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2327        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo@bar")));
2328        // After \makeatother the second occurrence splits into `\foo` + `@bar`.
2329        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
2330    }
2331
2332    #[test]
2333    fn document_flavor_keeps_at_non_letter() {
2334        // The default `.tex` flavor does not start in letter-mode.
2335        let toks = lex(r"\foo@bar");
2336        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2337        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
2338        assert!(!seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo@bar")));
2339    }
2340
2341    #[test]
2342    fn dtx_mode_lexes_line_leading_percent_as_a_margin() {
2343        // A line-leading `%` is a one-byte `DOC_MARGIN`; the rest of the doc line
2344        // lexes as ordinary LaTeX. A `%` not in column 0 stays a `COMMENT`.
2345        let dtx = LexConfig {
2346            flavor: LatexFlavor::Document,
2347            dtx: true,
2348        };
2349        let toks = lex_with("% \\foo\nbar % tail\n", &ParseCtx::default(), dtx);
2350        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2351        assert_eq!(seen[0], (SyntaxKind::DOC_MARGIN, "%"));
2352        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
2353        assert!(seen.contains(&(SyntaxKind::COMMENT, "% tail")));
2354        // Exactly one margin (column 0 of the first line only).
2355        assert_eq!(
2356            seen.iter()
2357                .filter(|(k, _)| *k == SyntaxKind::DOC_MARGIN)
2358                .count(),
2359            1
2360        );
2361    }
2362
2363    #[test]
2364    fn dtx_mode_is_off_by_default_for_margins_and_guards() {
2365        // Without the docstrip flag a `%` line stays a comment (plain `.tex`); a
2366        // `%<…>` guard likewise stays a single comment.
2367        let plain = lex("% \\foo\n");
2368        assert_eq!(plain[0].kind, SyntaxKind::COMMENT);
2369        let plain_guard = lex("%<*driver>\n");
2370        assert_eq!(plain_guard[0].kind, SyntaxKind::COMMENT);
2371        assert_eq!(plain_guard[0].text, "%<*driver>");
2372    }
2373
2374    #[test]
2375    fn dtx_mode_lexes_line_leading_guards() {
2376        let dtx = LexConfig {
2377            flavor: LatexFlavor::Document,
2378            dtx: true,
2379        };
2380        // `%<*tag>` / `%</tag>` block delimiters are single `GUARD` tokens.
2381        let block = lex_with("%<*driver>\n%</driver>\n", &ParseCtx::default(), dtx);
2382        assert_eq!(block[0].kind, SyntaxKind::GUARD);
2383        assert_eq!(block[0].text, "%<*driver>");
2384        assert!(
2385            block
2386                .iter()
2387                .any(|t| t.kind == SyntaxKind::GUARD && t.text == "%</driver>")
2388        );
2389        // An inline `%<tag>` is a `GUARD` prefix; the rest of the line lexes as code.
2390        let inline = lex_with("%<plain>\\RequirePackage{x}\n", &ParseCtx::default(), dtx);
2391        assert_eq!(inline[0].kind, SyntaxKind::GUARD);
2392        assert_eq!(inline[0].text, "%<plain>");
2393        assert!(
2394            inline
2395                .iter()
2396                .any(|t| t.kind == SyntaxKind::CONTROL_WORD && t.text == "\\RequirePackage")
2397        );
2398        // A boolean tag expression stays one token (through the closing `>`).
2399        let expr = lex_with("%<*package|driver>\n", &ParseCtx::default(), dtx);
2400        assert_eq!(expr[0].kind, SyntaxKind::GUARD);
2401        assert_eq!(expr[0].text, "%<*package|driver>");
2402        // A guard recognized only at column 0: a mid-line `%<…>` stays a comment.
2403        let midline = lex_with("a %<x>\n", &ParseCtx::default(), dtx);
2404        assert!(
2405            midline
2406                .iter()
2407                .any(|t| t.kind == SyntaxKind::COMMENT && t.text == "%<x>")
2408        );
2409        assert!(!midline.iter().any(|t| t.kind == SyntaxKind::GUARD));
2410        // A `%<` with no closing `>` before the line ends is not a guard.
2411        let malformed = lex_with("%<unterminated\n", &ParseCtx::default(), dtx);
2412        assert_eq!(malformed[0].kind, SyntaxKind::COMMENT);
2413        assert_eq!(malformed[0].text, "%<unterminated");
2414    }
2415
2416    #[test]
2417    fn verbatim_environment_body_is_one_raw_token() {
2418        let toks = lex("\\begin{verbatim}\n$not$ %literal\n\\end{verbatim}");
2419        assert_eq!(toks[0].text, "\\begin");
2420        assert_eq!(toks[2].text, "verbatim");
2421        assert!(
2422            toks.iter()
2423                .any(|t| t.kind == SyntaxKind::VERBATIM_BODY && t.text.contains("$not$ %literal"))
2424        );
2425        // Nothing inside the body was lexed as math or a comment.
2426        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::DOLLAR));
2427        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::COMMENT));
2428    }
2429
2430    #[test]
2431    fn argument_taking_verbatim_separates_args_from_body() {
2432        // `minted` declares `[opt]{req}`: both groups are tokenized normally, then
2433        // the rest is one raw body token.
2434        let toks = lex("\\begin{minted}[frame=single]{python}\nprint(\"$x$\")\n\\end{minted}");
2435        let kinds: Vec<_> = toks.iter().map(|t| t.kind).collect();
2436        // The optional and required argument delimiters survive as ordinary tokens…
2437        assert!(kinds.contains(&SyntaxKind::L_BRACKET));
2438        assert!(kinds.contains(&SyntaxKind::R_BRACKET));
2439        assert!(kinds.contains(&SyntaxKind::L_BRACE));
2440        // …and the body (with its `$`) is a single opaque token, not math.
2441        assert!(
2442            toks.iter()
2443                .any(|t| t.kind == SyntaxKind::VERBATIM_BODY && t.text.contains("print(\"$x$\")"))
2444        );
2445        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::DOLLAR));
2446    }
2447
2448    #[test]
2449    fn verbatim_body_starting_with_bracket_is_not_an_argument() {
2450        // `lstlisting`'s lone optional argument is absent (a newline separates the
2451        // `\begin` from the `[`), so `[1,2,3]` stays inside the raw body.
2452        let toks = lex("\\begin{lstlisting}\n[1,2,3]\n\\end{lstlisting}");
2453        assert!(
2454            !toks
2455                .iter()
2456                .take_while(|t| t.kind != SyntaxKind::VERBATIM_BODY)
2457                .any(|t| t.kind == SyntaxKind::L_BRACKET),
2458            "the bracket on the body's first line must not be lexed as an argument"
2459        );
2460        assert!(
2461            toks.iter()
2462                .any(|t| t.kind == SyntaxKind::VERBATIM_BODY && t.text.contains("[1,2,3]"))
2463        );
2464    }
2465
2466    #[test]
2467    fn make_short_verb_toggles_pipe_capture() {
2468        // Before the toggle a `|…|` is ordinary text; after `\MakeShortVerb{\|}`
2469        // it captures as one opaque `VERB`; `\DeleteShortVerb{\|}` turns it off.
2470        let toks = lex("|a| \\MakeShortVerb{\\|} |$| \\DeleteShortVerb{\\|} |b|");
2471        let verbs: Vec<_> = toks
2472            .iter()
2473            .filter(|t| t.kind == SyntaxKind::VERB)
2474            .map(|t| t.text.as_str())
2475            .collect();
2476        assert_eq!(verbs, ["|$|"]);
2477        assert_lossless("|a| \\MakeShortVerb{\\|} |$| \\DeleteShortVerb{\\|} |b|");
2478    }
2479
2480    #[test]
2481    fn documentclass_ltxguide_enables_the_pipe_short_verb() {
2482        // The curated doc classes (`ltxdoc`, `ltxguide`, `ltnews`, `l3doc`,
2483        // `amsldoc`) make `|` a short verb themselves, so loading one enables the
2484        // capture — options and trailing release dates included. `amsldoc` does it
2485        // with an active `|` (`\\gdef|{\\protect\\activevert{}}`, amsldoc.cls),
2486        // like `ltxguide`/`ltnews`; without it amsldoc.tex's `|\\begin{alignat}|`
2487        // prose read as real structure (issue #71).
2488        for preamble in [
2489            "\\documentclass{ltxguide}",
2490            "\\documentclass[a4paper]{ltxdoc}",
2491            "\\documentclass{ltxguide}[1994/11/20]",
2492            "\\documentclass{l3doc}",
2493            "\\documentclass[leqno,titlepage]{amsldoc}[1999/12/13]",
2494        ] {
2495            let input = format!("{preamble}\n|}}| done");
2496            let toks = lex(&input);
2497            assert!(
2498                toks.iter()
2499                    .any(|t| t.kind == SyntaxKind::VERB && t.text == "|}|"),
2500                "no VERB captured after {preamble}"
2501            );
2502        }
2503        // An unrelated class leaves `|` alone.
2504        let toks = lex("\\documentclass{article}\n|x| done");
2505        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::VERB));
2506    }
2507
2508    #[test]
2509    fn short_verb_never_captures_a_left_right_delimiter() {
2510        // `\left|x\right|` in math: the bars are delimiters, not a verb span.
2511        let toks = lex("\\MakeShortVerb{\\|} $\\left|x\\right|$");
2512        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::VERB));
2513        assert_lossless("\\MakeShortVerb{\\|} $\\left|x\\right|$");
2514    }
2515
2516    #[test]
2517    fn unclosed_short_verb_char_stands_alone() {
2518        // With no closing partner on the line, the enabled char is a lone
2519        // one-character word (never gluing into the following text).
2520        let toks = lex("\\MakeShortVerb{\\|} a|b\nc");
2521        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::VERB));
2522        assert!(
2523            toks.iter()
2524                .any(|t| t.kind == SyntaxKind::WORD && t.text == "|")
2525        );
2526        assert_lossless("\\MakeShortVerb{\\|} a|b\nc");
2527    }
2528
2529    /// A raw capture's *content* changes nothing about how the rest of the file
2530    /// lexes.
2531    ///
2532    /// This is a lexer property stated as one, but the reason it is pinned lives in
2533    /// `parser::reparse::protected`: that tier splices a new body into an existing
2534    /// tree without re-lexing anything after it, which is sound only because the
2535    /// lexer leaves a raw capture in the state it entered. Structurally it holds
2536    /// because `lex_verbatim_environment` / `lex_verbatim_command` /
2537    /// [`Lexer::try_short_verb`] push straight to `out`, so the captured bytes never
2538    /// reach [`Lexer::apply_toggles`], [`next_pending`], or
2539    /// [`Lexer::sync_brace_depth`] — but that is an argument about code, and this is
2540    /// the test that would notice it stop being true.
2541    ///
2542    /// The suffix is chosen to be sensitive to every state variable the lexer
2543    /// carries: `@` in a control word (`at_letter`), `_`/`:` (`expl_syntax`), a `|`
2544    /// (`short_verbs`), a `` ` `` after a `\char` (`brace_depth`), and a `\left`
2545    /// delimiter (`pending`).
2546    #[test]
2547    fn raw_capture_content_does_not_change_later_lexing() {
2548        /// Bodies that stay captured. Each would toggle a lexer mode or open a
2549        /// group if it were read as code rather than swallowed as data.
2550        const ENV_BODIES: &[&str] = &[
2551            "",
2552            "plain text",
2553            "\\makeatletter",
2554            "\\ExplSyntaxOn",
2555            "\\MakeShortVerb{\\|}",
2556            "{{{",
2557            "}}}",
2558            "% not a comment",
2559            "$ & # ^ _ ~",
2560            "\\end{verbatimx}",
2561            "\\begin{verbatim}",
2562            "\\char`{",
2563            "\\left(",
2564        ];
2565        /// The same, restricted to what every inline form can hold: no newline, no
2566        /// `+` (the delimiter), and braces balanced (`\url`'s scan needs them).
2567        const INLINE_BODIES: &[&str] = &[
2568            "",
2569            "x",
2570            "\\makeatletter",
2571            "\\ExplSyntaxOn",
2572            "{}",
2573            "$ & # ^ _ ~",
2574            "% not a comment",
2575            "\\char`",
2576        ];
2577        const SUFFIX: &str = "after \\my@cmd \\l_tmpa_tl |bar| \\char`{ \\left( x\n";
2578
2579        for (prefix, open, close, bodies) in [
2580            (
2581                "before x\n",
2582                "\\begin{verbatim}\n",
2583                "\n\\end{verbatim}\n",
2584                ENV_BODIES,
2585            ),
2586            (
2587                "before x\n",
2588                "\\begin{lstlisting}[a=b]\n",
2589                "\n\\end{lstlisting}\n",
2590                ENV_BODIES,
2591            ),
2592            ("before x ", "\\verb+", "+ ", INLINE_BODIES),
2593            ("before x ", "\\url{", "} ", INLINE_BODIES),
2594            ("before x ", "\\href{", "}{visible} ", INLINE_BODIES),
2595            ("before x ", "\\lstinline+", "+ ", INLINE_BODIES),
2596        ] {
2597            let mut expected: Option<Vec<(SyntaxKind, String)>> = None;
2598            for body in bodies {
2599                let region = format!("{open}{body}{close}");
2600                let doc = format!("{prefix}{region}{SUFFIX}");
2601                assert_lossless(&doc);
2602
2603                // The premise: the region really did capture. A body that *breaks*
2604                // its capture is a different case — see the test below.
2605                let toks = lex(&doc);
2606                assert!(
2607                    toks.iter()
2608                        .any(|t| matches!(t.kind, SyntaxKind::VERB | SyntaxKind::VERBATIM_BODY))
2609                        || body.is_empty(),
2610                    "no raw capture formed, so this case proves nothing\n  \
2611                     region: {region:?}",
2612                );
2613
2614                let from = prefix.len() + region.len();
2615                let mut off = 0usize;
2616                let got: Vec<(SyntaxKind, String)> = toks
2617                    .into_iter()
2618                    .filter(|t| {
2619                        let start = off;
2620                        off += t.text.len();
2621                        start >= from
2622                    })
2623                    .map(|t| (t.kind, t.text.to_string()))
2624                    .collect();
2625
2626                match &expected {
2627                    None => expected = Some(got),
2628                    Some(want) => assert_eq!(
2629                        &got, want,
2630                        "a raw body changed how the text after it lexes\n  \
2631                         region: {region:?}",
2632                    ),
2633                }
2634            }
2635        }
2636    }
2637
2638    /// The other half, and the reason the reparse tier re-lexes a whole fragment
2639    /// rather than trusting the body alone: a body that *breaks* its capture does
2640    /// change how the rest of the file lexes.
2641    ///
2642    /// `\url{{}` leaves `braced_verb_content_len` unbalanced, so no `VERB` forms and
2643    /// the braces are ordinary structure — which ratchets `brace_depth` and flips
2644    /// the char-constant reading of a later `` \char`{ ``. Nothing about the body's
2645    /// own bytes says that; only re-lexing the construct does.
2646    #[test]
2647    fn a_body_that_breaks_its_capture_changes_later_lexing() {
2648        let captured = lex("\\url{x} \\char`{");
2649        assert!(captured.iter().any(|t| t.kind == SyntaxKind::VERB));
2650        assert!(
2651            captured
2652                .iter()
2653                .any(|t| t.kind == SyntaxKind::WORD && t.text == "`{")
2654        );
2655
2656        let broken = lex("\\url{{} \\char`{");
2657        assert!(!broken.iter().any(|t| t.kind == SyntaxKind::VERB));
2658        assert!(
2659            broken
2660                .iter()
2661                .any(|t| t.kind == SyntaxKind::WORD && t.text == "`")
2662        );
2663    }
2664}