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 = ®ion[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(®ion[..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 = ®ion[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(®ion[..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(®ion[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(®ion[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(®ion[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}