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, 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 lexer context carrying *user-defined* verbatim constructs — those a
97/// document declares with catcode manipulation (`\@makeother\$`, …), found by scanning
98/// definition bodies ([`crate::semantic::define`]). The lexer consults it (alongside
99/// the built-in DB) to capture a verbatim *command*'s final argument as one `VERB`
100/// token, and a verbatim *environment*'s body as one `VERBATIM_BODY` token. Empty for
101/// the first parse pass; populated for the second when the document defines any (see
102/// `parser::core`).
103///
104/// A command entry maps a name (no leading `\`) to its *leading*, non-verbatim
105/// argument shape, the verbatim argument itself being implicit — matching the built-in
106/// convention. An environment entry maps a name to its full argument shape (an
107/// environment's args are all leading; its body follows the `\begin{…}` arguments), so
108/// presence in `environments` means the environment is verbatim.
109///
110/// `suppressed` names the inverse case: commands the current file *redefines* to an
111/// ordinary (non-verbatim) macro whose name collides with a built-in braced-verbatim
112/// command (`\code`, `\url`, `\path`, …). A local definition shadows the built-in, so
113/// [`lex_verbatim_command`] must lex `\code{…}` as an ordinary group rather than capture
114/// the built-in `VERB` (follow-up to issue #53). We read only static definition facts (a
115/// visible `\newcommand`/`\def` with no catcode signal), never macro meaning.
116#[derive(Debug, Default, Clone)]
117pub struct VerbCtx {
118    commands: HashMap<SmolStr, Vec<ArgSpec>>,
119    environments: HashMap<SmolStr, Vec<ArgSpec>>,
120    suppressed: HashSet<SmolStr>,
121}
122
123impl VerbCtx {
124    /// Whether the context names no user verbatim constructs *and* no suppressions (the
125    /// common case — the second parse pass is skipped entirely).
126    pub fn is_empty(&self) -> bool {
127        self.commands.is_empty() && self.environments.is_empty() && self.suppressed.is_empty()
128    }
129
130    /// Record that `name` is a verbatim-argument command with the given `leading`
131    /// (non-verbatim) argument shape.
132    pub(crate) fn insert(&mut self, name: SmolStr, leading: Vec<ArgSpec>) {
133        self.commands.insert(name, leading);
134    }
135
136    /// Record that `name` — a built-in braced-verbatim command — is redefined
137    /// non-verbatim in this file, so its built-in verbatim capture is suppressed.
138    pub(crate) fn suppress(&mut self, name: SmolStr) {
139        self.suppressed.insert(name);
140    }
141
142    /// Whether `name`'s built-in verbatim capture is suppressed by a local redefinition.
143    fn is_suppressed(&self, name: &str) -> bool {
144        self.suppressed.contains(name)
145    }
146
147    /// Record that environment `name` is verbatim, with the given argument shape (all
148    /// leading; the raw body follows the arguments).
149    pub(crate) fn insert_environment(&mut self, name: SmolStr, args: Vec<ArgSpec>) {
150        self.environments.insert(name, args);
151    }
152
153    /// The leading argument shape of `name` if it is a known user verbatim command.
154    fn leading_args(&self, name: &str) -> Option<&[ArgSpec]> {
155        self.commands.get(name).map(Vec::as_slice)
156    }
157
158    /// The argument shape of `name` if it is a user-defined verbatim environment.
159    fn verbatim_environment_args(&self, name: &str) -> Option<&[ArgSpec]> {
160        self.environments.get(name).map(Vec::as_slice)
161    }
162
163    /// Is `name` a verbatim-like environment — one whose body the parser must route to
164    /// its raw-body branch, per `AGENTS.md` Core decision #1? A user-defined one (from
165    /// this context) or a built-in one ([`builtin`]). Both the lexer (to find where the
166    /// raw body begins) and the structural parser (`grammar.rs`) ask this question, so
167    /// one lookup keeps them in lockstep. We read only static argument-shape data; no
168    /// macro meaning is resolved, so this stays within decision #1's sanctioned modes.
169    ///
170    /// Deliberately consults [`builtin`] only, never the bulk CWL tier
171    /// ([`crate::semantic::signature::cwl`]): routing a body to the raw-verbatim
172    /// branch is lossy if wrong, so this behavior decision rests solely on curated
173    /// data (the CWL tier carries `verbatim_body == false` for every entry anyway).
174    pub(crate) fn is_verbatim_environment(&self, name: &str) -> bool {
175        self.environments.contains_key(name)
176            || builtin()
177                .environment(name)
178                .is_some_and(|env| env.verbatim_body)
179    }
180}
181
182/// Is `name` a block/display environment — one whose lone occurrence the parser
183/// should leave unwrapped rather than nest in a redundant `PARAGRAPH`? Resolved
184/// against the built-in signature database ([`builtin`]) only: the parser runs
185/// before any per-file `\newenvironment` scan, so (as with verbatim) user-defined
186/// block-ness is unknown at parse time and a user/unknown environment stays
187/// wrapped — the conservative, lossless-safe default. The bulk CWL tier is not
188/// consulted here (it carries no `block` flag, and parser layout decisions stay on
189/// curated data).
190pub(crate) fn is_block_environment(name: &str) -> bool {
191    builtin().environment(name).is_some_and(|env| env.block)
192}
193
194/// Is `name` a math environment — one whose body the parser should parse in math
195/// mode, wrapping it in a `MATH` node exactly as `\[…\]` does (so scripts become
196/// `SCRIPTED`, operators split, and `\left…\right` pair)? Resolved against the
197/// built-in signature database ([`builtin`]) only, for the same reason as
198/// [`is_block_environment`] and [`VerbCtx::is_verbatim_environment`]: routing a body
199/// into math mode is a structural (lossless-preserving but shape-changing) decision,
200/// so it rests solely on curated data. The bulk CWL tier carries `math == false` for
201/// every entry, and a user/unknown environment stays in text mode — the
202/// conservative default. This is a sanctioned static-fact mode (AGENTS.md, Core
203/// decision #1): no macro meaning is resolved, only the curated `math` flag is read.
204pub(crate) fn is_math_environment(name: &str) -> bool {
205    builtin().environment(name).is_some_and(|env| env.math)
206}
207
208/// Whether `text` (a `CONTROL_WORD`, leading `\` included) is a command-definition
209/// keyword whose immediately-following name must not be lexed as a verbatim call.
210/// Covers the LaTeX2e and xparse families the definition scanner recognizes plus the
211/// primitive `\def` family; `\let` is included since it too binds a following name.
212/// Reads only the static keyword, no macro meaning.
213fn is_definition_keyword(text: &str) -> bool {
214    matches!(
215        text,
216        "\\newcommand"
217            | "\\renewcommand"
218            | "\\providecommand"
219            | "\\DeclareRobustCommand"
220            | "\\NewDocumentCommand"
221            | "\\RenewDocumentCommand"
222            | "\\ProvideDocumentCommand"
223            | "\\DeclareDocumentCommand"
224            | "\\def"
225            | "\\edef"
226            | "\\gdef"
227            | "\\xdef"
228            | "\\let"
229    )
230}
231
232/// Whether `text` (a `CONTROL_WORD`, leading `\` included) is a TeX primitive
233/// that opens a numeric context, where a following number is conventionally
234/// written in backtick char-constant notation (`` \char`$ ``, `` \catcode`\%=12 ``,
235/// `` \number`\[ ``): after it, a backtick makes the next character *data*, never
236/// syntax. A closed curated set; reads only the static keyword, no macro meaning.
237/// The number-*producing* primitives (`\number`/`\the`/`\romannumeral`) and the
238/// numeric conditionals (`\ifnum`/`\ifodd`/`\ifdim`) are included alongside the
239/// codetables because their operand is just as routinely a backtick constant.
240/// Whether `text` (a `CONTROL_WORD`, leading `\` included) is a TeX primitive
241/// that grabs the *next token* without expanding it, so a following character
242/// keeps its literal shape. Only the short-verb capture reads this: an active
243/// `|` after `\string` is the token being printed, not a `\verb`-style opener
244/// (`\meta{first\texttt{\string|}last}`, lthooks.dtx). A closed curated set,
245/// read from the static keyword alone — no macro meaning.
246fn is_literal_token_command(text: &str) -> bool {
247    matches!(
248        text,
249        "\\string" | "\\noexpand" | "\\meaning" | "\\expandafter" | "\\show"
250    )
251}
252
253fn is_char_constant_command(text: &str) -> bool {
254    matches!(
255        text,
256        "\\char"
257            | "\\catcode"
258            | "\\lccode"
259            | "\\uccode"
260            | "\\sfcode"
261            | "\\mathcode"
262            | "\\delcode"
263            | "\\number"
264            | "\\the"
265            | "\\romannumeral"
266            | "\\numexpr"
267            | "\\dimexpr"
268            | "\\ifnum"
269            | "\\ifodd"
270            | "\\ifdim"
271    )
272}
273
274/// An expl3 catcode-mode toggle recognized purely by its control-word spelling.
275/// Shared by the lexer (which flips its `expl_syntax` flag) and the formatter's
276/// region pre-pass (the `badness-formatter` crate recomputes in-region byte spans), so the
277/// two read the *same* fixed toggle set and can never drift.
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
279pub enum ExplToggle {
280    /// `\ExplSyntaxOn`, or `\ProvidesExplPackage`/`Class`/`File` (which open expl3
281    /// syntax for the rest of the file).
282    On,
283    /// `\ExplSyntaxOff`.
284    Off,
285}
286
287/// Classify a control word's text as an expl3 catcode-mode toggle, if any. Only
288/// meaningful on [`SyntaxKind::CONTROL_WORD`] text: a `\ExplSyntaxOn` inside a
289/// `\verb`/comment lexes as a `VERB`/`COMMENT` token and so never reaches here.
290pub fn expl_toggle(text: &str) -> Option<ExplToggle> {
291    match text {
292        "\\ExplSyntaxOn"
293        | "\\ProvidesExplPackage"
294        | "\\ProvidesExplClass"
295        | "\\ProvidesExplFile" => Some(ExplToggle::On),
296        "\\ExplSyntaxOff" => Some(ExplToggle::Off),
297        _ => None,
298    }
299}
300
301/// True when a `.dtx` file carries a *static expl3 signal* even though it never
302/// runs an in-file toggle: a line-leading `%<@@=…>` docstrip module-prefix guard,
303/// or a `\ProvidesExpl{Package,Class,File}` declaration anywhere. Real expl3
304/// package sources declare expl3 in the parent `.dtx`/build and set the module
305/// prefix `@@` with a `%<@@=mod>` guard, so their `macrocode` bodies are expl3
306/// code with no `\ExplSyntaxOn` to see (`ltx-talk-structure.dtx`, TODO.md).
307///
308/// Scans the raw text before lexing, so it cannot reuse [`expl_toggle`] (which
309/// classifies already-lexed token text). Deliberately coarse and name-only, like
310/// the lexer's other expl handling: it sees the whole file — prose and verbatim
311/// examples included — so a `\ProvidesExpl*` mentioned as text also trips it. That
312/// is acceptable (`AGENTS.md` decision #1): a false positive only *joins* `_`/`:`
313/// into a control word (lossless), and reading the whole file keeps the signal
314/// order-independent, so a body *above* the declaration is flagged too.
315fn dtx_has_expl_signal(input: &str) -> bool {
316    input.contains("\\ProvidesExpl")
317        || input
318            .lines()
319            .any(|l| l.starts_with("%<@@=") && l[5..].contains('>'))
320}
321
322/// Lex `input` into a flat, lossless token stream, consulting only the built-in
323/// signature DB for verbatim commands/environments. The entry used by the first
324/// parse pass; [`lex_with`] adds user-defined verbatim commands. Uses the
325/// [`Document`](LatexFlavor::Document) flavor (ordinary starting catcodes).
326pub fn lex(input: &str) -> Vec<Token> {
327    lex_with(input, &VerbCtx::default(), LexConfig::default())
328}
329
330/// Lex `input` like [`lex`], additionally treating the user-defined verbatim
331/// commands in `ctx` as verbatim (their final argument captured as one `VERB`
332/// token). Used by the second parse pass once definition scanning has discovered
333/// catcode-othering commands. `config` fixes the initial catcode regime (a
334/// [`Package`](LatexFlavor::Package) flavor starts with `@` already a letter) and
335/// whether to run the `.dtx` docstrip mode.
336pub fn lex_with(input: &str, ctx: &VerbCtx, config: LexConfig) -> Vec<Token> {
337    let mut out: Vec<Token> = Vec::new();
338    let mut pos = 0;
339    let mut at_letter = config.flavor.letter_mode_start(); // `\makeatletter` state
340    // `\ExplSyntaxOn` state: while true, `_` and `:` are catcode-11 letters, so
341    // expl3 names (`\seq_new:N`, `\__module_internal:nn`) lex as single control
342    // words. Toggled by `\ExplSyntaxOn`/`\ExplSyntaxOff` and turned on by the
343    // `\ProvidesExpl*` package/class/file declarations (a sanctioned static lexer
344    // mode, `AGENTS.md` decision #1). Independent of `at_letter`; the two compose.
345    let mut expl_syntax = false;
346    // `.dtx` docstrip mode: true at the start of a physical line (start of input
347    // or just after a `NEWLINE`), so a line-leading `%` can be recognized as a
348    // documentation margin. Any token — including whitespace — clears it, matching
349    // docstrip's rule that only a `%` in *column 0* is a margin.
350    let mut at_line_start = true;
351    // doc-package short-verb characters (`\MakeShortVerb{\|}`): while a char is
352    // enabled, `<c>…<c>` on one line captures as a single opaque `VERB` token,
353    // exactly like `\verb<c>…<c>`. A sanctioned static lexer mode (`AGENTS.md`
354    // decision #1): the toggles are the explicit `\MakeShortVerb`/
355    // `\DeleteShortVerb` calls (left-to-right, like `\makeatletter`), plus the
356    // curated doc classes that enable `|` themselves (`\documentclass{ltxdoc}`
357    // and friends, see [`doc_class_enables_bar`]). The `.dtx` documentation
358    // layer gets `|` from the start — dtx files are typeset under `ltxdoc`, and
359    // the driver holding the `\documentclass` may live in a separate file.
360    let mut short_verbs: Vec<char> = if config.dtx { vec!['|'] } else { Vec::new() };
361    // True while inside a `macrocode`/`macrocode*` environment body (between its
362    // frame lines). There, code lines carry no margin, a line-leading `%` is an
363    // ordinary code comment (not a margin), and `@` is a letter (`macrocode` runs
364    // under `\makeatletter`). The pre-macrocode `at_letter` is saved here and
365    // restored on exit.
366    let mut in_macrocode = false;
367    let mut saved_at_letter = at_letter;
368    // Implicit expl3: a toggle-less `.dtx` whose static signal (a `%<@@=mod>`
369    // module guard or a `\ProvidesExpl*` anywhere) marks its `macrocode` bodies as
370    // expl3 code. When set, `expl_syntax` is forced on inside every macrocode body
371    // (and restored on exit), mirroring the `at_letter` save/restore above. Only
372    // `.dtx` files have macrocode bodies, so this is gated on `config.dtx`.
373    let implicit_expl = config.dtx && dtx_has_expl_signal(input);
374    let mut saved_expl_syntax = expl_syntax;
375    // True while lexing the remainder of a `.dtx` documentation line (a line whose
376    // column-0 `%` was emitted as a `DOC_MARGIN` above). On such lines the ltxdoc/
377    // l3doc `\catcode`\^^A=14` convention applies, so a literal `^^A` reads as a
378    // comment to end of line. Cleared at every physical line boundary.
379    let mut in_doc_line = false;
380    // True when the previous meaningful token was `\left`/`\right`, so the next
381    // delimiter must be isolated as a single token (it carries across whitespace,
382    // which TeX skips before the delimiter).
383    let mut pending_delim = false;
384    // True while the next control word is the *name being defined* by a definition
385    // keyword (`\newcommand\foo…`, `\NewDocumentCommand{\foo}…`, `\def\foo…`), so it
386    // must not be lexed as a verbatim *call*: at a definition site the trailing
387    // `{…}` are the signature/body, not the command's argument. Persists across the
388    // intervening `{`/whitespace of the braced form and clears once the name is
389    // consumed. Without this, a command flagged verbatim in pass 1 would have its own
390    // definition's first group captured as a `VERB` in pass 2.
391    let mut pending_def = false;
392    // True right after a `\char`/`\catcode`-family primitive (across inline
393    // whitespace), where a backtick opens TeX's char-constant number notation:
394    // the character after the backtick is data (`` \char`$ ``, `` \char`} ``),
395    // never a math opener or group brace. The doc layer writes the notation in
396    // prose (issue #60), so without this the hidden `$`/`{` cascade into
397    // unclosed-math and unclosed-group diagnostics.
398    let mut pending_char_constant = false;
399    // True right after a primitive that consumes the *next token* unexpanded
400    // ([`is_literal_token_command`]), where a short-verb character is that
401    // token rather than a capture opener (`\string|`, lthooks.dtx, issue #71).
402    let mut pending_literal_token = false;
403    // Number of brace groups open at the cursor, counted over every token
404    // emitted so far (helpers push braces too, so `out` is the one place that
405    // sees them all). Read by the char-constant branch: inside a group TeX has
406    // already claimed a `{`/`}` as balanced-text structure, so a backtick there
407    // cannot hide it. Saturating, so an unbalanced file never underflows.
408    let mut brace_depth = 0usize;
409    let mut brace_counted = 0usize;
410    while pos < input.len() {
411        let rest = &input[pos..];
412        while brace_counted < out.len() {
413            match out[brace_counted].kind {
414                SyntaxKind::L_BRACE => brace_depth += 1,
415                SyntaxKind::R_BRACE => brace_depth = brace_depth.saturating_sub(1),
416                _ => {}
417            }
418            brace_counted += 1;
419        }
420
421        // `.dtx` `macrocode` frame line. A `%␣*\begin{macrocode}` line opens a code
422        // region; its `%␣*\end{macrocode}` terminator closes it. Both lex as a
423        // margin + indent + `\begin`/`\end{macrocode}` so the ordinary environment
424        // grammar pairs them, but the *body* in between lexes as real code, under
425        // the package regime (`@` a letter) with no margin stripping. We look for a
426        // begin frame outside the body and the end frame inside it; anything else on
427        // a `%` line inside the body is an ordinary code comment.
428        if config.dtx
429            && at_line_start
430            && let Some(consumed) = lex_macrocode_frame(rest, !in_macrocode, &mut out)
431        {
432            if in_macrocode {
433                in_macrocode = false;
434                at_letter = saved_at_letter;
435                expl_syntax = saved_expl_syntax;
436            } else {
437                in_macrocode = true;
438                saved_at_letter = at_letter;
439                at_letter = true;
440                saved_expl_syntax = expl_syntax;
441                if implicit_expl {
442                    expl_syntax = true;
443                }
444            }
445            pos += consumed;
446            at_line_start = false;
447            pending_delim = false;
448            pending_literal_token = false;
449            pending_def = false;
450            continue;
451        }
452
453        // `.dtx` docstrip guard: a line-leading `%<…>` is a docstrip guard
454        // expression (`%<*tag>`/`%</tag>` block delimiters or an inline `%<tag>`
455        // prefix), not a comment. Emit the `%<…>` (through the closing `>`) as a
456        // single `GUARD` trivia leaf; code after an inline guard's `>` lexes
457        // normally. Guards nest on the docstrip axis, orthogonal to LaTeX nesting,
458        // so this is a flat floating leaf (no block node), like a margin. Recognized
459        // at line start only (column-0 rule) but in *any* layer — guards punctuate
460        // `macrocode` bodies too — so it is not gated on `in_macrocode`. A `%<` with
461        // no closing `>` before the line ends is not a guard; it falls through to an
462        // ordinary comment. Trivia, so `pending_delim`/`pending_def` carry across.
463        if config.dtx
464            && at_line_start
465            && rest.starts_with("%<")
466            && let Some(rel) = rest[2..].find(['>', '\n', '\r'])
467            && rest.as_bytes()[2 + rel] == b'>'
468        {
469            let len = 2 + rel + 1;
470            out.push(Token {
471                kind: SyntaxKind::GUARD,
472                text: SmolStr::new(&rest[..len]),
473            });
474            pos += len;
475            at_line_start = false;
476            continue;
477        }
478
479        // `.dtx` documentation margin: a line-leading `%` (but not a `%<…>` guard,
480        // which lexes as a `GUARD` above) is a documentation line's
481        // comment *margin*, not a comment. Emit it as a `DOC_MARGIN` trivia token —
482        // one byte, never the following space — so the rest of the line lexes (and
483        // parses) as ordinary LaTeX and the margin floats like whitespace. Only the
484        // line-leading `%` is a margin; a later `%` on the same line stays a
485        // `COMMENT`. Inside a `macrocode` body there is no margin (code lines own
486        // their `%`), so this is gated on `!in_macrocode`. The margin is trivia, so
487        // it carries `pending_delim`/`pending_def` across unchanged (like whitespace).
488        if config.dtx
489            && at_line_start
490            && !in_macrocode
491            && rest.starts_with('%')
492            && !rest.starts_with("%<")
493        {
494            out.push(Token {
495                kind: SyntaxKind::DOC_MARGIN,
496                text: SmolStr::new("%"),
497            });
498            pos += 1;
499            at_line_start = false;
500            in_doc_line = true;
501            continue;
502        }
503
504        // Verbatim-like environment: emit `\begin{name}` then a raw body token.
505        if let Some(consumed) = lex_verbatim_environment(rest, ctx, &mut out) {
506            pos += consumed;
507            pending_delim = false;
508            pending_literal_token = false;
509            pending_def = false;
510            at_line_start = false;
511            continue;
512        }
513
514        // l3doc `v`-type name argument in delimited form (`\begin{macro}+…+`):
515        // capture the span as one opaque `VERB` token so its unbalanced braces
516        // stay data. Gated off inside a `macrocode` body, where a `\begin` is
517        // plain macro code, not an l3doc environment.
518        if !in_macrocode && let Some(consumed) = lex_verbatim_arg_environment(rest, &mut out) {
519            pos += consumed;
520            pending_delim = false;
521            pending_literal_token = false;
522            pending_def = false;
523            at_line_start = false;
524            continue;
525        }
526
527        // Verbatim-argument command (`\url{…}`, `\code{…}`, `\lstinline|…|`, …):
528        // emit the control word and any leading args, then a raw argument token.
529        // `\verb`/`\verb*` are handled separately in `lex_control` (delimiter
530        // only), so they fall through here. Suppressed at a definition site
531        // (`pending_def`), where the following groups are the signature/body.
532        if !pending_def
533            && let Some(consumed) =
534                lex_verbatim_command(rest, at_letter, expl_syntax, ctx, &mut out)
535        {
536            pos += consumed;
537            pending_delim = false;
538            pending_literal_token = false;
539            at_line_start = false;
540            continue;
541        }
542
543        // Short-verb span (`|…|` under doc's `\MakeShortVerb{\|}`): capture the
544        // delimited run as one opaque `VERB` token, same-line only (like `\verb`).
545        // Gated off inside a `macrocode` body (a code layer, where `|` is an
546        // ordinary catcode-12 character) and after `\left`/`\right` (whose next
547        // character is a delimiter, `\left|x\right|`). With no closing delimiter
548        // on the line, fall through: the word-run truncation below still emits
549        // the lone character as its own token. Also gated off after a primitive
550        // that takes the next token unexpanded ([`is_literal_token_command`]):
551        // `\string|` prints the bar, it does not open a capture that would run
552        // to the next `|` and swallow the intervening braces (lthooks.dtx's
553        // `\meta{first\texttt{\string|}last}\verb|):|`, issue #71).
554        if !short_verbs.is_empty()
555            && !in_macrocode
556            && !pending_delim
557            && !pending_literal_token
558            && let Some(c) = rest.chars().next()
559            && short_verbs.contains(&c)
560            && let Some(len) = delimited_len(rest)
561        {
562            out.push(Token {
563                kind: SyntaxKind::VERB,
564                text: SmolStr::new(&rest[..len]),
565            });
566            pos += len;
567            at_line_start = false;
568            pending_def = false;
569            continue;
570        }
571
572        // TeX char-constant backtick notation: after a `\char`/`\catcode`-family
573        // primitive, a backtick makes the next character data (`` \char`$ ``,
574        // `` \char`} ``), so emit the backtick and that character as one plain
575        // `WORD` token — a `$`/`{` there must not open math or a group. The
576        // escaped single-character form (`` \number`\[ ``) is captured the same
577        // way, backtick plus the whole control symbol: a `\[`/`\]` there is the
578        // *character* `[`/`]`, not a math delimiter (encguide.tex's char-code
579        // table, issue #71).
580        //
581        // A *bare* `{`/`}` is the exception, and only at brace depth 0. Inside a
582        // group the brace has already been claimed as structure by whichever
583        // balanced-text scan opened it — a `\def` body or a macro argument, both
584        // of which count brace *tokens* long before `\char` ever runs — so the
585        // `}` in `` \def\v{\char`} `` (longtable.dtx) and the `` \ifnum`}=0\fi ``
586        // brace-balance idiom (longtable/amsmath) closes its group and is not
587        // data. At depth 0 there is no such scan and the constant reading stands
588        // (`a close-group character is written \char`} in running text`). The
589        // *escaped* form `` `\} `` is unaffected: a control symbol is never a
590        // group delimiter, so it stays data at any depth (issue #71).
591        if pending_char_constant
592            && let Some(after) = rest.strip_prefix('`')
593            && let Some(c) = after.chars().next()
594            && !matches!(c, '\n' | '\r')
595            && !(brace_depth > 0 && matches!(c, '{' | '}'))
596            && let Some(len) = if c == '\\' {
597                // `` `\X ``: backtick, backslash, and one escaped character; a
598                // bare `` `\ `` at line end has no character and falls through.
599                after[1..]
600                    .chars()
601                    .next()
602                    .filter(|e| !matches!(e, '\n' | '\r'))
603                    .map(|e| 2 + e.len_utf8())
604            } else {
605                Some(1 + c.len_utf8())
606            }
607        {
608            out.push(Token {
609                kind: SyntaxKind::WORD,
610                text: SmolStr::new(&rest[..len]),
611            });
612            pos += len;
613            at_line_start = false;
614            pending_char_constant = false;
615            pending_delim = false;
616            pending_literal_token = false;
617            pending_def = false;
618            continue;
619        }
620
621        // `.dtx` `^^A` comment: ltxdoc/l3doc set `\catcode`\^^A=14`, and the doc
622        // layer leans on it for editor-balance hacks in prose (`^^A{` paired with
623        // a verb `|}|`, a commented-out `^^A\end{function}`), so on a doc-margin
624        // line the literal `^^A` sequence is a comment to end of line — a bounded
625        // static fact like the on-by-default `|` short verb (`AGENTS.md` decision
626        // #1). Scoped to doc lines only: inside a `macrocode` body `^^A` is live
627        // code (`\char_set_catcode:nn { `\^^A }` must not swallow its line), and
628        // unmargined driver lines keep ordinary lexing.
629        if in_doc_line && rest.starts_with("^^A") {
630            let len = run_len(rest, |c| c != '\n' && c != '\r');
631            out.push(Token {
632                kind: SyntaxKind::COMMENT,
633                text: SmolStr::new(&rest[..len]),
634            });
635            pos += len;
636            at_line_start = false;
637            pending_delim = false;
638            pending_literal_token = false;
639            pending_def = false;
640            continue;
641        }
642
643        let (kind, mut len) = next_token(rest, at_letter, expl_syntax);
644        // A `\left`/`\right` delimiter that lexes as a word run: keep only its
645        // first character so it does not glue into the following text.
646        if pending_delim && kind == SyntaxKind::WORD {
647            len = rest.chars().next().expect("rest is non-empty").len_utf8();
648        }
649        // An enabled short-verb char never joins a word run: split it off so a
650        // mid-word `x|y|` still opens a capture on the next iteration, and an
651        // unclosed `|` stands alone rather than gluing into the following text.
652        if kind == SyntaxKind::WORD
653            && !short_verbs.is_empty()
654            && let Some((i, c)) = rest[..len]
655                .char_indices()
656                .find(|(_, c)| short_verbs.contains(c))
657        {
658            len = if i == 0 { c.len_utf8() } else { i };
659        }
660        debug_assert!(len > 0, "lexer made no progress at byte {pos}");
661        let text = &rest[..len];
662        if kind == SyntaxKind::CONTROL_WORD {
663            match text {
664                "\\makeatletter" => at_letter = true,
665                "\\makeatother" => at_letter = false,
666                // doc's short-verb toggles: `\MakeShortVerb{\|}` (or the `*` and
667                // unbraced forms) enables the char, `\DeleteShortVerb{\|}`
668                // disables it. Read as static facts left-to-right; a definition
669                // site (`\def\MakeShortVerb{…`) never matches the `\c` argument
670                // shape, so it does not toggle.
671                "\\MakeShortVerb" => {
672                    if let Some(c) = short_verb_char(&rest[len..])
673                        && !short_verbs.contains(&c)
674                    {
675                        short_verbs.push(c);
676                    }
677                }
678                "\\DeleteShortVerb" => {
679                    if let Some(c) = short_verb_char(&rest[len..]) {
680                        short_verbs.retain(|&x| x != c);
681                    }
682                }
683                // The curated doc classes make `|` a short verb themselves
684                // (`ltxdoc` via `\MakeShortVerb`, and the `ltxguide`/`ltnews`
685                // internal equivalents), so loading one enables `|`.
686                "\\documentclass" | "\\LoadClass" => {
687                    if doc_class_enables_bar(&rest[len..]) && !short_verbs.contains(&'|') {
688                        short_verbs.push('|');
689                    }
690                }
691                // `\ExplSyntaxOn`/`Off`, and the `\ProvidesExpl*` declarations which
692                // open expl3 syntax for the rest of the file (they appear at the top
693                // of an expl3 package/class) so left-to-right they act as an On.
694                _ => {
695                    if let Some(toggle) = expl_toggle(text) {
696                        expl_syntax = matches!(toggle, ExplToggle::On);
697                    }
698                }
699            }
700        }
701        pending_delim = match kind {
702            // Trivia is skipped before the delimiter, so the mode persists.
703            SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE => pending_delim,
704            SyntaxKind::CONTROL_WORD if text == "\\left" || text == "\\right" => true,
705            _ => false,
706        };
707        pending_literal_token = match kind {
708            // TeX skips spaces before the token it is about to grab.
709            SyntaxKind::WHITESPACE => pending_literal_token,
710            SyntaxKind::CONTROL_WORD if is_literal_token_command(text) => true,
711            _ => false,
712        };
713        pending_char_constant = match kind {
714            // TeX skips spaces before the number, so the notation may be spaced
715            // (`\char `$`); a line break conventionally ends the shape.
716            SyntaxKind::WHITESPACE => pending_char_constant,
717            SyntaxKind::CONTROL_WORD if is_char_constant_command(text) => true,
718            _ => false,
719        };
720        pending_def = match kind {
721            // A definition keyword arms the suppression for the name that follows.
722            SyntaxKind::CONTROL_WORD if is_definition_keyword(text) => true,
723            // The braced name form (`\newcommand{\foo}`) interposes a `{` and
724            // whitespace before the name; keep the suppression armed across them.
725            SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE | SyntaxKind::L_BRACE => pending_def,
726            // Any other token — in particular the defined name's own control word —
727            // consumes the suppression.
728            _ => false,
729        };
730        out.push(Token {
731            kind,
732            text: SmolStr::new(text),
733        });
734        // A new physical line begins right after a `NEWLINE` — or after any
735        // token that swallows its trailing line break, like the `\<newline>`
736        // control symbol (`… \LaTeX\` at end of line): the next byte is column
737        // 0 either way, so a `.dtx` margin there must still be recognized. Any
738        // other token (whitespace included) leaves the cursor mid-line.
739        at_line_start = kind == SyntaxKind::NEWLINE || text.ends_with('\n') || text.ends_with('\r');
740        if at_line_start {
741            in_doc_line = false;
742        }
743        pos += len;
744    }
745    out
746}
747
748/// Classify the token at the start of `rest` and return its `(kind, byte_len)`.
749fn next_token(rest: &str, at_letter: bool, expl_syntax: bool) -> (SyntaxKind, usize) {
750    let c = rest.chars().next().expect("rest is non-empty");
751    match c {
752        '\\' => lex_control(rest, at_letter, expl_syntax),
753        '%' => (
754            SyntaxKind::COMMENT,
755            run_len(rest, |c| c != '\n' && c != '\r'),
756        ),
757        '{' => (SyntaxKind::L_BRACE, 1),
758        '}' => (SyntaxKind::R_BRACE, 1),
759        '[' => (SyntaxKind::L_BRACKET, 1),
760        ']' => (SyntaxKind::R_BRACKET, 1),
761        '$' => (SyntaxKind::DOLLAR, 1),
762        '&' => (SyntaxKind::AMPERSAND, 1),
763        '#' => (SyntaxKind::HASH, 1),
764        '^' => (SyntaxKind::CARET, 1),
765        // Under `\ExplSyntaxOn`, `_` is a catcode-11 letter, not a subscript: a
766        // bare `_` joins the surrounding word run (handled by the default arm).
767        '_' if !expl_syntax => (SyntaxKind::UNDERSCORE, 1),
768        '~' => (SyntaxKind::TILDE, 1),
769        '\n' => (SyntaxKind::NEWLINE, 1),
770        '\r' => {
771            let len = if rest.as_bytes().get(1) == Some(&b'\n') {
772                2
773            } else {
774                1
775            };
776            (SyntaxKind::NEWLINE, len)
777        }
778        ' ' | '\t' => (
779            SyntaxKind::WHITESPACE,
780            run_len(rest, |c| c == ' ' || c == '\t'),
781        ),
782        _ => (
783            SyntaxKind::WORD,
784            run_len(rest, |c| is_word_char(c) || (expl_syntax && c == '_')),
785        ),
786    }
787}
788
789/// Lex a control sequence: `rest` is known to start with `\`.
790fn lex_control(rest: &str, at_letter: bool, expl_syntax: bool) -> (SyntaxKind, usize) {
791    match rest[1..].chars().next() {
792        // Control word: backslash + one or more letters (`@` too under
793        // `\makeatletter`; `_`/`:` too under `\ExplSyntaxOn`).
794        Some(d) if is_letter(d, at_letter, expl_syntax) => {
795            let letters = run_len(&rest[1..], |c| is_letter(c, at_letter, expl_syntax));
796            let word_len = 1 + letters;
797            // `\verb` / `\verb*`: swallow the delimited argument as one token.
798            if &rest[..word_len] == "\\verb"
799                && let Some(arg_len) = verb_len(&rest[word_len..])
800            {
801                return (SyntaxKind::VERB, word_len + arg_len);
802            }
803            (SyntaxKind::CONTROL_WORD, word_len)
804        }
805        // Control symbol: backslash + exactly one other character.
806        Some(d) => (SyntaxKind::CONTROL_SYMBOL, 1 + d.len_utf8()),
807        // A lone trailing backslash at end of input.
808        None => (SyntaxKind::CONTROL_SYMBOL, 1),
809    }
810}
811
812/// Length in bytes of a `\verb` argument: an optional `*`, then a delimited run.
813/// Returns `None` if malformed (no delimiter, or it spans a line break).
814fn verb_len(after: &str) -> Option<usize> {
815    match after.strip_prefix('*') {
816        Some(rest) => Some(1 + delimited_len(rest)?),
817        None => delimited_len(after),
818    }
819}
820
821/// Length in bytes of a `\verb`-style delimited run: a delimiter character, then
822/// everything up to and including its next occurrence. Returns `None` if the
823/// delimiter is whitespace or the run spans a line break.
824fn delimited_len(after: &str) -> Option<usize> {
825    let mut chars = after.chars();
826    let delim = chars.next()?;
827    if delim.is_whitespace() {
828        return None;
829    }
830    let mut consumed = delim.len_utf8();
831    for c in chars {
832        if c == '\n' || c == '\r' {
833            return None;
834        }
835        consumed += c.len_utf8();
836        if c == delim {
837            return Some(consumed);
838        }
839    }
840    None
841}
842
843/// The character argument of `\MakeShortVerb`/`\DeleteShortVerb`, read from the
844/// text following the control word: an optional `*`, inline whitespace, then
845/// `{\c}` or a bare `\c`. Returns `None` when the shape does not match (e.g. at
846/// the command's own definition site, `\def\MakeShortVerb{…`), so a non-call
847/// never toggles. Same-line only — the argument conventionally abuts the call.
848fn short_verb_char(after: &str) -> Option<char> {
849    let s = after.strip_prefix('*').unwrap_or(after);
850    let s = s.trim_start_matches([' ', '\t']);
851    let (body, braced) = match s.strip_prefix('{') {
852        Some(inner) => (inner.trim_start_matches([' ', '\t']), true),
853        None => (s, false),
854    };
855    let arg = body.strip_prefix('\\')?;
856    let c = arg.chars().next()?;
857    if c == '\n' || c == '\r' {
858        return None;
859    }
860    if braced
861        && !arg[c.len_utf8()..]
862            .trim_start_matches([' ', '\t'])
863            .starts_with('}')
864    {
865        return None;
866    }
867    Some(c)
868}
869
870/// Whether the `{name}` argument following `\documentclass`/`\LoadClass` names a
871/// curated documentation class that makes `|` a short verb (`ltxdoc` and `l3doc`
872/// call `\MakeShortVerb` on `\|`; `ltxguide` and `ltnews` define the equivalent
873/// active `|`). A leading `[options]` group is skipped; a trailing `[date]` is
874/// ignored.
875fn doc_class_enables_bar(after: &str) -> bool {
876    let mut s = after.trim_start_matches([' ', '\t']);
877    if let Some(rest) = s.strip_prefix('[') {
878        match rest.find(']') {
879            Some(i) => s = rest[i + 1..].trim_start_matches([' ', '\t', '\n', '\r']),
880            None => return false,
881        }
882    }
883    let Some(rest) = s.strip_prefix('{') else {
884        return false;
885    };
886    let Some(close) = rest.find('}') else {
887        return false;
888    };
889    matches!(
890        rest[..close].trim(),
891        "ltxdoc" | "ltxguide" | "ltnews" | "l3doc" | "amsldoc"
892    )
893}
894
895/// If `rest` starts with `\begin{name}` for a verbatim-like `name`, emit the
896/// `\begin{name}` tokens, then any environment arguments as ordinary tokens, and
897/// finally a single raw body token, returning the bytes consumed (through the body,
898/// up to the closing `\end{name}`).
899///
900/// Arguments are lexed *before* the body because the raw body begins only after
901/// them: in `\begin{minted}{python}`, `{python}` is a structured argument, not body
902/// text. The built-in signature ([`builtin`]) bounds how many leading groups count
903/// as arguments, so a body that legitimately starts with `[` (an option-free
904/// `lstlisting` whose first code line is `[1,2,3]`) is not mistaken for one.
905fn lex_verbatim_environment(rest: &str, ctx: &VerbCtx, out: &mut Vec<Token>) -> Option<usize> {
906    let after_begin = rest.strip_prefix("\\begin{")?;
907    let close = after_begin.find('}')?;
908    let name = &after_begin[..close];
909    // A user-defined catcode-verbatim environment (from `ctx`) wins over the built-in
910    // DB; either way we read only the static leading-argument shape, never macro
911    // meaning. The verbatim args are all leading — the raw body follows them.
912    let args: &[ArgSpec] = match ctx.verbatim_environment_args(name) {
913        Some(args) => args,
914        None => {
915            &builtin()
916                .environment(name)
917                .filter(|e| e.verbatim_body)?
918                .args
919        }
920    };
921
922    let prefix_len = "\\begin{".len() + name.len() + "}".len();
923    out.push(Token {
924        kind: SyntaxKind::CONTROL_WORD,
925        text: SmolStr::new("\\begin"),
926    });
927    out.push(Token {
928        kind: SyntaxKind::L_BRACE,
929        text: SmolStr::new("{"),
930    });
931    out.push(Token {
932        kind: SyntaxKind::WORD,
933        text: SmolStr::new(name),
934    });
935    out.push(Token {
936        kind: SyntaxKind::R_BRACE,
937        text: SmolStr::new("}"),
938    });
939
940    // Locate the argument span, then tokenize it normally. It holds no nested
941    // verbatim-begin, so the ordinary token loop is safe and lets the parser build
942    // the usual OPTIONAL/GROUP argument nodes.
943    let args_region = &rest[prefix_len..];
944    let args_len = scan_verbatim_args(args_region, args);
945    lex_into(&args_region[..args_len], out);
946
947    let body_region = &args_region[args_len..];
948    let end_marker = format!("\\end{{{name}}}");
949    let body_len = body_region.find(&end_marker).unwrap_or(body_region.len());
950    if body_len > 0 {
951        out.push(Token {
952            kind: SyntaxKind::VERBATIM_BODY,
953            text: SmolStr::new(&body_region[..body_len]),
954        });
955    }
956    Some(prefix_len + args_len + body_len)
957}
958
959/// If `rest` starts with `\begin{name}` for an environment whose name argument is
960/// xparse `v`-type (`verbatim_arg` in the curated DB: l3doc's `macro`/`function`/
961/// `variable`, declared `{ O{} +v }`), emit the `\begin{name}` tokens, a leading
962/// `[…]` optional as ordinary tokens, and the name argument as one opaque `VERB`
963/// token, returning the bytes consumed. Both argument forms capture:
964/// - The *delimited* form (`\begin{macro}+\@@_compile_{:+`) captures the whole
965///   delimited span as the `VERB`. Upstream chooses this form precisely when the
966///   name holds unbalanced braces (`\@@_compile_}:`), which would otherwise
967///   corrupt group pairing for the rest of the file. The delimiter must directly
968///   abut and be punctuation that cannot open another argument shape (never `\`,
969///   a brace or bracket, `%`, `*`, or `$`), so an ordinary `\begin{macro}`
970///   followed by prose or code never captures.
971/// - The *braced* form (`\begin{macro}{\]}`) keeps its `{`/`}` as ordinary brace
972///   tokens (the parser still builds the usual name `GROUP`) with the balanced
973///   content between them as the `VERB`: the content is raw data, so a `\]`,
974///   `\(`, or `$` in a name never opens math or draws an orphan-closer
975///   diagnostic (issue #60). Balance tracking skips escaped braces (`\{`, `\}`
976///   are part of a name, not group delimiters).
977///
978/// Same-line only, like `\verb`, in both forms. The parser attaches the abutting
979/// `VERB` or name group into the `BEGIN` node like any verbatim command argument
980/// (`attach_arguments`).
981fn lex_verbatim_arg_environment(rest: &str, out: &mut Vec<Token>) -> Option<usize> {
982    let after_begin = rest.strip_prefix("\\begin{")?;
983    let close = after_begin.find('}')?;
984    let name = &after_begin[..close];
985    builtin().environment(name).filter(|e| e.verbatim_arg)?;
986
987    let prefix_len = "\\begin{".len() + name.len() + "}".len();
988    // A leading `[…]` optional (the `O{}` slot, `\begin{macro}[EXP]+…+`) is
989    // structured, not verbatim; it lexes normally below. Same-line, unnested.
990    let region = &rest[prefix_len..];
991    let mut args_len = 0;
992    if let Some(after) = region.strip_prefix('[') {
993        let i = after.find([']', '\n', '\r'])?;
994        if after.as_bytes()[i] != b']' {
995            return None;
996        }
997        args_len = 1 + i + 1;
998    }
999    let arg_region = &region[args_len..];
1000    let delim = arg_region.chars().next()?;
1001    let braced_content_len = if delim == '{' {
1002        Some(braced_verb_content_len(&arg_region[1..])?)
1003    } else {
1004        if !delim.is_ascii_punctuation()
1005            || matches!(delim, '\\' | '}' | '[' | ']' | '%' | '*' | '$')
1006        {
1007            return None;
1008        }
1009        None
1010    };
1011
1012    out.push(Token {
1013        kind: SyntaxKind::CONTROL_WORD,
1014        text: SmolStr::new("\\begin"),
1015    });
1016    out.push(Token {
1017        kind: SyntaxKind::L_BRACE,
1018        text: SmolStr::new("{"),
1019    });
1020    out.push(Token {
1021        kind: SyntaxKind::WORD,
1022        text: SmolStr::new(name),
1023    });
1024    out.push(Token {
1025        kind: SyntaxKind::R_BRACE,
1026        text: SmolStr::new("}"),
1027    });
1028    lex_into(&region[..args_len], out);
1029    let verb_len = match braced_content_len {
1030        // Braced form: `{` VERB(content) `}` — the braces stay real tokens so
1031        // the parser builds the ordinary name `GROUP`.
1032        Some(content_len) => {
1033            out.push(Token {
1034                kind: SyntaxKind::L_BRACE,
1035                text: SmolStr::new("{"),
1036            });
1037            out.push(Token {
1038                kind: SyntaxKind::VERB,
1039                text: SmolStr::new(&arg_region[1..1 + content_len]),
1040            });
1041            out.push(Token {
1042                kind: SyntaxKind::R_BRACE,
1043                text: SmolStr::new("}"),
1044            });
1045            1 + content_len + 1
1046        }
1047        None => {
1048            let verb_len = delimited_len(arg_region)?;
1049            out.push(Token {
1050                kind: SyntaxKind::VERB,
1051                text: SmolStr::new(&arg_region[..verb_len]),
1052            });
1053            verb_len
1054        }
1055    };
1056    Some(prefix_len + args_len + verb_len)
1057}
1058
1059/// Length of the brace-balanced content of a braced `v`-type name argument,
1060/// starting just past the opening `{`. Same-line only; escaped braces (`\{`,
1061/// `\}`) are name characters, not delimiters. `None` when the closing `}` is
1062/// not on the line (falls back to normal lexing) or the content is empty
1063/// (nothing to capture; a bare `{}` lexes normally).
1064fn braced_verb_content_len(content: &str) -> Option<usize> {
1065    let mut depth = 1usize;
1066    let mut chars = content.char_indices();
1067    while let Some((i, c)) = chars.next() {
1068        match c {
1069            '\\' => {
1070                chars.next()?;
1071            }
1072            '{' => depth += 1,
1073            '}' => {
1074                depth -= 1;
1075                if depth == 0 {
1076                    return (i > 0).then_some(i);
1077                }
1078            }
1079            '\n' | '\r' => return None,
1080            _ => {}
1081        }
1082    }
1083    None
1084}
1085
1086/// A `.dtx` `macrocode` frame line, at a line start: `%␣*\begin{macrocode}` (when
1087/// `want_begin`) or `%␣*\end{macrocode}` (otherwise), with the `*` variant
1088/// accepted. On a match, emit the frame tokens — the `%` margin, the indent
1089/// whitespace, the `\begin`/`\end` control word, and the `{macrocode}` name group —
1090/// and return the bytes consumed (through the closing `}`; the trailing newline
1091/// lexes normally). Returns `None` when `rest` is not the requested frame.
1092///
1093/// Unlike a verbatim environment, the body is *not* captured here: it lexes as
1094/// ordinary code in the main loop (under the package regime). The frame line must
1095/// hold nothing but trailing whitespace after the name group, so a stray
1096/// `\begin{macrocode}{x}` is not mistaken for a frame. The *end* frame also
1097/// tolerates a trailing `%` comment (`%    \end{macrocode}%`, a guard against a
1098/// stray trailing space): doc.sty's terminator is a delimited match on the
1099/// `%    \end{macrocode}` string, so anything after it on the line is doc-layer
1100/// material. A begin frame stays strict — same-line text there is captured into
1101/// the body by `\xmacro@code`, not doc prose.
1102///
1103/// A *begin* frame additionally tolerates indentation before the `%`. In the
1104/// documentation layer `\DocInput` runs under `\MakePercentIgnore`
1105/// (`` \catcode`\%=9 ``, doc.dtx), so a `%` there is an *ignored* character at any
1106/// column and `␣*%␣*\begin{macrocode}` opens a chunk exactly like the column-0
1107/// spelling (multicol.dtx, latex-lab-block.dtx — issue #71). The indent rides as a
1108/// `WHITESPACE` token before the margin, so the line stays lossless and the
1109/// formatter re-pins the frame at column 0. The *end* frame stays column-0 strict:
1110/// inside the body `%` is a comment again, and doc.sty terminates on a delimited
1111/// match against the literal `%    \end{macrocode}` line.
1112fn lex_macrocode_frame(rest: &str, want_begin: bool, out: &mut Vec<Token>) -> Option<usize> {
1113    let indent = if want_begin {
1114        rest.bytes()
1115            .take_while(|&b| b == b' ' || b == b'\t')
1116            .count()
1117    } else {
1118        0
1119    };
1120    let after_pct = rest[indent..].strip_prefix('%')?;
1121    let ws_len = after_pct
1122        .bytes()
1123        .take_while(|&b| b == b' ' || b == b'\t')
1124        .count();
1125    let body = &after_pct[ws_len..];
1126    let (control, open) = if want_begin {
1127        ("\\begin", "\\begin{")
1128    } else {
1129        ("\\end", "\\end{")
1130    };
1131    let after_open = body.strip_prefix(open)?;
1132    let close = after_open.find('}')?;
1133    let name = &after_open[..close];
1134    if name != "macrocode" && name != "macrocode*" {
1135        return None;
1136    }
1137    // The frame line carries nothing but trailing whitespace after `}` — plus,
1138    // on an end frame, an optional `%` comment tail (lexed as an ordinary
1139    // `COMMENT` by the main loop).
1140    let after_close = &after_open[close + 1..];
1141    let trailing = after_close
1142        .bytes()
1143        .take_while(|&b| b == b' ' || b == b'\t')
1144        .count();
1145    let tail = &after_close[trailing..];
1146    let comment_tail = !want_begin && tail.starts_with('%');
1147    if !(tail.is_empty() || tail.starts_with('\n') || tail.starts_with('\r') || comment_tail) {
1148        return None;
1149    }
1150
1151    if indent > 0 {
1152        out.push(Token {
1153            kind: SyntaxKind::WHITESPACE,
1154            text: SmolStr::new(&rest[..indent]),
1155        });
1156    }
1157    out.push(Token {
1158        kind: SyntaxKind::DOC_MARGIN,
1159        text: SmolStr::new("%"),
1160    });
1161    if ws_len > 0 {
1162        out.push(Token {
1163            kind: SyntaxKind::WHITESPACE,
1164            text: SmolStr::new(&after_pct[..ws_len]),
1165        });
1166    }
1167    out.push(Token {
1168        kind: SyntaxKind::CONTROL_WORD,
1169        text: SmolStr::new(control),
1170    });
1171    out.push(Token {
1172        kind: SyntaxKind::L_BRACE,
1173        text: SmolStr::new("{"),
1174    });
1175    out.push(Token {
1176        kind: SyntaxKind::WORD,
1177        text: SmolStr::new(name),
1178    });
1179    out.push(Token {
1180        kind: SyntaxKind::R_BRACE,
1181        text: SmolStr::new("}"),
1182    });
1183    Some(indent + 1 + ws_len + control.len() + 1 + name.len() + 1)
1184}
1185
1186/// If `rest` starts with a verbatim-argument command (`\url`, `\code`,
1187/// `\lstinline`, …), emit its control word, any leading non-verbatim arguments
1188/// (as ordinary tokens), and finally a single raw [`SyntaxKind::VERB`] token for
1189/// the verbatim argument; return the bytes consumed. Returns `None` when `rest`
1190/// is not such a command or no verbatim argument follows (so the caller lexes it
1191/// normally and losslessness is preserved either way).
1192///
1193/// The verbatim argument's form is decided by its first non-blank character,
1194/// matching how these commands actually parse: a brace introduces a balanced
1195/// `{…}` group (`\code{…}`, `\url{…}`); any other character is a `\verb`-style
1196/// delimiter run (`\lstinline|…|`), but only for built-ins whose signature
1197/// grants the delimiter form (`verbatim_delimited`). For braced-only commands —
1198/// `\code`, `\path`, and every scanner-discovered user command — a non-brace
1199/// follower means this occurrence is not a verbatim argument (the name may be an
1200/// unrelated user macro: `\code` as a math operator, TikZ's `\path (0,0)`), so
1201/// we return `None` and lex normally; a missed capture is benign where a wrong
1202/// delimiter capture swallows text across the line. `\verb`/`\verb*` are
1203/// deliberately excluded — they are delimiter-only and handled in
1204/// [`lex_control`]. Like the verbatim environment path, this reads only static
1205/// signature data (decision #1).
1206fn lex_verbatim_command(
1207    rest: &str,
1208    at_letter: bool,
1209    expl_syntax: bool,
1210    ctx: &VerbCtx,
1211    out: &mut Vec<Token>,
1212) -> Option<usize> {
1213    if !rest.starts_with('\\') {
1214        return None;
1215    }
1216    let letters = run_len(&rest[1..], |c| is_letter(c, at_letter, expl_syntax));
1217    if letters == 0 {
1218        return None;
1219    }
1220    let word_len = 1 + letters;
1221    let name = &rest[1..word_len];
1222    // `\verb` keeps its dedicated delimiter-only path.
1223    if name == "verb" {
1224        return None;
1225    }
1226    // A user-defined catcode-verbatim command (from `ctx`) wins over the built-in DB;
1227    // either way we read only the static leading-argument shape, never macro meaning.
1228    // Discovered commands are `\newcommand`-style braced definitions, so they never
1229    // get the delimiter form.
1230    let (leading, delimited): (&[ArgSpec], bool) = match ctx.leading_args(name) {
1231        Some(args) => (args, false),
1232        None => {
1233            // A visible non-verbatim redefinition in this file shadows the built-in, so
1234            // don't capture — lex the braced argument as an ordinary group (issue #53).
1235            if ctx.is_suppressed(name) {
1236                return None;
1237            }
1238            let sig = builtin().command(name).filter(|c| c.verbatim)?;
1239            (&sig.args, sig.verbatim_delimited)
1240        }
1241    };
1242
1243    // Leading arguments precede the verbatim one (e.g. `\mintinline{lang}{code}`).
1244    let after_word = &rest[word_len..];
1245    let args_len = scan_verbatim_args(after_word, leading);
1246
1247    // Skip inline whitespace (never a line break — an argument never crosses a
1248    // newline) to reach the verbatim argument's opening delimiter.
1249    let region = &after_word[args_len..];
1250    let ws_len = region
1251        .bytes()
1252        .take_while(|&b| b == b' ' || b == b'\t')
1253        .count();
1254    let arg_region = &region[ws_len..];
1255    let arg_len = match arg_region.bytes().next() {
1256        Some(b'{') => balanced_group_len(arg_region, b'}')?,
1257        // A `\verb`-style delimiter run: the first character delimits, and the
1258        // argument may not span a line break.
1259        Some(_) if delimited => delimited_len(arg_region)?,
1260        _ => return None,
1261    };
1262
1263    out.push(Token {
1264        kind: SyntaxKind::CONTROL_WORD,
1265        text: SmolStr::new(&rest[..word_len]),
1266    });
1267    lex_into(&after_word[..args_len], out);
1268    if ws_len > 0 {
1269        out.push(Token {
1270            kind: SyntaxKind::WHITESPACE,
1271            text: SmolStr::new(&region[..ws_len]),
1272        });
1273    }
1274    out.push(Token {
1275        kind: SyntaxKind::VERB,
1276        text: SmolStr::new(&arg_region[..arg_len]),
1277    });
1278    Some(word_len + args_len + ws_len + arg_len)
1279}
1280
1281/// Byte length of the argument span that precedes a verbatim body, given the
1282/// environment's declared `args`. For each argument in order, consume any inline
1283/// whitespace (spaces/tabs, never a line break — an argument never crosses a
1284/// newline, so a bracket on the next line is body text) followed by the balanced
1285/// group of the expected delimiter when present. A missing optional or required
1286/// argument is skipped; a malformed (unbalanced) group is left to the body, so the
1287/// scan never runs past the input and losslessness is preserved.
1288fn scan_verbatim_args(region: &str, args: &[ArgSpec]) -> usize {
1289    let bytes = region.as_bytes();
1290    let mut pos = 0;
1291    for arg in args {
1292        let mut probe = pos;
1293        while matches!(bytes.get(probe), Some(b' ' | b'\t')) {
1294            probe += 1;
1295        }
1296        let (open, close) = match arg.kind {
1297            ArgKind::Bracket => (b'[', b']'),
1298            ArgKind::Brace => (b'{', b'}'),
1299        };
1300        if bytes.get(probe) != Some(&open) {
1301            // Argument absent; the skipped whitespace belongs to the body.
1302            continue;
1303        }
1304        match balanced_group_len(&region[probe..], close) {
1305            Some(len) => pos = probe + len,
1306            None => break, // unbalanced: treat the remainder as body
1307        }
1308    }
1309    pos
1310}
1311
1312/// Length in bytes of the balanced group starting at `s[0]` (an `[` or `{`), up to
1313/// and including its matching closer. Brace and bracket nesting is tracked with a
1314/// delimiter stack, so a `]` inside `{…}` (or vice versa) is treated as literal; a
1315/// `\`-escaped delimiter is skipped. Returns `None` if the group never closes.
1316fn balanced_group_len(s: &str, close: u8) -> Option<usize> {
1317    let bytes = s.as_bytes();
1318    let mut stack = vec![close];
1319    let mut i = 1;
1320    while i < bytes.len() {
1321        match bytes[i] {
1322            b'\\' => {
1323                // Skip the escaped byte; a delimiter loses its meaning.
1324                i += 2;
1325                continue;
1326            }
1327            b'{' => stack.push(b'}'),
1328            b'[' => stack.push(b']'),
1329            c @ (b'}' | b']') if stack.last() == Some(&c) => {
1330                stack.pop();
1331                if stack.is_empty() {
1332                    return Some(i + 1);
1333                }
1334            }
1335            // A non-matching closer is literal text; ignore it.
1336            _ => {}
1337        }
1338        i += 1;
1339    }
1340    None
1341}
1342
1343/// Tokenize `region` with the ordinary, context-free token loop, appending to
1344/// `out`. Used for the argument span of a verbatim-like environment, which carries
1345/// no `\makeatletter` or nested verbatim-begin context.
1346fn lex_into(region: &str, out: &mut Vec<Token>) {
1347    let mut pos = 0;
1348    while pos < region.len() {
1349        let (kind, len) = next_token(&region[pos..], false, false);
1350        debug_assert!(len > 0, "lexer made no progress in verbatim args");
1351        out.push(Token {
1352            kind,
1353            text: SmolStr::new(&region[pos..pos + len]),
1354        });
1355        pos += len;
1356    }
1357}
1358
1359/// Number of leading bytes of `s` whose chars all satisfy `pred`.
1360fn run_len(s: &str, pred: impl Fn(char) -> bool) -> usize {
1361    let mut len = 0;
1362    for c in s.chars() {
1363        if pred(c) {
1364            len += c.len_utf8();
1365        } else {
1366            break;
1367        }
1368    }
1369    len
1370}
1371
1372/// A control-word continuation character: a letter, `@` under `\makeatletter`,
1373/// or `_`/`:` under `\ExplSyntaxOn` (where they are catcode-11 letters).
1374fn is_letter(c: char, at_letter: bool, expl_syntax: bool) -> bool {
1375    c.is_ascii_alphabetic() || (at_letter && c == '@') || (expl_syntax && (c == '_' || c == ':'))
1376}
1377
1378/// Ordinary text: anything that is not whitespace, a line break, or one of the
1379/// characters the lexer treats specially.
1380pub fn is_word_char(c: char) -> bool {
1381    !matches!(
1382        c,
1383        '\\' | '%'
1384            | '{'
1385            | '}'
1386            | '['
1387            | ']'
1388            | '$'
1389            | '&'
1390            | '#'
1391            | '^'
1392            | '_'
1393            | '~'
1394            | ' '
1395            | '\t'
1396            | '\n'
1397            | '\r'
1398    )
1399}
1400
1401#[cfg(test)]
1402mod tests {
1403    use super::*;
1404
1405    /// The lexer is total and lossless: concatenated token text == input.
1406    fn assert_lossless(input: &str) {
1407        let joined: String = lex(input).iter().map(|t| t.text.as_str()).collect();
1408        assert_eq!(joined, input);
1409    }
1410
1411    #[test]
1412    fn block_environment_classification() {
1413        assert!(is_block_environment("figure"));
1414        assert!(is_block_environment("itemize")); // derived via `list`
1415        assert!(!is_block_environment("myenv")); // unknown
1416    }
1417
1418    #[test]
1419    fn lossless_on_assorted_inputs() {
1420        for input in [
1421            "",
1422            "plain text",
1423            r"\section{Hi}[x]",
1424            "$a^2_b$",
1425            "a%c\n\nb",
1426            "café ∑ \\\\ \\{ \\,",
1427            "tab\tand  spaces",
1428            "trailing\\",
1429            r"\verb|$x$|",
1430            "\\begin{verbatim}\n$x$ %not a comment\n\\end{verbatim}",
1431            "\\begin{lstlisting}[language=C]\nint a[3];  % raw\n\\end{lstlisting}",
1432            "\\begin{minted}[frame=single]{python}\nprint(\"$x$\")\n\\end{minted}",
1433            "\\begin{lstlisting}\n[1,2,3]\n\\end{lstlisting}",
1434            r"\makeatletter\a@b\makeatother\a@b",
1435            r"\ExplSyntaxOn\seq_new:N \g_@@_x_tl a_b\ExplSyntaxOff\seq_new:N",
1436            r"$\left(x+y\right)^2 \left.\frac{a}{b}\right|_0$",
1437        ] {
1438            assert_lossless(input);
1439        }
1440    }
1441
1442    #[test]
1443    fn control_word_stops_at_non_letter() {
1444        let toks = lex(r"\alpha2");
1445        assert_eq!(toks[0].kind, SyntaxKind::CONTROL_WORD);
1446        assert_eq!(toks[0].text, "\\alpha");
1447        assert_eq!(toks[1].kind, SyntaxKind::WORD);
1448        assert_eq!(toks[1].text, "2");
1449    }
1450
1451    #[test]
1452    fn double_backslash_is_one_control_symbol() {
1453        let toks = lex(r"\\");
1454        assert_eq!(toks.len(), 1);
1455        assert_eq!(toks[0].kind, SyntaxKind::CONTROL_SYMBOL);
1456        assert_eq!(toks[0].text, r"\\");
1457    }
1458
1459    #[test]
1460    fn comment_stops_before_newline() {
1461        let toks = lex("% hi\nx");
1462        assert_eq!(toks[0].kind, SyntaxKind::COMMENT);
1463        assert_eq!(toks[0].text, "% hi");
1464        assert_eq!(toks[1].kind, SyntaxKind::NEWLINE);
1465    }
1466
1467    #[test]
1468    fn crlf_is_a_single_newline() {
1469        let toks = lex("a\r\nb");
1470        assert_eq!(toks[1].kind, SyntaxKind::NEWLINE);
1471        assert_eq!(toks[1].text, "\r\n");
1472    }
1473
1474    #[test]
1475    fn verb_inline_is_one_token() {
1476        let toks = lex(r"\verb|$x$|");
1477        assert_eq!(toks.len(), 1);
1478        assert_eq!(toks[0].kind, SyntaxKind::VERB);
1479        assert_eq!(toks[0].text, r"\verb|$x$|");
1480    }
1481
1482    #[test]
1483    fn verb_star_with_plus_delimiter() {
1484        let toks = lex(r"a\verb*+b+c");
1485        assert_eq!(toks[1].kind, SyntaxKind::VERB);
1486        assert_eq!(toks[1].text, r"\verb*+b+");
1487        assert_eq!(toks[2].text, "c");
1488    }
1489
1490    #[test]
1491    fn verb_without_closing_delimiter_is_a_plain_control_word() {
1492        let toks = lex(r"\verb|x");
1493        assert_eq!(toks[0].kind, SyntaxKind::CONTROL_WORD);
1494        assert_eq!(toks[0].text, r"\verb");
1495    }
1496
1497    #[test]
1498    fn left_right_isolate_word_delimiter() {
1499        // `(` would normally glue into `(x+y` as one word; after `\left` it is
1500        // its own one-character token, and `\right)`'s `)` likewise.
1501        let toks = lex(r"\left(x+y\right)");
1502        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1503        assert_eq!(
1504            seen,
1505            [
1506                (SyntaxKind::CONTROL_WORD, "\\left"),
1507                (SyntaxKind::WORD, "("),
1508                (SyntaxKind::WORD, "x+y"),
1509                (SyntaxKind::CONTROL_WORD, "\\right"),
1510                (SyntaxKind::WORD, ")"),
1511            ]
1512        );
1513    }
1514
1515    #[test]
1516    fn left_delimiter_carries_across_whitespace() {
1517        // TeX skips spaces before the delimiter; the mode persists so `(` is
1518        // still isolated.
1519        let toks = lex(r"\left ( a");
1520        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1521        assert_eq!(
1522            seen,
1523            [
1524                (SyntaxKind::CONTROL_WORD, "\\left"),
1525                (SyntaxKind::WHITESPACE, " "),
1526                (SyntaxKind::WORD, "("),
1527                (SyntaxKind::WHITESPACE, " "),
1528                (SyntaxKind::WORD, "a"),
1529            ]
1530        );
1531    }
1532
1533    #[test]
1534    fn left_non_word_delimiters_are_untouched() {
1535        // A control-symbol (`\{`), control-word (`\langle`), or bracket delimiter
1536        // already lexes as a single token, so the mode changes nothing.
1537        for input in [r"\left\{", r"\left\langle", r"\left["] {
1538            assert_lossless(input);
1539        }
1540        let toks = lex(r"\left\langle x \right\rangle");
1541        assert!(toks.iter().any(|t| t.text == "\\langle"));
1542        assert!(toks.iter().any(|t| t.text == "\\rangle"));
1543    }
1544
1545    #[test]
1546    fn leftarrow_is_not_left() {
1547        // The maximal letter run keeps `\leftarrow` one control word, so the
1548        // delimiter mode never triggers.
1549        let toks = lex(r"\leftarrow(x)");
1550        assert_eq!(toks[0].text, "\\leftarrow");
1551        // `(x)` glues normally — the mode did not fire.
1552        assert_eq!(toks[1].text, "(x)");
1553    }
1554
1555    #[test]
1556    fn makeatletter_makes_at_a_letter() {
1557        let toks = lex(r"\makeatletter\foo@bar\makeatother\foo@bar");
1558        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1559        // Under \makeatletter, `\foo@bar` is one control word…
1560        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo@bar")));
1561        // …after \makeatother it splits into `\foo` + `@bar`.
1562        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
1563    }
1564
1565    #[test]
1566    fn expl_syntax_makes_underscore_and_colon_letters() {
1567        let toks = lex(r"\ExplSyntaxOn\seq_new:N\ExplSyntaxOff\seq_new:N");
1568        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1569        // Under \ExplSyntaxOn, `\seq_new:N` is one control word…
1570        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
1571        // …after \ExplSyntaxOff it stops at the first `_`.
1572        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq")));
1573    }
1574
1575    #[test]
1576    fn expl_syntax_lexes_internal_double_underscore_name() {
1577        let toks = lex(r"\ExplSyntaxOn\__module_internal:nn");
1578        assert_eq!(toks[1].kind, SyntaxKind::CONTROL_WORD);
1579        assert_eq!(toks[1].text, "\\__module_internal:nn");
1580    }
1581
1582    #[test]
1583    fn provides_expl_package_turns_on_expl_syntax() {
1584        let toks = lex(r"\ProvidesExplPackage{p}{2026/01/01}{1.0}{d}\tl_set:Nn");
1585        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1586        // The `\ProvidesExplPackage` declaration opens expl3 syntax, so the later
1587        // `\tl_set:Nn` lexes as one control word.
1588        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\tl_set:Nn")));
1589    }
1590
1591    #[test]
1592    fn expl_syntax_composes_with_makeatletter() {
1593        // The `@@` module-prefix convention needs both `@` and `_`/`:` as letters.
1594        let toks = lex(r"\makeatletter\ExplSyntaxOn\g_@@_frame_title_tl");
1595        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1596        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\g_@@_frame_title_tl")));
1597    }
1598
1599    #[test]
1600    fn expl_syntax_makes_bare_underscore_a_word_not_subscript() {
1601        let toks = lex(r"\ExplSyntaxOn a_b");
1602        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1603        // Under expl3, `_` is a catcode-11 letter: `a_b` is one word, no UNDERSCORE.
1604        assert!(seen.contains(&(SyntaxKind::WORD, "a_b")));
1605        assert!(!seen.iter().any(|(k, _)| *k == SyntaxKind::UNDERSCORE));
1606    }
1607
1608    /// Lex `input` under the docstrip (`.dtx`) config, the regime in which
1609    /// implicit expl3 applies.
1610    fn lex_dtx(input: &str) -> Vec<Token> {
1611        lex_with(
1612            input,
1613            &VerbCtx::default(),
1614            LexConfig {
1615                flavor: LatexFlavor::Document,
1616                dtx: true,
1617            },
1618        )
1619    }
1620
1621    #[test]
1622    fn implicit_expl_module_guard_makes_macrocode_body_expl3() {
1623        // A toggle-less `.dtx` with only a `%<@@=mod>` module guard: its macrocode
1624        // body is expl3 code, so `\seq_new:N` lexes as one control word.
1625        let toks = lex_dtx(
1626            "%<@@=mod>\n\
1627             %    \\begin{macrocode}\n\
1628             \\seq_new:N\n\
1629             %    \\end{macrocode}\n",
1630        );
1631        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1632        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
1633    }
1634
1635    #[test]
1636    fn no_expl_signal_leaves_macrocode_body_plain() {
1637        // The same shape without a signal: `.dtx` macrocode is plain code, so
1638        // `\seq_new:N` stops at the first `_` (the feature is opt-in).
1639        let toks = lex_dtx(
1640            "%    \\begin{macrocode}\n\
1641             \\seq_new:N\n\
1642             %    \\end{macrocode}\n",
1643        );
1644        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1645        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq")));
1646        assert!(!seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
1647    }
1648
1649    #[test]
1650    fn implicit_expl_provides_expl_flags_every_body_regardless_of_order() {
1651        // `\ProvidesExplPackage` is a whole-file signal, so a macrocode body
1652        // *above* the declaration is expl3 too — the property left-to-right
1653        // toggling misses.
1654        let toks = lex_dtx(
1655            "%    \\begin{macrocode}\n\
1656             \\seq_new:N\n\
1657             %    \\end{macrocode}\n\
1658             % \\ProvidesExplPackage{p}{2026/01/01}{1.0}{d}\n\
1659             %    \\begin{macrocode}\n\
1660             \\tl_set:Nn\n\
1661             %    \\end{macrocode}\n",
1662        );
1663        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1664        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
1665        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\tl_set:Nn")));
1666    }
1667
1668    #[test]
1669    fn implicit_expl_is_body_only_doc_layer_stays_plain() {
1670        // Implicit expl3 is forced inside the macrocode body and restored on exit,
1671        // so the doc-margin line between/around bodies is ordinary LaTeX: `a_b`
1672        // joins in the body but splits on the doc line.
1673        let toks = lex_dtx(
1674            "%<@@=mod>\n\
1675             % a_b\n\
1676             %    \\begin{macrocode}\n\
1677             c_d\n\
1678             %    \\end{macrocode}\n",
1679        );
1680        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1681        // Body: `_` is a letter, one word.
1682        assert!(seen.contains(&(SyntaxKind::WORD, "c_d")));
1683        // Doc layer: `_` stays a subscript, so `a_b` splits.
1684        assert!(seen.iter().any(|(k, _)| *k == SyntaxKind::UNDERSCORE));
1685    }
1686
1687    #[test]
1688    fn implicit_expl_explicit_off_wins_then_next_body_re_enters() {
1689        // An explicit `\ExplSyntaxOff` inside an implicit body turns expl off for
1690        // the rest of that body; the next body still re-enters expl (the
1691        // save/restore restores the pre-body state, not the toggled-off one).
1692        let toks = lex_dtx(
1693            "%<@@=mod>\n\
1694             %    \\begin{macrocode}\n\
1695             \\seq_new:N\n\
1696             \\ExplSyntaxOff\n\
1697             a_b\n\
1698             %    \\end{macrocode}\n\
1699             %    \\begin{macrocode}\n\
1700             \\tl_set:Nn\n\
1701             %    \\end{macrocode}\n",
1702        );
1703        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1704        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
1705        // After the explicit off, `a_b` splits.
1706        assert!(seen.iter().any(|(k, _)| *k == SyntaxKind::UNDERSCORE));
1707        // The second body re-enters expl despite the earlier off.
1708        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\tl_set:Nn")));
1709    }
1710
1711    #[test]
1712    fn implicit_expl_gated_off_outside_dtx() {
1713        // The signal only fires under `.dtx` mode: a `.sty` with the same bytes
1714        // must not enable implicit expl (there are no macrocode bodies anyway).
1715        let toks = lex_with(
1716            "%<@@=mod>\n\\seq_new:N",
1717            &VerbCtx::default(),
1718            LexConfig {
1719                flavor: LatexFlavor::Package,
1720                dtx: false,
1721            },
1722        );
1723        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1724        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq")));
1725        assert!(!seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
1726    }
1727
1728    #[test]
1729    fn package_flavor_starts_in_letter_mode() {
1730        // A `.sty`/`.cls` is loaded under an implicit `\makeatletter`, so `@` is a
1731        // letter from the first byte — `\foo@bar` is one control word with no
1732        // explicit `\makeatletter`.
1733        let toks = lex_with(
1734            r"\foo@bar",
1735            &VerbCtx::default(),
1736            LatexFlavor::Package.into(),
1737        );
1738        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1739        assert_eq!(seen, vec![(SyntaxKind::CONTROL_WORD, "\\foo@bar")]);
1740    }
1741
1742    #[test]
1743    fn package_flavor_respects_trailing_makeatother() {
1744        // Letter-mode starts on, but an explicit `\makeatother` still turns it off.
1745        let toks = lex_with(
1746            r"\foo@bar\makeatother\foo@bar",
1747            &VerbCtx::default(),
1748            LatexFlavor::Package.into(),
1749        );
1750        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1751        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo@bar")));
1752        // After \makeatother the second occurrence splits into `\foo` + `@bar`.
1753        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
1754    }
1755
1756    #[test]
1757    fn document_flavor_keeps_at_non_letter() {
1758        // The default `.tex` flavor does not start in letter-mode.
1759        let toks = lex(r"\foo@bar");
1760        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1761        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
1762        assert!(!seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo@bar")));
1763    }
1764
1765    #[test]
1766    fn dtx_mode_lexes_line_leading_percent_as_a_margin() {
1767        // A line-leading `%` is a one-byte `DOC_MARGIN`; the rest of the doc line
1768        // lexes as ordinary LaTeX. A `%` not in column 0 stays a `COMMENT`.
1769        let dtx = LexConfig {
1770            flavor: LatexFlavor::Document,
1771            dtx: true,
1772        };
1773        let toks = lex_with("% \\foo\nbar % tail\n", &VerbCtx::default(), dtx);
1774        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1775        assert_eq!(seen[0], (SyntaxKind::DOC_MARGIN, "%"));
1776        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
1777        assert!(seen.contains(&(SyntaxKind::COMMENT, "% tail")));
1778        // Exactly one margin (column 0 of the first line only).
1779        assert_eq!(
1780            seen.iter()
1781                .filter(|(k, _)| *k == SyntaxKind::DOC_MARGIN)
1782                .count(),
1783            1
1784        );
1785    }
1786
1787    #[test]
1788    fn dtx_mode_is_off_by_default_for_margins_and_guards() {
1789        // Without the docstrip flag a `%` line stays a comment (plain `.tex`); a
1790        // `%<…>` guard likewise stays a single comment.
1791        let plain = lex("% \\foo\n");
1792        assert_eq!(plain[0].kind, SyntaxKind::COMMENT);
1793        let plain_guard = lex("%<*driver>\n");
1794        assert_eq!(plain_guard[0].kind, SyntaxKind::COMMENT);
1795        assert_eq!(plain_guard[0].text, "%<*driver>");
1796    }
1797
1798    #[test]
1799    fn dtx_mode_lexes_line_leading_guards() {
1800        let dtx = LexConfig {
1801            flavor: LatexFlavor::Document,
1802            dtx: true,
1803        };
1804        // `%<*tag>` / `%</tag>` block delimiters are single `GUARD` tokens.
1805        let block = lex_with("%<*driver>\n%</driver>\n", &VerbCtx::default(), dtx);
1806        assert_eq!(block[0].kind, SyntaxKind::GUARD);
1807        assert_eq!(block[0].text, "%<*driver>");
1808        assert!(
1809            block
1810                .iter()
1811                .any(|t| t.kind == SyntaxKind::GUARD && t.text == "%</driver>")
1812        );
1813        // An inline `%<tag>` is a `GUARD` prefix; the rest of the line lexes as code.
1814        let inline = lex_with("%<plain>\\RequirePackage{x}\n", &VerbCtx::default(), dtx);
1815        assert_eq!(inline[0].kind, SyntaxKind::GUARD);
1816        assert_eq!(inline[0].text, "%<plain>");
1817        assert!(
1818            inline
1819                .iter()
1820                .any(|t| t.kind == SyntaxKind::CONTROL_WORD && t.text == "\\RequirePackage")
1821        );
1822        // A boolean tag expression stays one token (through the closing `>`).
1823        let expr = lex_with("%<*package|driver>\n", &VerbCtx::default(), dtx);
1824        assert_eq!(expr[0].kind, SyntaxKind::GUARD);
1825        assert_eq!(expr[0].text, "%<*package|driver>");
1826        // A guard recognized only at column 0: a mid-line `%<…>` stays a comment.
1827        let midline = lex_with("a %<x>\n", &VerbCtx::default(), dtx);
1828        assert!(
1829            midline
1830                .iter()
1831                .any(|t| t.kind == SyntaxKind::COMMENT && t.text == "%<x>")
1832        );
1833        assert!(!midline.iter().any(|t| t.kind == SyntaxKind::GUARD));
1834        // A `%<` with no closing `>` before the line ends is not a guard.
1835        let malformed = lex_with("%<unterminated\n", &VerbCtx::default(), dtx);
1836        assert_eq!(malformed[0].kind, SyntaxKind::COMMENT);
1837        assert_eq!(malformed[0].text, "%<unterminated");
1838    }
1839
1840    #[test]
1841    fn verbatim_environment_body_is_one_raw_token() {
1842        let toks = lex("\\begin{verbatim}\n$not$ %literal\n\\end{verbatim}");
1843        assert_eq!(toks[0].text, "\\begin");
1844        assert_eq!(toks[2].text, "verbatim");
1845        assert!(
1846            toks.iter()
1847                .any(|t| t.kind == SyntaxKind::VERBATIM_BODY && t.text.contains("$not$ %literal"))
1848        );
1849        // Nothing inside the body was lexed as math or a comment.
1850        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::DOLLAR));
1851        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::COMMENT));
1852    }
1853
1854    #[test]
1855    fn argument_taking_verbatim_separates_args_from_body() {
1856        // `minted` declares `[opt]{req}`: both groups are tokenized normally, then
1857        // the rest is one raw body token.
1858        let toks = lex("\\begin{minted}[frame=single]{python}\nprint(\"$x$\")\n\\end{minted}");
1859        let kinds: Vec<_> = toks.iter().map(|t| t.kind).collect();
1860        // The optional and required argument delimiters survive as ordinary tokens…
1861        assert!(kinds.contains(&SyntaxKind::L_BRACKET));
1862        assert!(kinds.contains(&SyntaxKind::R_BRACKET));
1863        assert!(kinds.contains(&SyntaxKind::L_BRACE));
1864        // …and the body (with its `$`) is a single opaque token, not math.
1865        assert!(
1866            toks.iter()
1867                .any(|t| t.kind == SyntaxKind::VERBATIM_BODY && t.text.contains("print(\"$x$\")"))
1868        );
1869        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::DOLLAR));
1870    }
1871
1872    #[test]
1873    fn verbatim_body_starting_with_bracket_is_not_an_argument() {
1874        // `lstlisting`'s lone optional argument is absent (a newline separates the
1875        // `\begin` from the `[`), so `[1,2,3]` stays inside the raw body.
1876        let toks = lex("\\begin{lstlisting}\n[1,2,3]\n\\end{lstlisting}");
1877        assert!(
1878            !toks
1879                .iter()
1880                .take_while(|t| t.kind != SyntaxKind::VERBATIM_BODY)
1881                .any(|t| t.kind == SyntaxKind::L_BRACKET),
1882            "the bracket on the body's first line must not be lexed as an argument"
1883        );
1884        assert!(
1885            toks.iter()
1886                .any(|t| t.kind == SyntaxKind::VERBATIM_BODY && t.text.contains("[1,2,3]"))
1887        );
1888    }
1889
1890    #[test]
1891    fn make_short_verb_toggles_pipe_capture() {
1892        // Before the toggle a `|…|` is ordinary text; after `\MakeShortVerb{\|}`
1893        // it captures as one opaque `VERB`; `\DeleteShortVerb{\|}` turns it off.
1894        let toks = lex("|a| \\MakeShortVerb{\\|} |$| \\DeleteShortVerb{\\|} |b|");
1895        let verbs: Vec<_> = toks
1896            .iter()
1897            .filter(|t| t.kind == SyntaxKind::VERB)
1898            .map(|t| t.text.as_str())
1899            .collect();
1900        assert_eq!(verbs, ["|$|"]);
1901        assert_lossless("|a| \\MakeShortVerb{\\|} |$| \\DeleteShortVerb{\\|} |b|");
1902    }
1903
1904    #[test]
1905    fn documentclass_ltxguide_enables_the_pipe_short_verb() {
1906        // The curated doc classes (`ltxdoc`, `ltxguide`, `ltnews`, `l3doc`,
1907        // `amsldoc`) make `|` a short verb themselves, so loading one enables the
1908        // capture — options and trailing release dates included. `amsldoc` does it
1909        // with an active `|` (`\\gdef|{\\protect\\activevert{}}`, amsldoc.cls),
1910        // like `ltxguide`/`ltnews`; without it amsldoc.tex's `|\\begin{alignat}|`
1911        // prose read as real structure (issue #71).
1912        for preamble in [
1913            "\\documentclass{ltxguide}",
1914            "\\documentclass[a4paper]{ltxdoc}",
1915            "\\documentclass{ltxguide}[1994/11/20]",
1916            "\\documentclass{l3doc}",
1917            "\\documentclass[leqno,titlepage]{amsldoc}[1999/12/13]",
1918        ] {
1919            let input = format!("{preamble}\n|}}| done");
1920            let toks = lex(&input);
1921            assert!(
1922                toks.iter()
1923                    .any(|t| t.kind == SyntaxKind::VERB && t.text == "|}|"),
1924                "no VERB captured after {preamble}"
1925            );
1926        }
1927        // An unrelated class leaves `|` alone.
1928        let toks = lex("\\documentclass{article}\n|x| done");
1929        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::VERB));
1930    }
1931
1932    #[test]
1933    fn short_verb_never_captures_a_left_right_delimiter() {
1934        // `\left|x\right|` in math: the bars are delimiters, not a verb span.
1935        let toks = lex("\\MakeShortVerb{\\|} $\\left|x\\right|$");
1936        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::VERB));
1937        assert_lossless("\\MakeShortVerb{\\|} $\\left|x\\right|$");
1938    }
1939
1940    #[test]
1941    fn unclosed_short_verb_char_stands_alone() {
1942        // With no closing partner on the line, the enabled char is a lone
1943        // one-character word (never gluing into the following text).
1944        let toks = lex("\\MakeShortVerb{\\|} a|b\nc");
1945        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::VERB));
1946        assert!(
1947            toks.iter()
1948                .any(|t| t.kind == SyntaxKind::WORD && t.text == "|")
1949        );
1950        assert_lossless("\\MakeShortVerb{\\|} a|b\nc");
1951    }
1952}