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