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 braced-verbatim
118/// command (`\code`, `\url`, `\path`, …). 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 braced-verbatim 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).
907    ///
908    /// A *bare* `{`/`}` is the exception, and only at brace depth 0. Inside a group
909    /// the brace has already been claimed as structure by whichever balanced-text
910    /// scan opened it — a `\def` body or a macro argument, both of which count brace
911    /// *tokens* long before `\char` ever runs — so the `}` in `` \def\v{\char`} ``
912    /// (longtable.dtx) and the `` \ifnum`}=0\fi `` brace-balance idiom
913    /// (longtable/amsmath) closes its group and is not data. At depth 0 there is no
914    /// such scan and the constant reading stands (`a close-group character is
915    /// written \char`} in running text`). The *escaped* form `` `\} `` is
916    /// unaffected: a control symbol is never a group delimiter, so it stays data at
917    /// any depth (issue #71).
918    fn try_char_constant(&mut self) -> bool {
919        if self.pending != Some(Pending::CharConstant) {
920            return false;
921        }
922        let rest = self.rest();
923        let Some(after) = rest.strip_prefix('`') else {
924            return false;
925        };
926        let Some(c) = after.chars().next() else {
927            return false;
928        };
929        if matches!(c, '\n' | '\r') || (self.brace_depth > 0 && matches!(c, '{' | '}')) {
930            return false;
931        }
932        let len = if c == '\\' {
933            // `` `\X ``: backtick, backslash, and one escaped character; a bare
934            // `` `\ `` at line end has no character and falls through.
935            match after[1..]
936                .chars()
937                .next()
938                .filter(|e| !matches!(e, '\n' | '\r'))
939            {
940                Some(e) => 2 + e.len_utf8(),
941                None => return false,
942            }
943        } else {
944            1 + c.len_utf8()
945        };
946        self.push(SyntaxKind::WORD, &rest[..len]);
947        self.consume(len);
948        true
949    }
950
951    /// `.dtx` `^^A` comment: ltxdoc/l3doc set `` \catcode`\^^A=14 ``, and the doc
952    /// layer leans on it for editor-balance hacks in prose (`^^A{` paired with a
953    /// verb `|}|`, a commented-out `^^A\end{function}`), so on a doc-margin line the
954    /// literal `^^A` sequence is a comment to end of line — a bounded static fact
955    /// like the on-by-default `|` short verb (`AGENTS.md` decision #1). Scoped to
956    /// doc lines only: inside a `macrocode` body `^^A` is live code
957    /// (``\char_set_catcode:nn { `\^^A }`` must not swallow its line), and
958    /// unmargined driver lines keep ordinary lexing.
959    fn try_doc_comment(&mut self) -> bool {
960        let rest = self.rest();
961        if !(self.in_doc_line && rest.starts_with("^^A")) {
962            return false;
963        }
964        let len = run_len(rest, |c| c != '\n' && c != '\r');
965        self.push(SyntaxKind::COMMENT, &rest[..len]);
966        self.consume(len);
967        true
968    }
969
970    /// Lex the one ordinary token at the cursor: classify it, apply the truncations
971    /// an armed mode or an enabled short-verb character imposes, run whatever
972    /// catcode toggle its text carries, and advance. `word_len` is the pre-scanned
973    /// control-word length at the cursor ([`control_word_len`]).
974    fn lex_token(&mut self, word_len: Option<usize>) {
975        let rest = self.rest();
976        let (kind, mut len) = next_token(rest, word_len, self.expl_syntax);
977        // A `\left`/`\right` delimiter that lexes as a word run: keep only its
978        // first character so it does not glue into the following text.
979        if self.pending == Some(Pending::Delim) && kind == SyntaxKind::WORD {
980            len = rest.chars().next().expect("rest is non-empty").len_utf8();
981        }
982        // An enabled short-verb char never joins a word run: split it off so a
983        // mid-word `x|y|` still opens a capture on the next iteration, and an
984        // unclosed `|` stands alone rather than gluing into the following text.
985        if kind == SyntaxKind::WORD
986            && !self.short_verbs.is_empty()
987            && let Some((i, c)) = rest[..len]
988                .char_indices()
989                .find(|(_, c)| self.short_verbs.contains(c))
990        {
991            len = if i == 0 { c.len_utf8() } else { i };
992        }
993        debug_assert!(len > 0, "lexer made no progress at byte {}", self.pos);
994        let text = &rest[..len];
995        if kind == SyntaxKind::CONTROL_WORD {
996            self.apply_toggles(text, &rest[len..]);
997        }
998        self.pending = next_pending(self.pending, kind, text);
999        self.push(kind, text);
1000        // A new physical line begins right after a `NEWLINE` — or after any token
1001        // that swallows its trailing line break, like the `\<newline>` control
1002        // symbol (`… \LaTeX\` at end of line): the next byte is column 0 either
1003        // way, so a `.dtx` margin there must still be recognized. Any other token
1004        // (whitespace included) leaves the cursor mid-line.
1005        self.at_line_start =
1006            kind == SyntaxKind::NEWLINE || text.ends_with('\n') || text.ends_with('\r');
1007        if self.at_line_start {
1008            self.in_doc_line = false;
1009        }
1010        self.pos += len;
1011    }
1012
1013    /// Apply the catcode / short-verb toggle a control word carries, if any.
1014    /// `after` is the text following it, from which the toggles that take a
1015    /// character or class argument read it.
1016    fn apply_toggles(&mut self, text: &str, after: &str) {
1017        match text {
1018            "\\makeatletter" => self.at_letter = true,
1019            "\\makeatother" => self.at_letter = false,
1020            // doc's short-verb toggles: `\MakeShortVerb{\|}` (or the `*` and
1021            // unbraced forms) enables the char, `\DeleteShortVerb{\|}` disables it.
1022            // Read as static facts left-to-right; a definition site
1023            // (`\def\MakeShortVerb{…`) never matches the `\c` argument shape, so it
1024            // does not toggle.
1025            "\\MakeShortVerb" => {
1026                if let Some(c) = short_verb_char(after)
1027                    && !self.short_verbs.contains(&c)
1028                {
1029                    self.short_verbs.push(c);
1030                }
1031            }
1032            "\\DeleteShortVerb" => {
1033                if let Some(c) = short_verb_char(after) {
1034                    self.short_verbs.retain(|&x| x != c);
1035                }
1036            }
1037            // The curated doc classes make `|` a short verb themselves
1038            // ([`BAR_SHORT_VERB_CLASSES`]), so loading one enables `|`.
1039            "\\documentclass" | "\\LoadClass" => {
1040                if doc_class_enables_bar(after) && !self.short_verbs.contains(&'|') {
1041                    self.short_verbs.push('|');
1042                }
1043            }
1044            // `\ExplSyntaxOn`/`Off`, and the `\ProvidesExpl*` declarations which
1045            // open expl3 syntax for the rest of the file (they appear at the top of
1046            // an expl3 package/class) so left-to-right they act as an On.
1047            _ => {
1048                if let Some(toggle) = expl_toggle(text) {
1049                    self.expl_syntax = matches!(toggle, ExplToggle::On);
1050                }
1051            }
1052        }
1053    }
1054}
1055
1056/// Whether a control word makes the lexer read the *raw text that follows it*,
1057/// beyond the ordinary token scan — so a later token's own text can decide how the
1058/// rest of the file lexes.
1059///
1060/// Two families, and between them this is the whole set. [`apply_toggles`] reads a
1061/// following argument for the short-verb and document-class toggles (the
1062/// `\makeatletter` and expl3 toggles read only the control word itself, so they are
1063/// not here). And [`next_pending`] arms the one-shot lookahead, which changes how
1064/// the *next* token lexes; asking it rather than restating its four sets is what
1065/// keeps this from drifting when a fifth is added.
1066///
1067/// Exists for [`crate::parser::reparse`]'s token tier, which may not splice a leaf
1068/// whose text one of these reads: the tier's soundness rests on the token *kind*
1069/// vector being unchanged, and these are the lexer's way of making one token's text
1070/// change another token's kind.
1071pub(crate) fn reads_following_text(text: &str) -> bool {
1072    matches!(
1073        text,
1074        "\\MakeShortVerb" | "\\DeleteShortVerb" | "\\documentclass" | "\\LoadClass"
1075    ) || next_pending(None, SyntaxKind::CONTROL_WORD, text).is_some()
1076}
1077
1078/// The one-shot mode in force after lexing a token of `kind`/`text`: newly armed
1079/// by a command that takes one, carried across the trivia the awaited token may
1080/// sit behind, and otherwise spent.
1081///
1082/// Each variant carries across exactly the trivia TeX skips before *its* token.
1083/// Spaces always; a line break additionally for [`Pending::Delim`] (TeX scans for
1084/// the delimiter across lines) and for [`Pending::Def`], whose braced form
1085/// `\newcommand{\foo}` also interposes the `{`. A char constant and a
1086/// literal-token grab conventionally stay on their line.
1087fn next_pending(pending: Option<Pending>, kind: SyntaxKind, text: &str) -> Option<Pending> {
1088    if kind == SyntaxKind::CONTROL_WORD {
1089        // The four arming sets are disjoint, so the order of these tests is
1090        // immaterial; any other control word — the defined name itself included —
1091        // spends whatever was armed.
1092        return if text == "\\left" || text == "\\right" {
1093            Some(Pending::Delim)
1094        } else if is_definition_keyword(text) {
1095            Some(Pending::Def)
1096        } else if is_char_constant_command(text) {
1097            Some(Pending::CharConstant)
1098        } else if is_literal_token_command(text) {
1099            Some(Pending::LiteralToken)
1100        } else {
1101            None
1102        };
1103    }
1104    match pending? {
1105        p @ (Pending::Delim | Pending::Def)
1106            if matches!(kind, SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE) =>
1107        {
1108            Some(p)
1109        }
1110        Pending::Def if kind == SyntaxKind::L_BRACE => Some(Pending::Def),
1111        p @ (Pending::CharConstant | Pending::LiteralToken) if kind == SyntaxKind::WHITESPACE => {
1112            Some(p)
1113        }
1114        _ => None,
1115    }
1116}
1117
1118/// Byte length of the control word at the start of `rest` — the backslash plus its
1119/// maximal letter run — or `None` when `rest` does not start one (no backslash, or
1120/// no letter behind it). Scanned once per cursor position and threaded to every
1121/// consumer, since the letter run is the same bytes under the same catcode regime.
1122fn control_word_len(rest: &str, at_letter: bool, expl_syntax: bool) -> Option<usize> {
1123    let after = rest.strip_prefix('\\')?;
1124    let letters = run_len(after, |c| is_letter(c, at_letter, expl_syntax));
1125    (letters > 0).then_some(1 + letters)
1126}
1127
1128/// Classify the token at the start of `rest` and return its `(kind, byte_len)`.
1129/// `word_len` is the pre-scanned [`control_word_len`] at `rest`.
1130fn next_token(rest: &str, word_len: Option<usize>, expl_syntax: bool) -> (SyntaxKind, usize) {
1131    let c = rest.chars().next().expect("rest is non-empty");
1132    match c {
1133        '\\' => lex_control(rest, word_len),
1134        '%' => (
1135            SyntaxKind::COMMENT,
1136            run_len(rest, |c| c != '\n' && c != '\r'),
1137        ),
1138        '{' => (SyntaxKind::L_BRACE, 1),
1139        '}' => (SyntaxKind::R_BRACE, 1),
1140        '[' => (SyntaxKind::L_BRACKET, 1),
1141        ']' => (SyntaxKind::R_BRACKET, 1),
1142        '$' => (SyntaxKind::DOLLAR, 1),
1143        '&' => (SyntaxKind::AMPERSAND, 1),
1144        '#' => (SyntaxKind::HASH, 1),
1145        '^' => (SyntaxKind::CARET, 1),
1146        // Under `\ExplSyntaxOn`, `_` is a catcode-11 letter, not a subscript: a
1147        // bare `_` joins the surrounding word run (handled by the default arm).
1148        '_' if !expl_syntax => (SyntaxKind::UNDERSCORE, 1),
1149        '~' => (SyntaxKind::TILDE, 1),
1150        '\n' => (SyntaxKind::NEWLINE, 1),
1151        '\r' => {
1152            let len = if rest.as_bytes().get(1) == Some(&b'\n') {
1153                2
1154            } else {
1155                1
1156            };
1157            (SyntaxKind::NEWLINE, len)
1158        }
1159        ' ' | '\t' => (
1160            SyntaxKind::WHITESPACE,
1161            run_len(rest, |c| c == ' ' || c == '\t'),
1162        ),
1163        _ => (
1164            SyntaxKind::WORD,
1165            run_len(rest, |c| is_word_char(c) || (expl_syntax && c == '_')),
1166        ),
1167    }
1168}
1169
1170/// Lex a control sequence: `rest` is known to start with `\`, and `word_len` is
1171/// its pre-scanned [`control_word_len`] — `Some` for a control word (backslash
1172/// plus one or more letters, `@` too under `\makeatletter`, `_`/`:` too under
1173/// `\ExplSyntaxOn`), `None` for a control symbol.
1174fn lex_control(rest: &str, word_len: Option<usize>) -> (SyntaxKind, usize) {
1175    match word_len {
1176        Some(word_len) => {
1177            // `\verb` / `\verb*`: swallow the delimited argument as one token.
1178            if &rest[..word_len] == "\\verb"
1179                && let Some(arg_len) = verb_len(&rest[word_len..])
1180            {
1181                return (SyntaxKind::VERB, word_len + arg_len);
1182            }
1183            (SyntaxKind::CONTROL_WORD, word_len)
1184        }
1185        // Control symbol: backslash + exactly one other character — or a lone
1186        // trailing backslash at end of input.
1187        None => match rest[1..].chars().next() {
1188            Some(d) => (SyntaxKind::CONTROL_SYMBOL, 1 + d.len_utf8()),
1189            None => (SyntaxKind::CONTROL_SYMBOL, 1),
1190        },
1191    }
1192}
1193
1194/// Length in bytes of a `\verb` argument: an optional `*`, then a delimited run.
1195/// Returns `None` if malformed (no delimiter, or it spans a line break).
1196fn verb_len(after: &str) -> Option<usize> {
1197    match after.strip_prefix('*') {
1198        Some(rest) => Some(1 + delimited_len(rest)?),
1199        None => delimited_len(after),
1200    }
1201}
1202
1203/// Length in bytes of a `\verb`-style delimited run: a delimiter character, then
1204/// everything up to and including its next occurrence. Returns `None` if the
1205/// delimiter is whitespace or the run spans a line break.
1206fn delimited_len(after: &str) -> Option<usize> {
1207    let mut chars = after.chars();
1208    let delim = chars.next()?;
1209    if delim.is_whitespace() {
1210        return None;
1211    }
1212    let mut consumed = delim.len_utf8();
1213    for c in chars {
1214        if c == '\n' || c == '\r' {
1215            return None;
1216        }
1217        consumed += c.len_utf8();
1218        if c == delim {
1219            return Some(consumed);
1220        }
1221    }
1222    None
1223}
1224
1225/// The character argument of `\MakeShortVerb`/`\DeleteShortVerb`, read from the
1226/// text following the control word: an optional `*`, inline whitespace, then
1227/// `{\c}` or a bare `\c`. Returns `None` when the shape does not match (e.g. at
1228/// the command's own definition site, `\def\MakeShortVerb{…`), so a non-call
1229/// never toggles. Same-line only — the argument conventionally abuts the call.
1230fn short_verb_char(after: &str) -> Option<char> {
1231    let s = skip_inline_ws(after.strip_prefix('*').unwrap_or(after));
1232    let (body, braced) = match s.strip_prefix('{') {
1233        Some(inner) => (skip_inline_ws(inner), true),
1234        None => (s, false),
1235    };
1236    let arg = body.strip_prefix('\\')?;
1237    let c = arg.chars().next()?;
1238    if c == '\n' || c == '\r' {
1239        return None;
1240    }
1241    if braced && !skip_inline_ws(&arg[c.len_utf8()..]).starts_with('}') {
1242        return None;
1243    }
1244    Some(c)
1245}
1246
1247/// The documentation classes that make `|` a short verb themselves, so loading one
1248/// enables the short-verb capture with no `\MakeShortVerb` in the file. `ltxdoc`
1249/// and `l3doc` call `\MakeShortVerb` on `\|`; `ltxguide`, `ltnews`, and `amsldoc`
1250/// define the equivalent active `|` (`\gdef|{\protect\activevert{}}`, amsldoc.cls).
1251/// Curated and closed — a class outside it leaves `|` alone (issue #71).
1252const BAR_SHORT_VERB_CLASSES: [&str; 5] = ["ltxdoc", "ltxguide", "ltnews", "l3doc", "amsldoc"];
1253
1254/// Whether the `{name}` argument following `\documentclass`/`\LoadClass` names one
1255/// of [`BAR_SHORT_VERB_CLASSES`]. A leading `[options]` group is skipped; a
1256/// trailing `[date]` is ignored.
1257fn doc_class_enables_bar(after: &str) -> bool {
1258    let mut s = skip_inline_ws(after);
1259    if let Some(rest) = s.strip_prefix('[') {
1260        match rest.find(']') {
1261            Some(i) => s = rest[i + 1..].trim_start_matches([' ', '\t', '\n', '\r']),
1262            None => return false,
1263        }
1264    }
1265    let Some(rest) = s.strip_prefix('{') else {
1266        return false;
1267    };
1268    let Some(close) = rest.find('}') else {
1269        return false;
1270    };
1271    BAR_SHORT_VERB_CLASSES.contains(&rest[..close].trim())
1272}
1273
1274/// If `rest` starts with `\begin{name}` for a verbatim-like `name`, emit the
1275/// `\begin{name}` tokens, then any environment arguments as ordinary tokens, and
1276/// finally a single raw body token, returning the bytes consumed (through the body,
1277/// up to the closing `\end{name}`).
1278///
1279/// Arguments are lexed *before* the body because the raw body begins only after
1280/// them: in `\begin{minted}{python}`, `{python}` is a structured argument, not body
1281/// text. The built-in signature ([`builtin`]) bounds how many leading groups count
1282/// as arguments, so a body that legitimately starts with `[` (an option-free
1283/// `lstlisting` whose first code line is `[1,2,3]`) is not mistaken for one.
1284fn lex_verbatim_environment(rest: &str, ctx: &ParseCtx, out: &mut Vec<Token>) -> Option<usize> {
1285    let (name, prefix_len) = begin_name(rest)?;
1286    // A user-defined catcode-verbatim environment (from `ctx`) wins over the built-in
1287    // DB; either way we read only the static leading-argument shape, never macro
1288    // meaning. The verbatim args are all leading — the raw body follows them.
1289    let args: &[ArgSpec] = match ctx.verbatim_environment_args(name) {
1290        Some(args) => args,
1291        None => {
1292            &builtin()
1293                .environment(name)
1294                .filter(|e| e.verbatim_body)?
1295                .args
1296        }
1297    };
1298
1299    push_env_delimiter(out, "\\begin", name);
1300
1301    // Locate the argument span, then tokenize it normally. It holds no nested
1302    // verbatim-begin, so the ordinary token loop is safe and lets the parser build
1303    // the usual OPTIONAL/GROUP argument nodes.
1304    let args_region = &rest[prefix_len..];
1305    let args_len = scan_verbatim_args(args_region, args);
1306    lex_into(&args_region[..args_len], out);
1307
1308    let body_region = &args_region[args_len..];
1309    let body_len = verbatim_body_len(body_region, name);
1310    if body_len > 0 {
1311        out.push(Token {
1312            kind: SyntaxKind::VERBATIM_BODY,
1313            text: SmolStr::new(&body_region[..body_len]),
1314        });
1315    }
1316    Some(prefix_len + args_len + body_len)
1317}
1318
1319/// Byte offset within `body` of the `\end{name}` that terminates it, or `body`'s
1320/// full length when the environment is never closed (the raw body then runs to end
1321/// of input, which keeps the lex lossless either way).
1322///
1323/// Matched by scanning for the fixed `\end{` lead and comparing the name in place,
1324/// rather than searching for a per-environment `\end{name}` string — the latter
1325/// allocates once per verbatim environment in the file for a comparison the borrow
1326/// already supports.
1327fn verbatim_body_len(body: &str, name: &str) -> usize {
1328    const LEAD: &str = "\\end{";
1329    let mut from = 0;
1330    while let Some(rel) = body[from..].find(LEAD) {
1331        let at = from + rel;
1332        let after = &body[at + LEAD.len()..];
1333        if let Some(tail) = after.strip_prefix(name)
1334            && tail.starts_with('}')
1335        {
1336            return at;
1337        }
1338        from = at + LEAD.len();
1339    }
1340    body.len()
1341}
1342
1343/// If `rest` starts with `\begin{name}` for an environment whose name argument is
1344/// xparse `v`-type (`verbatim_arg` in the curated DB: l3doc's `macro`/`function`/
1345/// `variable`, declared `{ O{} +v }`), emit the `\begin{name}` tokens, a leading
1346/// `[…]` optional as ordinary tokens, and the name argument as one opaque `VERB`
1347/// token, returning the bytes consumed. Both argument forms capture:
1348/// - The *delimited* form (`\begin{macro}+\@@_compile_{:+`) captures the whole
1349///   delimited span as the `VERB`. Upstream chooses this form precisely when the
1350///   name holds unbalanced braces (`\@@_compile_}:`), which would otherwise
1351///   corrupt group pairing for the rest of the file. The delimiter must directly
1352///   abut and be punctuation that cannot open another argument shape (never `\`,
1353///   a brace or bracket, `%`, `*`, or `$`), so an ordinary `\begin{macro}`
1354///   followed by prose or code never captures.
1355/// - The *braced* form (`\begin{macro}{\]}`) keeps its `{`/`}` as ordinary brace
1356///   tokens (the parser still builds the usual name `GROUP`) with the balanced
1357///   content between them as the `VERB`: the content is raw data, so a `\]`,
1358///   `\(`, or `$` in a name never opens math or draws an orphan-closer
1359///   diagnostic (issue #60). Balance tracking skips escaped braces (`\{`, `\}`
1360///   are part of a name, not group delimiters).
1361///
1362/// Same-line only, like `\verb`, in both forms. The parser attaches the abutting
1363/// `VERB` or name group into the `BEGIN` node like any verbatim command argument
1364/// (`attach_arguments`).
1365fn lex_verbatim_arg_environment(rest: &str, out: &mut Vec<Token>) -> Option<usize> {
1366    let (name, prefix_len) = begin_name(rest)?;
1367    builtin().environment(name).filter(|e| e.verbatim_arg)?;
1368
1369    // A leading `[…]` optional (the `O{}` slot, `\begin{macro}[EXP]+…+`) is
1370    // structured, not verbatim; it lexes normally below. Same-line, unnested.
1371    let region = &rest[prefix_len..];
1372    let mut args_len = 0;
1373    if let Some(after) = region.strip_prefix('[') {
1374        let i = after.find([']', '\n', '\r'])?;
1375        if after.as_bytes()[i] != b']' {
1376            return None;
1377        }
1378        args_len = 1 + i + 1;
1379    }
1380    let arg_region = &region[args_len..];
1381    let delim = arg_region.chars().next()?;
1382    let braced_content_len = if delim == '{' {
1383        Some(braced_verb_content_len(&arg_region[1..])?)
1384    } else {
1385        if !delim.is_ascii_punctuation()
1386            || matches!(delim, '\\' | '}' | '[' | ']' | '%' | '*' | '$')
1387        {
1388            return None;
1389        }
1390        None
1391    };
1392
1393    push_env_delimiter(out, "\\begin", name);
1394    lex_into(&region[..args_len], out);
1395    let verb_len = match braced_content_len {
1396        // Braced form: `{` VERB(content) `}` — the braces stay real tokens so
1397        // the parser builds the ordinary name `GROUP`.
1398        Some(content_len) => {
1399            out.push(Token {
1400                kind: SyntaxKind::L_BRACE,
1401                text: SmolStr::new("{"),
1402            });
1403            out.push(Token {
1404                kind: SyntaxKind::VERB,
1405                text: SmolStr::new(&arg_region[1..1 + content_len]),
1406            });
1407            out.push(Token {
1408                kind: SyntaxKind::R_BRACE,
1409                text: SmolStr::new("}"),
1410            });
1411            1 + content_len + 1
1412        }
1413        None => {
1414            let verb_len = delimited_len(arg_region)?;
1415            out.push(Token {
1416                kind: SyntaxKind::VERB,
1417                text: SmolStr::new(&arg_region[..verb_len]),
1418            });
1419            verb_len
1420        }
1421    };
1422    Some(prefix_len + args_len + verb_len)
1423}
1424
1425/// Length of the brace-balanced content of a braced `v`-type name argument,
1426/// starting just past the opening `{`. Same-line only; escaped braces (`\{`,
1427/// `\}`) are name characters, not delimiters. `None` when the closing `}` is
1428/// not on the line (falls back to normal lexing) or the content is empty
1429/// (nothing to capture; a bare `{}` lexes normally).
1430fn braced_verb_content_len(content: &str) -> Option<usize> {
1431    let mut depth = 1usize;
1432    let mut chars = content.char_indices();
1433    while let Some((i, c)) = chars.next() {
1434        match c {
1435            '\\' => {
1436                chars.next()?;
1437            }
1438            '{' => depth += 1,
1439            '}' => {
1440                depth -= 1;
1441                if depth == 0 {
1442                    return (i > 0).then_some(i);
1443                }
1444            }
1445            '\n' | '\r' => return None,
1446            _ => {}
1447        }
1448    }
1449    None
1450}
1451
1452/// A `.dtx` `macrocode` frame line, at a line start: `%␣*\begin{macrocode}` (when
1453/// `want_begin`) or `%␣*\end{macrocode}` (otherwise), with the `*` variant
1454/// accepted. On a match, emit the frame tokens — the `%` margin, the indent
1455/// whitespace, the `\begin`/`\end` control word, and the `{macrocode}` name group —
1456/// and return the bytes consumed (through the closing `}`; the trailing newline
1457/// lexes normally). Returns `None` when `rest` is not the requested frame.
1458///
1459/// Unlike a verbatim environment, the body is *not* captured here: it lexes as
1460/// ordinary code in the main loop (under the package regime). The frame line must
1461/// hold nothing but trailing whitespace after the name group, so a stray
1462/// `\begin{macrocode}{x}` is not mistaken for a frame. The *end* frame also
1463/// tolerates a trailing `%` comment (`%    \end{macrocode}%`, a guard against a
1464/// stray trailing space): doc.sty's terminator is a delimited match on the
1465/// `%    \end{macrocode}` string, so anything after it on the line is doc-layer
1466/// material. A begin frame stays strict — same-line text there is captured into
1467/// the body by `\xmacro@code`, not doc prose.
1468///
1469/// A *begin* frame additionally tolerates indentation before the `%`. In the
1470/// documentation layer `\DocInput` runs under `\MakePercentIgnore`
1471/// (`` \catcode`\%=9 ``, doc.dtx), so a `%` there is an *ignored* character at any
1472/// column and `␣*%␣*\begin{macrocode}` opens a chunk exactly like the column-0
1473/// spelling (multicol.dtx, latex-lab-block.dtx — issue #71). The indent rides as a
1474/// `WHITESPACE` token before the margin, so the line stays lossless and the
1475/// formatter re-pins the frame at column 0. The *end* frame stays column-0 strict:
1476/// inside the body `%` is a comment again, and doc.sty terminates on a delimited
1477/// match against the literal `%    \end{macrocode}` line.
1478fn lex_macrocode_frame(rest: &str, want_begin: bool, out: &mut Vec<Token>) -> Option<usize> {
1479    let indent = if want_begin { inline_ws_len(rest) } else { 0 };
1480    let after_pct = rest[indent..].strip_prefix('%')?;
1481    let ws_len = inline_ws_len(after_pct);
1482    let body = &after_pct[ws_len..];
1483    let (control, open) = if want_begin {
1484        ("\\begin", "\\begin{")
1485    } else {
1486        ("\\end", "\\end{")
1487    };
1488    let after_open = body.strip_prefix(open)?;
1489    let close = after_open.find('}')?;
1490    let name = &after_open[..close];
1491    if name != "macrocode" && name != "macrocode*" {
1492        return None;
1493    }
1494    // The frame line carries nothing but trailing whitespace after `}` — plus,
1495    // on an end frame, an optional `%` comment tail (lexed as an ordinary
1496    // `COMMENT` by the main loop).
1497    let after_close = &after_open[close + 1..];
1498    let tail = skip_inline_ws(after_close);
1499    let comment_tail = !want_begin && tail.starts_with('%');
1500    if !(tail.is_empty() || tail.starts_with('\n') || tail.starts_with('\r') || comment_tail) {
1501        return None;
1502    }
1503
1504    if indent > 0 {
1505        out.push(Token {
1506            kind: SyntaxKind::WHITESPACE,
1507            text: SmolStr::new(&rest[..indent]),
1508        });
1509    }
1510    out.push(Token {
1511        kind: SyntaxKind::DOC_MARGIN,
1512        text: SmolStr::new("%"),
1513    });
1514    if ws_len > 0 {
1515        out.push(Token {
1516            kind: SyntaxKind::WHITESPACE,
1517            text: SmolStr::new(&after_pct[..ws_len]),
1518        });
1519    }
1520    push_env_delimiter(out, control, name);
1521    Some(indent + 1 + ws_len + control.len() + 1 + name.len() + 1)
1522}
1523
1524/// If `rest` starts with a verbatim-argument command (`\url`, `\code`,
1525/// `\lstinline`, …), emit its control word, any leading non-verbatim arguments
1526/// (as ordinary tokens), and finally a single raw [`SyntaxKind::VERB`] token for
1527/// the verbatim argument; return the bytes consumed. Returns `None` when `rest`
1528/// is not such a command or no verbatim argument follows (so the caller lexes it
1529/// normally and losslessness is preserved either way).
1530///
1531/// The verbatim argument's form is decided by its first non-blank character,
1532/// matching how these commands actually parse: a brace introduces a balanced
1533/// `{…}` group (`\code{…}`, `\url{…}`); any other character is a `\verb`-style
1534/// delimiter run (`\lstinline|…|`), but only for built-ins whose signature
1535/// grants the delimiter form (`verbatim_delimited`). For braced-only commands —
1536/// `\code`, `\path`, and every scanner-discovered user command — a non-brace
1537/// follower means this occurrence is not a verbatim argument (the name may be an
1538/// unrelated user macro: `\code` as a math operator, TikZ's `\path (0,0)`), so
1539/// we return `None` and lex normally; a missed capture is benign where a wrong
1540/// delimiter capture swallows text across the line. `\verb`/`\verb*` are
1541/// deliberately excluded — they are delimiter-only and handled in
1542/// [`lex_control`]. Like the verbatim environment path, this reads only static
1543/// signature data (decision #1).
1544///
1545/// `word_len` is the pre-scanned [`control_word_len`] at `rest`, so the command's
1546/// letter run is not re-scanned here and again when the caller falls through to
1547/// ordinary lexing.
1548fn lex_verbatim_command(
1549    rest: &str,
1550    word_len: Option<usize>,
1551    ctx: &ParseCtx,
1552    on_dtx_doc_line: bool,
1553    out: &mut Vec<Token>,
1554) -> Option<usize> {
1555    let word_len = word_len?;
1556    let name = &rest[1..word_len];
1557    // `\verb` keeps its dedicated delimiter-only path.
1558    if name == "verb" {
1559        return None;
1560    }
1561    // A user-defined catcode-verbatim command (from `ctx`) wins over the built-in DB;
1562    // either way we read only the static leading-argument shape, never macro meaning.
1563    // Discovered commands are `\newcommand`-style braced definitions, so they never
1564    // get the delimiter form.
1565    let (leading, delimited): (&[ArgSpec], bool) = match ctx.leading_args(name) {
1566        Some(args) => (args, false),
1567        None => {
1568            // A visible non-verbatim redefinition in this file shadows the built-in, so
1569            // don't capture — lex the braced argument as an ordinary group (issue #53).
1570            if ctx.is_suppressed(name) {
1571                return None;
1572            }
1573            let sig = builtin().command(name).filter(|c| c.verbatim)?;
1574            (&sig.args, sig.verbatim_delimited)
1575        }
1576    };
1577
1578    // Leading arguments precede the verbatim one (e.g. `\mintinline{lang}{code}`).
1579    let after_word = &rest[word_len..];
1580    let args_len = scan_verbatim_args(after_word, leading);
1581
1582    // A braced-only argument is an ordinary TeX argument and may begin on the
1583    // next line. Delimiter-style verbatim remains same-line: its closing delimiter
1584    // cannot cross a line break.
1585    let region = &after_word[args_len..];
1586    let dtx_gap = (!delimited && on_dtx_doc_line)
1587        .then(|| dtx_doc_argument_gap_len(region))
1588        .flatten();
1589    let ws_len = if let Some(len) = dtx_gap {
1590        len
1591    } else if delimited {
1592        inline_ws_len(region)
1593    } else {
1594        tex_whitespace_len(region)
1595    };
1596    let arg_region = &region[ws_len..];
1597    let arg_len = match arg_region.bytes().next() {
1598        Some(b'{') => balanced_group_len(arg_region, b'}')?,
1599        // A `\verb`-style delimiter run: the first character delimits, and the
1600        // argument may not span a line break.
1601        Some(_) if delimited => delimited_len(arg_region)?,
1602        _ => return None,
1603    };
1604
1605    out.push(Token {
1606        kind: SyntaxKind::CONTROL_WORD,
1607        text: SmolStr::new(&rest[..word_len]),
1608    });
1609    lex_into(&after_word[..args_len], out);
1610    if ws_len > 0 {
1611        if dtx_gap.is_some() {
1612            lex_dtx_doc_argument_gap(&region[..ws_len], out);
1613        } else {
1614            out.push(Token {
1615                kind: SyntaxKind::WHITESPACE,
1616                text: SmolStr::new(&region[..ws_len]),
1617            });
1618        }
1619    }
1620    out.push(Token {
1621        kind: SyntaxKind::VERB,
1622        text: SmolStr::new(&arg_region[..arg_len]),
1623    });
1624    Some(word_len + args_len + ws_len + arg_len)
1625}
1626
1627/// Byte length of the argument span that precedes a verbatim body, given the
1628/// environment's declared `args`. For each argument in order, consume any inline
1629/// whitespace (spaces/tabs, never a line break — an argument never crosses a
1630/// newline, so a bracket on the next line is body text) followed by the balanced
1631/// group of the expected delimiter when present. A missing optional or required
1632/// argument is skipped; a malformed (unbalanced) group is left to the body, so the
1633/// scan never runs past the input and losslessness is preserved.
1634fn scan_verbatim_args(region: &str, args: &[ArgSpec]) -> usize {
1635    let bytes = region.as_bytes();
1636    let mut pos = 0;
1637    for arg in args {
1638        let probe = pos + inline_ws_len(&region[pos..]);
1639        let (open, close) = match arg.kind {
1640            ArgKind::Bracket => (b'[', b']'),
1641            ArgKind::Brace => (b'{', b'}'),
1642        };
1643        if bytes.get(probe) != Some(&open) {
1644            // Argument absent; the skipped whitespace belongs to the body.
1645            continue;
1646        }
1647        match balanced_group_len(&region[probe..], close) {
1648            Some(len) => pos = probe + len,
1649            None => break, // unbalanced: treat the remainder as body
1650        }
1651    }
1652    pos
1653}
1654
1655/// Length in bytes of the balanced group starting at `s[0]` (an `[` or `{`), up to
1656/// and including its matching closer. Brace and bracket nesting is tracked with a
1657/// delimiter stack, so a `]` inside `{…}` (or vice versa) is treated as literal; a
1658/// `\`-escaped delimiter is skipped. Returns `None` if the group never closes.
1659fn balanced_group_len(s: &str, close: u8) -> Option<usize> {
1660    let bytes = s.as_bytes();
1661    let mut stack = vec![close];
1662    let mut i = 1;
1663    while i < bytes.len() {
1664        match bytes[i] {
1665            b'\\' => {
1666                // Skip the escaped byte; a delimiter loses its meaning.
1667                i += 2;
1668                continue;
1669            }
1670            b'{' => stack.push(b'}'),
1671            b'[' => stack.push(b']'),
1672            c @ (b'}' | b']') if stack.last() == Some(&c) => {
1673                stack.pop();
1674                if stack.is_empty() {
1675                    return Some(i + 1);
1676                }
1677            }
1678            // A non-matching closer is literal text; ignore it.
1679            _ => {}
1680        }
1681        i += 1;
1682    }
1683    None
1684}
1685
1686/// Tokenize `region` with the ordinary, context-free token loop, appending to
1687/// `out`. Used for the argument span of a verbatim-like environment, which carries
1688/// no `\makeatletter` or nested verbatim-begin context.
1689fn lex_into(region: &str, out: &mut Vec<Token>) {
1690    let mut pos = 0;
1691    while pos < region.len() {
1692        let rest = &region[pos..];
1693        let (kind, len) = next_token(rest, control_word_len(rest, false, false), false);
1694        debug_assert!(len > 0, "lexer made no progress in verbatim args");
1695        out.push(Token {
1696            kind,
1697            text: SmolStr::new(&region[pos..pos + len]),
1698        });
1699        pos += len;
1700    }
1701}
1702
1703/// The environment name of a `\begin{name}` at the start of `rest`, together with
1704/// the byte length of the whole `\begin{name}` prefix. `None` when `rest` does not
1705/// open one, or when the name group never closes.
1706fn begin_name(rest: &str) -> Option<(&str, usize)> {
1707    let after = rest.strip_prefix("\\begin{")?;
1708    let close = after.find('}')?;
1709    Some((&after[..close], "\\begin{".len() + close + 1))
1710}
1711
1712/// Emit the four tokens of an environment delimiter — `\begin`/`\end`, `{`, the
1713/// name, `}` — so the ordinary environment grammar sees the shape it expects even
1714/// where the lexer claimed the surrounding line itself (a verbatim `\begin`, a
1715/// `.dtx` `macrocode` frame).
1716fn push_env_delimiter(out: &mut Vec<Token>, control: &str, name: &str) {
1717    out.push(Token {
1718        kind: SyntaxKind::CONTROL_WORD,
1719        text: SmolStr::new(control),
1720    });
1721    out.push(Token {
1722        kind: SyntaxKind::L_BRACE,
1723        text: SmolStr::new("{"),
1724    });
1725    out.push(Token {
1726        kind: SyntaxKind::WORD,
1727        text: SmolStr::new(name),
1728    });
1729    out.push(Token {
1730        kind: SyntaxKind::R_BRACE,
1731        text: SmolStr::new("}"),
1732    });
1733}
1734
1735/// Number of leading bytes of `s` that are inline whitespace — spaces and tabs,
1736/// never a line break. An argument never crosses a newline, and a `.dtx` frame
1737/// line's indent is likewise same-line, so every scan in this module that steps
1738/// over blanks means exactly this.
1739fn inline_ws_len(s: &str) -> usize {
1740    s.bytes().take_while(|&b| b == b' ' || b == b'\t').count()
1741}
1742
1743/// Number of leading ASCII whitespace bytes TeX may skip before a braced argument.
1744fn tex_whitespace_len(s: &str) -> usize {
1745    s.bytes()
1746        .take_while(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r'))
1747        .count()
1748}
1749
1750/// A verbatim command on a `.dtx` documentation line may take its braced argument
1751/// on the next margined line. Return the gap through that line's indentation.
1752fn dtx_doc_argument_gap_len(s: &str) -> Option<usize> {
1753    let inline = inline_ws_len(s);
1754    let rest = &s[inline..];
1755    let newline = if rest.starts_with("\r\n") {
1756        2
1757    } else if rest.starts_with(['\n', '\r']) {
1758        1
1759    } else {
1760        return None;
1761    };
1762    let after_newline = &rest[newline..];
1763    let after_margin = after_newline.strip_prefix('%')?;
1764    Some(inline + newline + 1 + inline_ws_len(after_margin))
1765}
1766
1767fn lex_dtx_doc_argument_gap(gap: &str, out: &mut Vec<Token>) {
1768    let inline = inline_ws_len(gap);
1769    if inline > 0 {
1770        out.push(Token {
1771            kind: SyntaxKind::WHITESPACE,
1772            text: SmolStr::new(&gap[..inline]),
1773        });
1774    }
1775    let rest = &gap[inline..];
1776    let newline = if rest.starts_with("\r\n") { 2 } else { 1 };
1777    out.push(Token {
1778        kind: SyntaxKind::NEWLINE,
1779        text: SmolStr::new(&rest[..newline]),
1780    });
1781    out.push(Token {
1782        kind: SyntaxKind::DOC_MARGIN,
1783        text: SmolStr::new("%"),
1784    });
1785    let trailing = &rest[newline + 1..];
1786    if !trailing.is_empty() {
1787        out.push(Token {
1788            kind: SyntaxKind::WHITESPACE,
1789            text: SmolStr::new(trailing),
1790        });
1791    }
1792}
1793
1794/// `s` past its leading [`inline_ws_len`].
1795fn skip_inline_ws(s: &str) -> &str {
1796    &s[inline_ws_len(s)..]
1797}
1798
1799/// Number of leading bytes of `s` whose chars all satisfy `pred`.
1800fn run_len(s: &str, pred: impl Fn(char) -> bool) -> usize {
1801    let mut len = 0;
1802    for c in s.chars() {
1803        if pred(c) {
1804            len += c.len_utf8();
1805        } else {
1806            break;
1807        }
1808    }
1809    len
1810}
1811
1812/// A control-word continuation character: a letter, `@` under `\makeatletter`,
1813/// or `_`/`:` under `\ExplSyntaxOn` (where they are catcode-11 letters).
1814fn is_letter(c: char, at_letter: bool, expl_syntax: bool) -> bool {
1815    c.is_ascii_alphabetic() || (at_letter && c == '@') || (expl_syntax && (c == '_' || c == ':'))
1816}
1817
1818/// Could `name` (without its leading `\`) lex as a single [control
1819/// word](control_word_len) in *some* catcode regime?
1820///
1821/// The most permissive regime is the bar on purpose: a name is checked here
1822/// against `\makeatletter` *and* `\ExplSyntaxOn` letters at once, because a
1823/// declaration does not say which file it will be read in. Shares
1824/// [`is_letter`] with the lexer rather than restating the letter set, for the
1825/// same reason the expl3 toggle names are one set: a name the lexer would split
1826/// into two tokens can never match a declaration, so accepting one would be a
1827/// silent no-op (see [`crate::declarations`]).
1828pub fn is_control_word_name(name: &str) -> bool {
1829    !name.is_empty() && name.chars().all(|c| is_letter(c, true, true))
1830}
1831
1832/// Ordinary text: anything that is not whitespace, a line break, or one of the
1833/// characters the lexer treats specially.
1834pub fn is_word_char(c: char) -> bool {
1835    !matches!(
1836        c,
1837        '\\' | '%'
1838            | '{'
1839            | '}'
1840            | '['
1841            | ']'
1842            | '$'
1843            | '&'
1844            | '#'
1845            | '^'
1846            | '_'
1847            | '~'
1848            | ' '
1849            | '\t'
1850            | '\n'
1851            | '\r'
1852    )
1853}
1854
1855#[cfg(test)]
1856mod tests {
1857    use super::*;
1858
1859    /// The lexer is total and lossless: concatenated token text == input.
1860    fn assert_lossless(input: &str) {
1861        let joined: String = lex(input).iter().map(|t| t.text.as_str()).collect();
1862        assert_eq!(joined, input);
1863    }
1864
1865    #[test]
1866    fn the_pending_arming_sets_are_disjoint() {
1867        // `Pending` is one slot, which is faithful only because no control word
1868        // arms two modes. `next_pending` tests the four in a fixed order, so an
1869        // overlap would silently make one set shadow the other rather than fail
1870        // to compile. Every name currently in any set is checked here; a name
1871        // added to a *second* set trips this.
1872        for name in [
1873            "\\left",
1874            "\\right",
1875            "\\newcommand",
1876            "\\renewcommand",
1877            "\\providecommand",
1878            "\\DeclareRobustCommand",
1879            "\\NewDocumentCommand",
1880            "\\RenewDocumentCommand",
1881            "\\ProvideDocumentCommand",
1882            "\\DeclareDocumentCommand",
1883            "\\def",
1884            "\\edef",
1885            "\\gdef",
1886            "\\xdef",
1887            "\\let",
1888            "\\char",
1889            "\\catcode",
1890            "\\lccode",
1891            "\\uccode",
1892            "\\sfcode",
1893            "\\mathcode",
1894            "\\delcode",
1895            "\\number",
1896            "\\the",
1897            "\\romannumeral",
1898            "\\numexpr",
1899            "\\dimexpr",
1900            "\\ifnum",
1901            "\\ifodd",
1902            "\\ifdim",
1903            "\\string",
1904            "\\noexpand",
1905            "\\meaning",
1906            "\\expandafter",
1907            "\\show",
1908        ] {
1909            let armed = [
1910                name == "\\left" || name == "\\right",
1911                is_definition_keyword(name),
1912                is_char_constant_command(name),
1913                is_literal_token_command(name),
1914            ];
1915            assert_eq!(
1916                armed.iter().filter(|&&x| x).count(),
1917                1,
1918                "{name} arms {armed:?} — the `Pending` slot needs disjoint sets"
1919            );
1920        }
1921    }
1922
1923    #[test]
1924    fn a_claimed_construct_spends_the_armed_char_constant_mode() {
1925        // Every construct a `try_*` probe claims whole clears the one-shot slot,
1926        // so an armed mode never survives an unrelated capture. Directly after
1927        // `\char` a backtick still opens the char constant…
1928        let direct = lex("\\char `\\%");
1929        assert!(
1930            direct
1931                .iter()
1932                .any(|t| t.kind == SyntaxKind::WORD && t.text == "`\\%")
1933        );
1934        // …but with a short-verb capture in between, the mode is spent and the
1935        // backtick is ordinary text. (Before the four flags collapsed into one
1936        // slot this branch cleared a hand-picked subset that left the
1937        // char-constant mode armed indefinitely.)
1938        let intervened = lex("\\MakeShortVerb{\\|} \\char |a| `\\%");
1939        assert!(
1940            intervened
1941                .iter()
1942                .any(|t| t.kind == SyntaxKind::VERB && t.text == "|a|")
1943        );
1944        assert!(
1945            !intervened
1946                .iter()
1947                .any(|t| t.kind == SyntaxKind::WORD && t.text == "`\\%")
1948        );
1949        assert_lossless("\\MakeShortVerb{\\|} \\char |a| `\\%");
1950    }
1951
1952    #[test]
1953    fn block_environment_classification() {
1954        let ctx = ParseCtx::default();
1955        assert!(ctx.is_block_environment("figure"));
1956        assert!(ctx.is_block_environment("itemize")); // derived via `list`
1957        assert!(!ctx.is_block_environment("myenv")); // unknown
1958    }
1959
1960    #[test]
1961    fn lossless_on_assorted_inputs() {
1962        for input in [
1963            "",
1964            "plain text",
1965            r"\section{Hi}[x]",
1966            "$a^2_b$",
1967            "a%c\n\nb",
1968            "café ∑ \\\\ \\{ \\,",
1969            "tab\tand  spaces",
1970            "trailing\\",
1971            r"\verb|$x$|",
1972            "\\begin{verbatim}\n$x$ %not a comment\n\\end{verbatim}",
1973            "\\begin{lstlisting}[language=C]\nint a[3];  % raw\n\\end{lstlisting}",
1974            "\\begin{minted}[frame=single]{python}\nprint(\"$x$\")\n\\end{minted}",
1975            "\\begin{lstlisting}\n[1,2,3]\n\\end{lstlisting}",
1976            r"\makeatletter\a@b\makeatother\a@b",
1977            r"\ExplSyntaxOn\seq_new:N \g_@@_x_tl a_b\ExplSyntaxOff\seq_new:N",
1978            r"$\left(x+y\right)^2 \left.\frac{a}{b}\right|_0$",
1979        ] {
1980            assert_lossless(input);
1981        }
1982    }
1983
1984    #[test]
1985    fn control_word_stops_at_non_letter() {
1986        let toks = lex(r"\alpha2");
1987        assert_eq!(toks[0].kind, SyntaxKind::CONTROL_WORD);
1988        assert_eq!(toks[0].text, "\\alpha");
1989        assert_eq!(toks[1].kind, SyntaxKind::WORD);
1990        assert_eq!(toks[1].text, "2");
1991    }
1992
1993    #[test]
1994    fn double_backslash_is_one_control_symbol() {
1995        let toks = lex(r"\\");
1996        assert_eq!(toks.len(), 1);
1997        assert_eq!(toks[0].kind, SyntaxKind::CONTROL_SYMBOL);
1998        assert_eq!(toks[0].text, r"\\");
1999    }
2000
2001    #[test]
2002    fn comment_stops_before_newline() {
2003        let toks = lex("% hi\nx");
2004        assert_eq!(toks[0].kind, SyntaxKind::COMMENT);
2005        assert_eq!(toks[0].text, "% hi");
2006        assert_eq!(toks[1].kind, SyntaxKind::NEWLINE);
2007    }
2008
2009    #[test]
2010    fn crlf_is_a_single_newline() {
2011        let toks = lex("a\r\nb");
2012        assert_eq!(toks[1].kind, SyntaxKind::NEWLINE);
2013        assert_eq!(toks[1].text, "\r\n");
2014    }
2015
2016    #[test]
2017    fn verb_inline_is_one_token() {
2018        let toks = lex(r"\verb|$x$|");
2019        assert_eq!(toks.len(), 1);
2020        assert_eq!(toks[0].kind, SyntaxKind::VERB);
2021        assert_eq!(toks[0].text, r"\verb|$x$|");
2022    }
2023
2024    #[test]
2025    fn verb_star_with_plus_delimiter() {
2026        let toks = lex(r"a\verb*+b+c");
2027        assert_eq!(toks[1].kind, SyntaxKind::VERB);
2028        assert_eq!(toks[1].text, r"\verb*+b+");
2029        assert_eq!(toks[2].text, "c");
2030    }
2031
2032    #[test]
2033    fn verb_without_closing_delimiter_is_a_plain_control_word() {
2034        let toks = lex(r"\verb|x");
2035        assert_eq!(toks[0].kind, SyntaxKind::CONTROL_WORD);
2036        assert_eq!(toks[0].text, r"\verb");
2037    }
2038
2039    #[test]
2040    fn left_right_isolate_word_delimiter() {
2041        // `(` would normally glue into `(x+y` as one word; after `\left` it is
2042        // its own one-character token, and `\right)`'s `)` likewise.
2043        let toks = lex(r"\left(x+y\right)");
2044        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2045        assert_eq!(
2046            seen,
2047            [
2048                (SyntaxKind::CONTROL_WORD, "\\left"),
2049                (SyntaxKind::WORD, "("),
2050                (SyntaxKind::WORD, "x+y"),
2051                (SyntaxKind::CONTROL_WORD, "\\right"),
2052                (SyntaxKind::WORD, ")"),
2053            ]
2054        );
2055    }
2056
2057    #[test]
2058    fn left_delimiter_carries_across_whitespace() {
2059        // TeX skips spaces before the delimiter; the mode persists so `(` is
2060        // still isolated.
2061        let toks = lex(r"\left ( a");
2062        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2063        assert_eq!(
2064            seen,
2065            [
2066                (SyntaxKind::CONTROL_WORD, "\\left"),
2067                (SyntaxKind::WHITESPACE, " "),
2068                (SyntaxKind::WORD, "("),
2069                (SyntaxKind::WHITESPACE, " "),
2070                (SyntaxKind::WORD, "a"),
2071            ]
2072        );
2073    }
2074
2075    #[test]
2076    fn left_non_word_delimiters_are_untouched() {
2077        // A control-symbol (`\{`), control-word (`\langle`), or bracket delimiter
2078        // already lexes as a single token, so the mode changes nothing.
2079        for input in [r"\left\{", r"\left\langle", r"\left["] {
2080            assert_lossless(input);
2081        }
2082        let toks = lex(r"\left\langle x \right\rangle");
2083        assert!(toks.iter().any(|t| t.text == "\\langle"));
2084        assert!(toks.iter().any(|t| t.text == "\\rangle"));
2085    }
2086
2087    #[test]
2088    fn leftarrow_is_not_left() {
2089        // The maximal letter run keeps `\leftarrow` one control word, so the
2090        // delimiter mode never triggers.
2091        let toks = lex(r"\leftarrow(x)");
2092        assert_eq!(toks[0].text, "\\leftarrow");
2093        // `(x)` glues normally — the mode did not fire.
2094        assert_eq!(toks[1].text, "(x)");
2095    }
2096
2097    #[test]
2098    fn makeatletter_makes_at_a_letter() {
2099        let toks = lex(r"\makeatletter\foo@bar\makeatother\foo@bar");
2100        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2101        // Under \makeatletter, `\foo@bar` is one control word…
2102        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo@bar")));
2103        // …after \makeatother it splits into `\foo` + `@bar`.
2104        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
2105    }
2106
2107    #[test]
2108    fn expl_syntax_makes_underscore_and_colon_letters() {
2109        let toks = lex(r"\ExplSyntaxOn\seq_new:N\ExplSyntaxOff\seq_new:N");
2110        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2111        // Under \ExplSyntaxOn, `\seq_new:N` is one control word…
2112        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
2113        // …after \ExplSyntaxOff it stops at the first `_`.
2114        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq")));
2115    }
2116
2117    #[test]
2118    fn expl_syntax_lexes_internal_double_underscore_name() {
2119        let toks = lex(r"\ExplSyntaxOn\__module_internal:nn");
2120        assert_eq!(toks[1].kind, SyntaxKind::CONTROL_WORD);
2121        assert_eq!(toks[1].text, "\\__module_internal:nn");
2122    }
2123
2124    #[test]
2125    fn provides_expl_package_turns_on_expl_syntax() {
2126        let toks = lex(r"\ProvidesExplPackage{p}{2026/01/01}{1.0}{d}\tl_set:Nn");
2127        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2128        // The `\ProvidesExplPackage` declaration opens expl3 syntax, so the later
2129        // `\tl_set:Nn` lexes as one control word.
2130        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\tl_set:Nn")));
2131    }
2132
2133    #[test]
2134    fn expl_syntax_composes_with_makeatletter() {
2135        // The `@@` module-prefix convention needs both `@` and `_`/`:` as letters.
2136        let toks = lex(r"\makeatletter\ExplSyntaxOn\g_@@_frame_title_tl");
2137        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2138        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\g_@@_frame_title_tl")));
2139    }
2140
2141    #[test]
2142    fn expl_syntax_makes_bare_underscore_a_word_not_subscript() {
2143        let toks = lex(r"\ExplSyntaxOn a_b");
2144        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2145        // Under expl3, `_` is a catcode-11 letter: `a_b` is one word, no UNDERSCORE.
2146        assert!(seen.contains(&(SyntaxKind::WORD, "a_b")));
2147        assert!(!seen.iter().any(|(k, _)| *k == SyntaxKind::UNDERSCORE));
2148    }
2149
2150    /// Lex `input` under the docstrip (`.dtx`) config, the regime in which
2151    /// implicit expl3 applies.
2152    fn lex_dtx(input: &str) -> Vec<Token> {
2153        lex_with(
2154            input,
2155            &ParseCtx::default(),
2156            LexConfig {
2157                flavor: LatexFlavor::Document,
2158                dtx: true,
2159            },
2160        )
2161    }
2162
2163    #[test]
2164    fn implicit_expl_module_guard_makes_macrocode_body_expl3() {
2165        // A toggle-less `.dtx` with only a `%<@@=mod>` module guard: its macrocode
2166        // body is expl3 code, so `\seq_new:N` lexes as one control word.
2167        let toks = lex_dtx(
2168            "%<@@=mod>\n\
2169             %    \\begin{macrocode}\n\
2170             \\seq_new:N\n\
2171             %    \\end{macrocode}\n",
2172        );
2173        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2174        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
2175    }
2176
2177    #[test]
2178    fn no_expl_signal_leaves_macrocode_body_plain() {
2179        // The same shape without a signal: `.dtx` macrocode is plain code, so
2180        // `\seq_new:N` stops at the first `_` (the feature is opt-in).
2181        let toks = lex_dtx(
2182            "%    \\begin{macrocode}\n\
2183             \\seq_new:N\n\
2184             %    \\end{macrocode}\n",
2185        );
2186        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2187        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq")));
2188        assert!(!seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
2189    }
2190
2191    #[test]
2192    fn implicit_expl_provides_expl_flags_every_body_regardless_of_order() {
2193        // `\ProvidesExplPackage` is a whole-file signal, so a macrocode body
2194        // *above* the declaration is expl3 too — the property left-to-right
2195        // toggling misses.
2196        let toks = lex_dtx(
2197            "%    \\begin{macrocode}\n\
2198             \\seq_new:N\n\
2199             %    \\end{macrocode}\n\
2200             % \\ProvidesExplPackage{p}{2026/01/01}{1.0}{d}\n\
2201             %    \\begin{macrocode}\n\
2202             \\tl_set:Nn\n\
2203             %    \\end{macrocode}\n",
2204        );
2205        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2206        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
2207        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\tl_set:Nn")));
2208    }
2209
2210    #[test]
2211    fn implicit_expl_is_body_only_doc_layer_stays_plain() {
2212        // Implicit expl3 is forced inside the macrocode body and restored on exit,
2213        // so the doc-margin line between/around bodies is ordinary LaTeX: `a_b`
2214        // joins in the body but splits on the doc line.
2215        let toks = lex_dtx(
2216            "%<@@=mod>\n\
2217             % a_b\n\
2218             %    \\begin{macrocode}\n\
2219             c_d\n\
2220             %    \\end{macrocode}\n",
2221        );
2222        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2223        // Body: `_` is a letter, one word.
2224        assert!(seen.contains(&(SyntaxKind::WORD, "c_d")));
2225        // Doc layer: `_` stays a subscript, so `a_b` splits.
2226        assert!(seen.iter().any(|(k, _)| *k == SyntaxKind::UNDERSCORE));
2227    }
2228
2229    #[test]
2230    fn implicit_expl_explicit_off_wins_then_next_body_re_enters() {
2231        // An explicit `\ExplSyntaxOff` inside an implicit body turns expl off for
2232        // the rest of that body; the next body still re-enters expl (the
2233        // save/restore restores the pre-body state, not the toggled-off one).
2234        let toks = lex_dtx(
2235            "%<@@=mod>\n\
2236             %    \\begin{macrocode}\n\
2237             \\seq_new:N\n\
2238             \\ExplSyntaxOff\n\
2239             a_b\n\
2240             %    \\end{macrocode}\n\
2241             %    \\begin{macrocode}\n\
2242             \\tl_set:Nn\n\
2243             %    \\end{macrocode}\n",
2244        );
2245        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2246        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
2247        // After the explicit off, `a_b` splits.
2248        assert!(seen.iter().any(|(k, _)| *k == SyntaxKind::UNDERSCORE));
2249        // The second body re-enters expl despite the earlier off.
2250        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\tl_set:Nn")));
2251    }
2252
2253    #[test]
2254    fn implicit_expl_gated_off_outside_dtx() {
2255        // The signal only fires under `.dtx` mode: a `.sty` with the same bytes
2256        // must not enable implicit expl (there are no macrocode bodies anyway).
2257        let toks = lex_with(
2258            "%<@@=mod>\n\\seq_new:N",
2259            &ParseCtx::default(),
2260            LexConfig {
2261                flavor: LatexFlavor::Package,
2262                dtx: false,
2263            },
2264        );
2265        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2266        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq")));
2267        assert!(!seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
2268    }
2269
2270    #[test]
2271    fn package_flavor_starts_in_letter_mode() {
2272        // A `.sty`/`.cls` is loaded under an implicit `\makeatletter`, so `@` is a
2273        // letter from the first byte — `\foo@bar` is one control word with no
2274        // explicit `\makeatletter`.
2275        let toks = lex_with(
2276            r"\foo@bar",
2277            &ParseCtx::default(),
2278            LatexFlavor::Package.into(),
2279        );
2280        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2281        assert_eq!(seen, vec![(SyntaxKind::CONTROL_WORD, "\\foo@bar")]);
2282    }
2283
2284    #[test]
2285    fn package_flavor_respects_trailing_makeatother() {
2286        // Letter-mode starts on, but an explicit `\makeatother` still turns it off.
2287        let toks = lex_with(
2288            r"\foo@bar\makeatother\foo@bar",
2289            &ParseCtx::default(),
2290            LatexFlavor::Package.into(),
2291        );
2292        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2293        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo@bar")));
2294        // After \makeatother the second occurrence splits into `\foo` + `@bar`.
2295        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
2296    }
2297
2298    #[test]
2299    fn document_flavor_keeps_at_non_letter() {
2300        // The default `.tex` flavor does not start in letter-mode.
2301        let toks = lex(r"\foo@bar");
2302        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2303        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
2304        assert!(!seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo@bar")));
2305    }
2306
2307    #[test]
2308    fn dtx_mode_lexes_line_leading_percent_as_a_margin() {
2309        // A line-leading `%` is a one-byte `DOC_MARGIN`; the rest of the doc line
2310        // lexes as ordinary LaTeX. A `%` not in column 0 stays a `COMMENT`.
2311        let dtx = LexConfig {
2312            flavor: LatexFlavor::Document,
2313            dtx: true,
2314        };
2315        let toks = lex_with("% \\foo\nbar % tail\n", &ParseCtx::default(), dtx);
2316        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
2317        assert_eq!(seen[0], (SyntaxKind::DOC_MARGIN, "%"));
2318        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
2319        assert!(seen.contains(&(SyntaxKind::COMMENT, "% tail")));
2320        // Exactly one margin (column 0 of the first line only).
2321        assert_eq!(
2322            seen.iter()
2323                .filter(|(k, _)| *k == SyntaxKind::DOC_MARGIN)
2324                .count(),
2325            1
2326        );
2327    }
2328
2329    #[test]
2330    fn dtx_mode_is_off_by_default_for_margins_and_guards() {
2331        // Without the docstrip flag a `%` line stays a comment (plain `.tex`); a
2332        // `%<…>` guard likewise stays a single comment.
2333        let plain = lex("% \\foo\n");
2334        assert_eq!(plain[0].kind, SyntaxKind::COMMENT);
2335        let plain_guard = lex("%<*driver>\n");
2336        assert_eq!(plain_guard[0].kind, SyntaxKind::COMMENT);
2337        assert_eq!(plain_guard[0].text, "%<*driver>");
2338    }
2339
2340    #[test]
2341    fn dtx_mode_lexes_line_leading_guards() {
2342        let dtx = LexConfig {
2343            flavor: LatexFlavor::Document,
2344            dtx: true,
2345        };
2346        // `%<*tag>` / `%</tag>` block delimiters are single `GUARD` tokens.
2347        let block = lex_with("%<*driver>\n%</driver>\n", &ParseCtx::default(), dtx);
2348        assert_eq!(block[0].kind, SyntaxKind::GUARD);
2349        assert_eq!(block[0].text, "%<*driver>");
2350        assert!(
2351            block
2352                .iter()
2353                .any(|t| t.kind == SyntaxKind::GUARD && t.text == "%</driver>")
2354        );
2355        // An inline `%<tag>` is a `GUARD` prefix; the rest of the line lexes as code.
2356        let inline = lex_with("%<plain>\\RequirePackage{x}\n", &ParseCtx::default(), dtx);
2357        assert_eq!(inline[0].kind, SyntaxKind::GUARD);
2358        assert_eq!(inline[0].text, "%<plain>");
2359        assert!(
2360            inline
2361                .iter()
2362                .any(|t| t.kind == SyntaxKind::CONTROL_WORD && t.text == "\\RequirePackage")
2363        );
2364        // A boolean tag expression stays one token (through the closing `>`).
2365        let expr = lex_with("%<*package|driver>\n", &ParseCtx::default(), dtx);
2366        assert_eq!(expr[0].kind, SyntaxKind::GUARD);
2367        assert_eq!(expr[0].text, "%<*package|driver>");
2368        // A guard recognized only at column 0: a mid-line `%<…>` stays a comment.
2369        let midline = lex_with("a %<x>\n", &ParseCtx::default(), dtx);
2370        assert!(
2371            midline
2372                .iter()
2373                .any(|t| t.kind == SyntaxKind::COMMENT && t.text == "%<x>")
2374        );
2375        assert!(!midline.iter().any(|t| t.kind == SyntaxKind::GUARD));
2376        // A `%<` with no closing `>` before the line ends is not a guard.
2377        let malformed = lex_with("%<unterminated\n", &ParseCtx::default(), dtx);
2378        assert_eq!(malformed[0].kind, SyntaxKind::COMMENT);
2379        assert_eq!(malformed[0].text, "%<unterminated");
2380    }
2381
2382    #[test]
2383    fn verbatim_environment_body_is_one_raw_token() {
2384        let toks = lex("\\begin{verbatim}\n$not$ %literal\n\\end{verbatim}");
2385        assert_eq!(toks[0].text, "\\begin");
2386        assert_eq!(toks[2].text, "verbatim");
2387        assert!(
2388            toks.iter()
2389                .any(|t| t.kind == SyntaxKind::VERBATIM_BODY && t.text.contains("$not$ %literal"))
2390        );
2391        // Nothing inside the body was lexed as math or a comment.
2392        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::DOLLAR));
2393        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::COMMENT));
2394    }
2395
2396    #[test]
2397    fn argument_taking_verbatim_separates_args_from_body() {
2398        // `minted` declares `[opt]{req}`: both groups are tokenized normally, then
2399        // the rest is one raw body token.
2400        let toks = lex("\\begin{minted}[frame=single]{python}\nprint(\"$x$\")\n\\end{minted}");
2401        let kinds: Vec<_> = toks.iter().map(|t| t.kind).collect();
2402        // The optional and required argument delimiters survive as ordinary tokens…
2403        assert!(kinds.contains(&SyntaxKind::L_BRACKET));
2404        assert!(kinds.contains(&SyntaxKind::R_BRACKET));
2405        assert!(kinds.contains(&SyntaxKind::L_BRACE));
2406        // …and the body (with its `$`) is a single opaque token, not math.
2407        assert!(
2408            toks.iter()
2409                .any(|t| t.kind == SyntaxKind::VERBATIM_BODY && t.text.contains("print(\"$x$\")"))
2410        );
2411        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::DOLLAR));
2412    }
2413
2414    #[test]
2415    fn verbatim_body_starting_with_bracket_is_not_an_argument() {
2416        // `lstlisting`'s lone optional argument is absent (a newline separates the
2417        // `\begin` from the `[`), so `[1,2,3]` stays inside the raw body.
2418        let toks = lex("\\begin{lstlisting}\n[1,2,3]\n\\end{lstlisting}");
2419        assert!(
2420            !toks
2421                .iter()
2422                .take_while(|t| t.kind != SyntaxKind::VERBATIM_BODY)
2423                .any(|t| t.kind == SyntaxKind::L_BRACKET),
2424            "the bracket on the body's first line must not be lexed as an argument"
2425        );
2426        assert!(
2427            toks.iter()
2428                .any(|t| t.kind == SyntaxKind::VERBATIM_BODY && t.text.contains("[1,2,3]"))
2429        );
2430    }
2431
2432    #[test]
2433    fn make_short_verb_toggles_pipe_capture() {
2434        // Before the toggle a `|…|` is ordinary text; after `\MakeShortVerb{\|}`
2435        // it captures as one opaque `VERB`; `\DeleteShortVerb{\|}` turns it off.
2436        let toks = lex("|a| \\MakeShortVerb{\\|} |$| \\DeleteShortVerb{\\|} |b|");
2437        let verbs: Vec<_> = toks
2438            .iter()
2439            .filter(|t| t.kind == SyntaxKind::VERB)
2440            .map(|t| t.text.as_str())
2441            .collect();
2442        assert_eq!(verbs, ["|$|"]);
2443        assert_lossless("|a| \\MakeShortVerb{\\|} |$| \\DeleteShortVerb{\\|} |b|");
2444    }
2445
2446    #[test]
2447    fn documentclass_ltxguide_enables_the_pipe_short_verb() {
2448        // The curated doc classes (`ltxdoc`, `ltxguide`, `ltnews`, `l3doc`,
2449        // `amsldoc`) make `|` a short verb themselves, so loading one enables the
2450        // capture — options and trailing release dates included. `amsldoc` does it
2451        // with an active `|` (`\\gdef|{\\protect\\activevert{}}`, amsldoc.cls),
2452        // like `ltxguide`/`ltnews`; without it amsldoc.tex's `|\\begin{alignat}|`
2453        // prose read as real structure (issue #71).
2454        for preamble in [
2455            "\\documentclass{ltxguide}",
2456            "\\documentclass[a4paper]{ltxdoc}",
2457            "\\documentclass{ltxguide}[1994/11/20]",
2458            "\\documentclass{l3doc}",
2459            "\\documentclass[leqno,titlepage]{amsldoc}[1999/12/13]",
2460        ] {
2461            let input = format!("{preamble}\n|}}| done");
2462            let toks = lex(&input);
2463            assert!(
2464                toks.iter()
2465                    .any(|t| t.kind == SyntaxKind::VERB && t.text == "|}|"),
2466                "no VERB captured after {preamble}"
2467            );
2468        }
2469        // An unrelated class leaves `|` alone.
2470        let toks = lex("\\documentclass{article}\n|x| done");
2471        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::VERB));
2472    }
2473
2474    #[test]
2475    fn short_verb_never_captures_a_left_right_delimiter() {
2476        // `\left|x\right|` in math: the bars are delimiters, not a verb span.
2477        let toks = lex("\\MakeShortVerb{\\|} $\\left|x\\right|$");
2478        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::VERB));
2479        assert_lossless("\\MakeShortVerb{\\|} $\\left|x\\right|$");
2480    }
2481
2482    #[test]
2483    fn unclosed_short_verb_char_stands_alone() {
2484        // With no closing partner on the line, the enabled char is a lone
2485        // one-character word (never gluing into the following text).
2486        let toks = lex("\\MakeShortVerb{\\|} a|b\nc");
2487        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::VERB));
2488        assert!(
2489            toks.iter()
2490                .any(|t| t.kind == SyntaxKind::WORD && t.text == "|")
2491        );
2492        assert_lossless("\\MakeShortVerb{\\|} a|b\nc");
2493    }
2494
2495    /// A raw capture's *content* changes nothing about how the rest of the file
2496    /// lexes.
2497    ///
2498    /// This is a lexer property stated as one, but the reason it is pinned lives in
2499    /// `parser::reparse::protected`: that tier splices a new body into an existing
2500    /// tree without re-lexing anything after it, which is sound only because the
2501    /// lexer leaves a raw capture in the state it entered. Structurally it holds
2502    /// because `lex_verbatim_environment` / `lex_verbatim_command` /
2503    /// [`Lexer::try_short_verb`] push straight to `out`, so the captured bytes never
2504    /// reach [`Lexer::apply_toggles`], [`next_pending`], or
2505    /// [`Lexer::sync_brace_depth`] — but that is an argument about code, and this is
2506    /// the test that would notice it stop being true.
2507    ///
2508    /// The suffix is chosen to be sensitive to every state variable the lexer
2509    /// carries: `@` in a control word (`at_letter`), `_`/`:` (`expl_syntax`), a `|`
2510    /// (`short_verbs`), a `` ` `` after a `\char` (`brace_depth`), and a `\left`
2511    /// delimiter (`pending`).
2512    #[test]
2513    fn raw_capture_content_does_not_change_later_lexing() {
2514        /// Bodies that stay captured. Each would toggle a lexer mode or open a
2515        /// group if it were read as code rather than swallowed as data.
2516        const ENV_BODIES: &[&str] = &[
2517            "",
2518            "plain text",
2519            "\\makeatletter",
2520            "\\ExplSyntaxOn",
2521            "\\MakeShortVerb{\\|}",
2522            "{{{",
2523            "}}}",
2524            "% not a comment",
2525            "$ & # ^ _ ~",
2526            "\\end{verbatimx}",
2527            "\\begin{verbatim}",
2528            "\\char`{",
2529            "\\left(",
2530        ];
2531        /// The same, restricted to what every inline form can hold: no newline, no
2532        /// `+` (the delimiter), and braces balanced (`\url`'s scan needs them).
2533        const INLINE_BODIES: &[&str] = &[
2534            "",
2535            "x",
2536            "\\makeatletter",
2537            "\\ExplSyntaxOn",
2538            "{}",
2539            "$ & # ^ _ ~",
2540            "% not a comment",
2541            "\\char`",
2542        ];
2543        const SUFFIX: &str = "after \\my@cmd \\l_tmpa_tl |bar| \\char`{ \\left( x\n";
2544
2545        for (prefix, open, close, bodies) in [
2546            (
2547                "before x\n",
2548                "\\begin{verbatim}\n",
2549                "\n\\end{verbatim}\n",
2550                ENV_BODIES,
2551            ),
2552            (
2553                "before x\n",
2554                "\\begin{lstlisting}[a=b]\n",
2555                "\n\\end{lstlisting}\n",
2556                ENV_BODIES,
2557            ),
2558            ("before x ", "\\verb+", "+ ", INLINE_BODIES),
2559            ("before x ", "\\url{", "} ", INLINE_BODIES),
2560            ("before x ", "\\lstinline+", "+ ", INLINE_BODIES),
2561        ] {
2562            let mut expected: Option<Vec<(SyntaxKind, String)>> = None;
2563            for body in bodies {
2564                let region = format!("{open}{body}{close}");
2565                let doc = format!("{prefix}{region}{SUFFIX}");
2566                assert_lossless(&doc);
2567
2568                // The premise: the region really did capture. A body that *breaks*
2569                // its capture is a different case — see the test below.
2570                let toks = lex(&doc);
2571                assert!(
2572                    toks.iter()
2573                        .any(|t| matches!(t.kind, SyntaxKind::VERB | SyntaxKind::VERBATIM_BODY))
2574                        || body.is_empty(),
2575                    "no raw capture formed, so this case proves nothing\n  \
2576                     region: {region:?}",
2577                );
2578
2579                let from = prefix.len() + region.len();
2580                let mut off = 0usize;
2581                let got: Vec<(SyntaxKind, String)> = toks
2582                    .into_iter()
2583                    .filter(|t| {
2584                        let start = off;
2585                        off += t.text.len();
2586                        start >= from
2587                    })
2588                    .map(|t| (t.kind, t.text.to_string()))
2589                    .collect();
2590
2591                match &expected {
2592                    None => expected = Some(got),
2593                    Some(want) => assert_eq!(
2594                        &got, want,
2595                        "a raw body changed how the text after it lexes\n  \
2596                         region: {region:?}",
2597                    ),
2598                }
2599            }
2600        }
2601    }
2602
2603    /// The other half, and the reason the reparse tier re-lexes a whole fragment
2604    /// rather than trusting the body alone: a body that *breaks* its capture does
2605    /// change how the rest of the file lexes.
2606    ///
2607    /// `\url{{}` leaves `braced_verb_content_len` unbalanced, so no `VERB` forms and
2608    /// the braces are ordinary structure — which ratchets `brace_depth` and flips
2609    /// the char-constant reading of a later `` \char`{ ``. Nothing about the body's
2610    /// own bytes says that; only re-lexing the construct does.
2611    #[test]
2612    fn a_body_that_breaks_its_capture_changes_later_lexing() {
2613        let captured = lex("\\url{x} \\char`{");
2614        assert!(captured.iter().any(|t| t.kind == SyntaxKind::VERB));
2615        assert!(
2616            captured
2617                .iter()
2618                .any(|t| t.kind == SyntaxKind::WORD && t.text == "`{")
2619        );
2620
2621        let broken = lex("\\url{{} \\char`{");
2622        assert!(!broken.iter().any(|t| t.kind == SyntaxKind::VERB));
2623        assert!(
2624            broken
2625                .iter()
2626                .any(|t| t.kind == SyntaxKind::WORD && t.text == "`")
2627        );
2628    }
2629}