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