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