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