Skip to main content

badness_parser/parser/
lexer.rs

1//! Lossless lexer for LaTeX surface syntax.
2//!
3//! The lexer recognizes only bounded modes whose delimiters are visible in the
4//! source, including verbatim regions and explicit catcode toggles.
5
6use std::collections::{HashMap, HashSet};
7
8use smol_str::SmolStr;
9
10use crate::semantic::signature::{ArgKind, ArgSpec, EnvironmentSig, builtin};
11use crate::syntax::SyntaxKind;
12
13/// A single lexed token: its kind plus the exact source slice it covers.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct Token {
16    pub kind: SyntaxKind,
17    pub text: SmolStr,
18}
19
20/// The LaTeX file flavor, fixing the lexer's *initial* catcode regime. A
21/// document (`.tex`) starts in the ordinary regime; a package or class
22/// (`.sty`/`.cls`) is loaded under an implicit `\makeatletter`, so `@` is a
23/// letter from the first byte. A trailing explicit `\makeatother` still applies.
24#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
25pub enum LatexFlavor {
26    /// A `.tex` document: ordinary catcodes at the start.
27    #[default]
28    Document,
29    /// A `.sty`/`.cls` package or class: `@` is a letter from the start.
30    Package,
31}
32
33impl LatexFlavor {
34    /// Whether the lexer should begin with `@` already a letter (the implicit
35    /// `\makeatletter` of a package/class load).
36    fn letter_mode_start(self) -> bool {
37        matches!(self, LatexFlavor::Package)
38    }
39}
40
41/// The lexer's per-parse mode. [`flavor`](Self::flavor) fixes the *initial*
42/// catcode regime (a `.sty`/`.cls` starts under an implicit `\makeatletter`),
43/// while [`dtx`](Self::dtx) is an orthogonal axis: when set, the lexer runs the
44/// bounded line-oriented docstrip mode for a `.dtx` file — line-leading `%`
45/// margins become [`DOC_MARGIN`](SyntaxKind::DOC_MARGIN) trivia, line-leading
46/// `%<…>` guards become [`GUARD`](SyntaxKind::GUARD) trivia, and `macrocode`
47/// bodies lex as ordinary code (`AGENTS.md` decision #1). The two axes are
48/// independent because a `.dtx`'s catcode regime varies *by layer* (its
49/// documentation is `Document`-flavored, its `macrocode` `Package`-flavored), so
50/// `dtx` cannot be folded into a [`LatexFlavor`] variant.
51#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
52pub struct LexConfig {
53    /// The initial catcode regime.
54    pub flavor: LatexFlavor,
55    /// Run the docstrip (`.dtx`) line-oriented lexer mode.
56    pub dtx: bool,
57}
58
59impl From<LatexFlavor> for LexConfig {
60    /// A plain (non-`.dtx`) config of the given flavor — the common case, so a
61    /// bare [`LatexFlavor`] coerces into a [`LexConfig`] at call sites.
62    fn from(flavor: LatexFlavor) -> Self {
63        Self { flavor, dtx: false }
64    }
65}
66
67/// Per-parse facts discovered from definitions or project declarations.
68#[derive(Debug, Default, Clone, PartialEq, Eq)]
69pub struct ParseCtx {
70    commands: HashMap<SmolStr, Vec<ArgSpec>>,
71    environments: HashMap<SmolStr, Vec<ArgSpec>>,
72    suppressed: HashSet<SmolStr>,
73    begin_aliases: HashMap<SmolStr, SmolStr>,
74    end_aliases: HashMap<SmolStr, SmolStr>,
75    declared_environments: HashMap<SmolStr, EnvironmentSig>,
76}
77
78/// Backward-compatible alias for [`ParseCtx`].
79pub type VerbCtx = ParseCtx;
80
81impl ParseCtx {
82    /// Returns whether the context contains no discovered or declared facts.
83    pub fn is_empty(&self) -> bool {
84        self.commands.is_empty()
85            && self.environments.is_empty()
86            && self.suppressed.is_empty()
87            && self.begin_aliases.is_empty()
88            && self.end_aliases.is_empty()
89            && self.declared_environments.is_empty()
90    }
91
92    /// Applies project declarations over discovered facts.
93    pub fn overlay_declarations(&mut self, declared: &crate::declarations::ResolvedDeclarations) {
94        let db = declared.as_db();
95        for name in db.environment_names() {
96            if let Some(sig) = db.environment(name) {
97                self.declared_environments
98                    .insert(SmolStr::new(name), sig.clone());
99            }
100        }
101        for (name, target) in db.env_begin_aliases() {
102            self.insert_begin_alias(SmolStr::new(name), SmolStr::new(target));
103        }
104        for (name, target) in db.env_end_aliases() {
105            self.insert_end_alias(SmolStr::new(name), SmolStr::new(target));
106        }
107    }
108
109    pub(crate) fn insert(&mut self, name: SmolStr, leading: Vec<ArgSpec>) {
110        self.commands.insert(name, leading);
111    }
112
113    pub(crate) fn suppress(&mut self, name: SmolStr) {
114        self.suppressed.insert(name);
115    }
116
117    fn is_suppressed(&self, name: &str) -> bool {
118        self.suppressed.contains(name)
119    }
120
121    pub(crate) fn insert_environment(&mut self, name: SmolStr, args: Vec<ArgSpec>) {
122        self.environments.insert(name, args);
123    }
124
125    fn leading_args(&self, name: &str) -> Option<&[ArgSpec]> {
126        self.commands.get(name).map(Vec::as_slice)
127    }
128
129    fn verbatim_environment_args(&self, name: &str) -> Option<&[ArgSpec]> {
130        if let Some(sig) = self.declared_environment(name) {
131            return sig.verbatim_body.then(|| &*sig.args);
132        }
133        self.environments.get(name).map(Vec::as_slice)
134    }
135
136    pub(crate) fn is_verbatim_environment(&self, name: &str) -> bool {
137        match self.declared_environment(name) {
138            Some(sig) => sig.verbatim_body,
139            None => {
140                self.environments.contains_key(name)
141                    || builtin()
142                        .environment(name)
143                        .is_some_and(|env| env.verbatim_body)
144            }
145        }
146    }
147
148    pub(crate) fn insert_begin_alias(&mut self, name: SmolStr, target: SmolStr) {
149        self.begin_aliases.insert(name, target);
150    }
151
152    pub(crate) fn insert_end_alias(&mut self, name: SmolStr, target: SmolStr) {
153        self.end_aliases.insert(name, target);
154    }
155
156    pub(crate) fn begin_alias(&self, name: &str) -> Option<&str> {
157        self.begin_aliases.get(name).map(SmolStr::as_str)
158    }
159
160    pub(crate) fn end_alias(&self, name: &str) -> Option<&str> {
161        self.end_aliases.get(name).map(SmolStr::as_str)
162    }
163
164    pub(crate) fn begin_alias_targets(&self) -> impl Iterator<Item = &str> {
165        self.begin_aliases.values().map(SmolStr::as_str)
166    }
167
168    fn declared_environment(&self, name: &str) -> Option<&EnvironmentSig> {
169        self.declared_environments.get(name)
170    }
171
172    pub(crate) fn is_block_environment(&self, name: &str) -> bool {
173        match self.declared_environment(name) {
174            Some(sig) => sig.block(),
175            None => builtin()
176                .environment(name)
177                .is_some_and(EnvironmentSig::block),
178        }
179    }
180
181    pub(crate) fn is_math_environment(&self, name: &str) -> bool {
182        match self.declared_environment(name) {
183            Some(sig) => sig.math,
184            None => builtin().environment(name).is_some_and(|env| env.math),
185        }
186    }
187
188    pub(crate) fn is_statement_environment(&self, name: &str) -> bool {
189        match self.declared_environment(name) {
190            Some(sig) => sig.statement_body,
191            None => builtin()
192                .environment(name)
193                .is_some_and(|env| env.statement_body),
194        }
195    }
196
197    pub(crate) fn has_env_aliases(&self) -> bool {
198        !self.begin_aliases.is_empty() || !self.end_aliases.is_empty()
199    }
200}
201
202/// Returns whether a control word introduces a definition.
203pub(crate) fn is_definition_keyword(text: &str) -> bool {
204    matches!(
205        text,
206        "\\newcommand"
207            | "\\renewcommand"
208            | "\\providecommand"
209            | "\\DeclareRobustCommand"
210            | "\\NewDocumentCommand"
211            | "\\RenewDocumentCommand"
212            | "\\ProvideDocumentCommand"
213            | "\\DeclareDocumentCommand"
214            | "\\def"
215            | "\\edef"
216            | "\\gdef"
217            | "\\xdef"
218            | "\\let"
219    )
220}
221
222/// Returns the number of following control words claimed as definition names.
223pub(crate) fn definition_name_slots(text: &str) -> u8 {
224    match text {
225        "\\let" => 2,
226        _ if is_definition_keyword(text) => 1,
227        _ => 0,
228    }
229}
230
231fn is_literal_token_command(text: &str) -> bool {
232    matches!(
233        text,
234        "\\string" | "\\noexpand" | "\\meaning" | "\\expandafter" | "\\show"
235    )
236}
237
238fn is_char_constant_command(text: &str) -> bool {
239    matches!(
240        text,
241        "\\char"
242            | "\\catcode"
243            | "\\lccode"
244            | "\\uccode"
245            | "\\sfcode"
246            | "\\mathcode"
247            | "\\delcode"
248            | "\\number"
249            | "\\the"
250            | "\\romannumeral"
251            | "\\numexpr"
252            | "\\dimexpr"
253            | "\\ifnum"
254            | "\\ifodd"
255            | "\\ifdim"
256    )
257}
258
259#[derive(Debug, Clone, Copy, PartialEq, Eq)]
260/// An expl3 lexer-mode transition.
261pub enum ExplToggle {
262    On,
263    Off,
264}
265
266/// Returns the expl3 transition named by a control word.
267pub fn expl_toggle(text: &str) -> Option<ExplToggle> {
268    match text {
269        "\\ExplSyntaxOn"
270        | "\\ProvidesExplPackage"
271        | "\\ProvidesExplClass"
272        | "\\ProvidesExplFile" => Some(ExplToggle::On),
273        "\\ExplSyntaxOff" => Some(ExplToggle::Off),
274        _ => None,
275    }
276}
277
278pub(crate) fn dtx_has_expl_signal(input: &str) -> bool {
279    input.contains("\\ProvidesExpl")
280        || input
281            .lines()
282            .any(|l| l.starts_with("%<@@=") && l[5..].contains('>'))
283}
284
285/// Lexes a document into a lossless token stream.
286pub fn lex(input: &str) -> Vec<Token> {
287    lex_with(input, &ParseCtx::default(), LexConfig::default())
288}
289
290/// Lexes with per-parse facts and an explicit file configuration.
291pub fn lex_with(input: &str, ctx: &ParseCtx, config: LexConfig) -> Vec<Token> {
292    Lexer::new(input, ctx, config, None).run()
293}
294
295pub(crate) fn lex_with_implicit_expl(
296    input: &str,
297    ctx: &ParseCtx,
298    config: LexConfig,
299    implicit_expl: bool,
300) -> Vec<Token> {
301    Lexer::new(input, ctx, config, Some(implicit_expl)).run()
302}
303
304#[derive(Debug, Clone, Copy, PartialEq, Eq)]
305enum Pending {
306    Delim,
307    Def,
308    CharConstant,
309    LiteralToken,
310}
311
312#[derive(Debug, Clone, Copy)]
313struct MacrocodeSave {
314    at_letter: bool,
315    expl_syntax: bool,
316}
317
318struct Lexer<'a> {
319    input: &'a str,
320    ctx: &'a ParseCtx,
321    config: LexConfig,
322    implicit_expl: bool,
323    out: Vec<Token>,
324    pos: usize,
325    at_letter: bool,
326    expl_syntax: bool,
327    at_line_start: bool,
328    in_doc_line: bool,
329    short_verbs: Vec<char>,
330    macrocode: Option<MacrocodeSave>,
331    pending: Option<Pending>,
332    brace_depth: usize,
333    brace_counted: usize,
334}
335
336impl<'a> Lexer<'a> {
337    fn new(
338        input: &'a str,
339        ctx: &'a ParseCtx,
340        config: LexConfig,
341        implicit_expl_override: Option<bool>,
342    ) -> Self {
343        let implicit_expl = if config.dtx {
344            implicit_expl_override.unwrap_or_else(|| dtx_has_expl_signal(input))
345        } else {
346            false
347        };
348        Self {
349            input,
350            ctx,
351            config,
352            implicit_expl,
353            out: Vec::new(),
354            pos: 0,
355            at_letter: config.flavor.letter_mode_start(),
356            expl_syntax: false,
357            at_line_start: true,
358            in_doc_line: false,
359            short_verbs: if config.dtx { vec!['|'] } else { Vec::new() },
360            macrocode: None,
361            pending: None,
362            brace_depth: 0,
363            brace_counted: 0,
364        }
365    }
366
367    fn run(mut self) -> Vec<Token> {
368        while self.pos < self.input.len() {
369            self.sync_brace_depth();
370            if self.try_macrocode_frame()
371                || self.try_guard()
372                || self.try_doc_margin()
373                || self.try_verbatim_environment()
374                || self.try_verbatim_arg_environment()
375            {
376                continue;
377            }
378            let word_len = control_word_len(self.rest(), self.at_letter, self.expl_syntax);
379            if self.try_verbatim_command(word_len)
380                || self.try_short_verb()
381                || self.try_char_constant()
382                || self.try_doc_comment()
383            {
384                continue;
385            }
386            self.lex_token(word_len);
387        }
388        self.out
389    }
390
391    fn rest(&self) -> &'a str {
392        &self.input[self.pos..]
393    }
394
395    fn push(&mut self, kind: SyntaxKind, text: &str) {
396        self.out.push(Token {
397            kind,
398            text: SmolStr::new(text),
399        });
400    }
401
402    fn consume(&mut self, len: usize) {
403        self.pos += len;
404        self.at_line_start = false;
405        self.pending = None;
406    }
407
408    fn sync_brace_depth(&mut self) {
409        while self.brace_counted < self.out.len() {
410            match self.out[self.brace_counted].kind {
411                SyntaxKind::L_BRACE => self.brace_depth += 1,
412                SyntaxKind::R_BRACE => self.brace_depth = self.brace_depth.saturating_sub(1),
413                _ => {}
414            }
415            self.brace_counted += 1;
416        }
417    }
418
419    fn try_macrocode_frame(&mut self) -> bool {
420        if !(self.config.dtx && self.at_line_start) {
421            return false;
422        }
423        let rest = self.rest();
424        let want_begin = self.macrocode.is_none();
425        let Some(consumed) = lex_macrocode_frame(rest, want_begin, &mut self.out) else {
426            return false;
427        };
428        match self.macrocode.take() {
429            Some(saved) => {
430                self.at_letter = saved.at_letter;
431                self.expl_syntax = saved.expl_syntax;
432            }
433            None => {
434                self.macrocode = Some(MacrocodeSave {
435                    at_letter: self.at_letter,
436                    expl_syntax: self.expl_syntax,
437                });
438                self.at_letter = true;
439                if self.implicit_expl {
440                    self.expl_syntax = true;
441                }
442            }
443        }
444        self.consume(consumed);
445        true
446    }
447
448    fn try_guard(&mut self) -> bool {
449        let rest = self.rest();
450        if !(self.config.dtx && self.at_line_start && rest.starts_with("%<")) {
451            return false;
452        }
453        let Some(rel) = rest[2..].find(['>', '\n', '\r']) else {
454            return false;
455        };
456        if rest.as_bytes()[2 + rel] != b'>' {
457            return false;
458        }
459        let len = 2 + rel + 1;
460        self.push(SyntaxKind::GUARD, &rest[..len]);
461        self.pos += len;
462        self.at_line_start = false;
463        true
464    }
465
466    fn try_doc_margin(&mut self) -> bool {
467        let rest = self.rest();
468        if !(self.config.dtx
469            && self.at_line_start
470            && self.macrocode.is_none()
471            && rest.starts_with('%')
472            && !rest.starts_with("%<"))
473        {
474            return false;
475        }
476        self.push(SyntaxKind::DOC_MARGIN, "%");
477        self.pos += 1;
478        self.at_line_start = false;
479        self.in_doc_line = true;
480        true
481    }
482
483    fn try_verbatim_environment(&mut self) -> bool {
484        let (rest, ctx) = (self.rest(), self.ctx);
485        let Some(consumed) = lex_verbatim_environment(rest, ctx, &mut self.out) else {
486            return false;
487        };
488        self.consume(consumed);
489        true
490    }
491
492    fn try_verbatim_arg_environment(&mut self) -> bool {
493        if self.macrocode.is_some() {
494            return false;
495        }
496        let rest = self.rest();
497        let Some(consumed) = lex_verbatim_arg_environment(rest, &mut self.out) else {
498            return false;
499        };
500        self.consume(consumed);
501        true
502    }
503
504    fn try_verbatim_command(&mut self, word_len: Option<usize>) -> bool {
505        if self.pending == Some(Pending::Def) {
506            return false;
507        }
508        let (rest, ctx) = (self.rest(), self.ctx);
509        let Some(consumed) = lex_verbatim_command(
510            rest,
511            word_len,
512            ctx,
513            self.config.dtx && self.in_doc_line,
514            &mut self.out,
515        ) else {
516            return false;
517        };
518        self.consume(consumed);
519        true
520    }
521
522    fn try_short_verb(&mut self) -> bool {
523        if self.short_verbs.is_empty()
524            || self.macrocode.is_some()
525            || matches!(self.pending, Some(Pending::Delim | Pending::LiteralToken))
526        {
527            return false;
528        }
529        let rest = self.rest();
530        if !rest
531            .chars()
532            .next()
533            .is_some_and(|c| self.short_verbs.contains(&c))
534        {
535            return false;
536        }
537        let Some(len) = delimited_len(rest) else {
538            return false;
539        };
540        self.push(SyntaxKind::VERB, &rest[..len]);
541        self.consume(len);
542        true
543    }
544
545    fn try_char_constant(&mut self) -> bool {
546        let numeric_context = self.pending == Some(Pending::CharConstant);
547        let rest = self.rest();
548        let Some(after) = rest.strip_prefix('`') else {
549            return false;
550        };
551        let Some(c) = after.chars().next() else {
552            return false;
553        };
554        if matches!(c, '\n' | '\r') || (self.brace_depth > 0 && matches!(c, '{' | '}')) {
555            return false;
556        }
557        let len = if c == '\\' {
558            match after[1..]
559                .chars()
560                .next()
561                .filter(|e| !matches!(e, '\n' | '\r'))
562            {
563                Some(e) => 2 + e.len_utf8(),
564                None => return false,
565            }
566        } else {
567            1 + c.len_utf8()
568        };
569        let alignment_cell = self.pending.is_none() && c == '\\' && rest[len..].starts_with('&');
570        if !numeric_context && !alignment_cell {
571            return false;
572        }
573        self.push(SyntaxKind::WORD, &rest[..len]);
574        self.consume(len);
575        true
576    }
577
578    fn try_doc_comment(&mut self) -> bool {
579        let rest = self.rest();
580        if !(self.in_doc_line && rest.starts_with("^^A")) {
581            return false;
582        }
583        let len = run_len(rest, |c| c != '\n' && c != '\r');
584        self.push(SyntaxKind::COMMENT, &rest[..len]);
585        self.consume(len);
586        true
587    }
588
589    fn lex_token(&mut self, word_len: Option<usize>) {
590        let rest = self.rest();
591        let (kind, mut len) = next_token(rest, word_len, self.expl_syntax);
592        if self.pending == Some(Pending::Delim) && kind == SyntaxKind::WORD {
593            len = rest.chars().next().expect("rest is non-empty").len_utf8();
594        }
595        if kind == SyntaxKind::WORD
596            && !self.short_verbs.is_empty()
597            && let Some((i, c)) = rest[..len]
598                .char_indices()
599                .find(|(_, c)| self.short_verbs.contains(c))
600        {
601            len = if i == 0 { c.len_utf8() } else { i };
602        }
603        debug_assert!(len > 0, "lexer made no progress at byte {}", self.pos);
604        let text = &rest[..len];
605        if kind == SyntaxKind::CONTROL_WORD {
606            self.apply_toggles(text, &rest[len..]);
607        }
608        self.pending = next_pending(self.pending, kind, text);
609        self.push(kind, text);
610        self.at_line_start =
611            kind == SyntaxKind::NEWLINE || text.ends_with('\n') || text.ends_with('\r');
612        if self.at_line_start {
613            self.in_doc_line = false;
614        }
615        self.pos += len;
616    }
617
618    fn apply_toggles(&mut self, text: &str, after: &str) {
619        match text {
620            "\\makeatletter" => self.at_letter = true,
621            "\\makeatother" => self.at_letter = false,
622            "\\MakeShortVerb" => {
623                if let Some(c) = short_verb_char(after)
624                    && !self.short_verbs.contains(&c)
625                {
626                    self.short_verbs.push(c);
627                }
628            }
629            "\\DeleteShortVerb" => {
630                if let Some(c) = short_verb_char(after) {
631                    self.short_verbs.retain(|&x| x != c);
632                }
633            }
634            "\\documentclass" | "\\LoadClass" => {
635                if doc_class_enables_bar(after) && !self.short_verbs.contains(&'|') {
636                    self.short_verbs.push('|');
637                }
638            }
639            _ => {
640                if let Some(toggle) = expl_toggle(text) {
641                    self.expl_syntax = matches!(toggle, ExplToggle::On);
642                }
643            }
644        }
645    }
646}
647
648pub(crate) fn reads_following_text(text: &str) -> bool {
649    matches!(
650        text,
651        "\\MakeShortVerb" | "\\DeleteShortVerb" | "\\documentclass" | "\\LoadClass"
652    ) || next_pending(None, SyntaxKind::CONTROL_WORD, text).is_some()
653}
654
655fn next_pending(pending: Option<Pending>, kind: SyntaxKind, text: &str) -> Option<Pending> {
656    if kind == SyntaxKind::CONTROL_WORD {
657        return if text == "\\left" || text == "\\right" {
658            Some(Pending::Delim)
659        } else if is_definition_keyword(text) {
660            Some(Pending::Def)
661        } else if is_char_constant_command(text) {
662            Some(Pending::CharConstant)
663        } else if is_literal_token_command(text) {
664            Some(Pending::LiteralToken)
665        } else {
666            None
667        };
668    }
669    match pending? {
670        p @ (Pending::Delim | Pending::Def)
671            if matches!(kind, SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE) =>
672        {
673            Some(p)
674        }
675        Pending::Def if kind == SyntaxKind::L_BRACE => Some(Pending::Def),
676        p @ (Pending::CharConstant | Pending::LiteralToken) if kind == SyntaxKind::WHITESPACE => {
677            Some(p)
678        }
679        _ => None,
680    }
681}
682
683fn control_word_len(rest: &str, at_letter: bool, expl_syntax: bool) -> Option<usize> {
684    let after = rest.strip_prefix('\\')?;
685    let letters = run_len(after, |c| is_letter(c, at_letter, expl_syntax));
686    (letters > 0).then_some(1 + letters)
687}
688
689fn next_token(rest: &str, word_len: Option<usize>, expl_syntax: bool) -> (SyntaxKind, usize) {
690    let c = rest.chars().next().expect("rest is non-empty");
691    match c {
692        '\\' => lex_control(rest, word_len),
693        '%' => (
694            SyntaxKind::COMMENT,
695            run_len(rest, |c| c != '\n' && c != '\r'),
696        ),
697        '{' => (SyntaxKind::L_BRACE, 1),
698        '}' => (SyntaxKind::R_BRACE, 1),
699        '[' => (SyntaxKind::L_BRACKET, 1),
700        ']' => (SyntaxKind::R_BRACKET, 1),
701        '$' => (SyntaxKind::DOLLAR, 1),
702        '&' => (SyntaxKind::AMPERSAND, 1),
703        '#' => (SyntaxKind::HASH, 1),
704        '^' => (SyntaxKind::CARET, 1),
705        '_' if !expl_syntax => (SyntaxKind::UNDERSCORE, 1),
706        '~' => (SyntaxKind::TILDE, 1),
707        '\n' => (SyntaxKind::NEWLINE, 1),
708        '\r' => {
709            let len = if rest.as_bytes().get(1) == Some(&b'\n') {
710                2
711            } else {
712                1
713            };
714            (SyntaxKind::NEWLINE, len)
715        }
716        ' ' | '\t' => (
717            SyntaxKind::WHITESPACE,
718            run_len(rest, |c| c == ' ' || c == '\t'),
719        ),
720        _ => (
721            SyntaxKind::WORD,
722            run_len(rest, |c| is_word_char(c) || (expl_syntax && c == '_')),
723        ),
724    }
725}
726
727fn lex_control(rest: &str, word_len: Option<usize>) -> (SyntaxKind, usize) {
728    match word_len {
729        Some(word_len) => {
730            if &rest[..word_len] == "\\verb"
731                && let Some(arg_len) = verb_len(&rest[word_len..])
732            {
733                return (SyntaxKind::VERB, word_len + arg_len);
734            }
735            (SyntaxKind::CONTROL_WORD, word_len)
736        }
737        None => {
738            let after = &rest[1..];
739            let symbol_len = if after.starts_with("\r\n") {
740                2
741            } else {
742                after.chars().next().map_or(0, char::len_utf8)
743            };
744            (SyntaxKind::CONTROL_SYMBOL, 1 + symbol_len)
745        }
746    }
747}
748
749fn verb_len(after: &str) -> Option<usize> {
750    match after.strip_prefix('*') {
751        Some(rest) => Some(1 + delimited_len(rest)?),
752        None => delimited_len(after),
753    }
754}
755
756fn delimited_len(after: &str) -> Option<usize> {
757    let mut chars = after.chars();
758    let delim = chars.next()?;
759    if delim.is_whitespace() {
760        return None;
761    }
762    let mut consumed = delim.len_utf8();
763    for c in chars {
764        if c == '\n' || c == '\r' {
765            return None;
766        }
767        consumed += c.len_utf8();
768        if c == delim {
769            return Some(consumed);
770        }
771    }
772    None
773}
774
775fn short_verb_char(after: &str) -> Option<char> {
776    let s = skip_inline_ws(after.strip_prefix('*').unwrap_or(after));
777    let (body, braced) = match s.strip_prefix('{') {
778        Some(inner) => (skip_inline_ws(inner), true),
779        None => (s, false),
780    };
781    let arg = body.strip_prefix('\\')?;
782    let c = arg.chars().next()?;
783    if c == '\n' || c == '\r' {
784        return None;
785    }
786    if braced && !skip_inline_ws(&arg[c.len_utf8()..]).starts_with('}') {
787        return None;
788    }
789    Some(c)
790}
791
792const BAR_SHORT_VERB_CLASSES: [&str; 5] = ["ltxdoc", "ltxguide", "ltnews", "l3doc", "amsldoc"];
793
794fn doc_class_enables_bar(after: &str) -> bool {
795    let mut s = skip_inline_ws(after);
796    if let Some(rest) = s.strip_prefix('[') {
797        match rest.find(']') {
798            Some(i) => s = rest[i + 1..].trim_start_matches([' ', '\t', '\n', '\r']),
799            None => return false,
800        }
801    }
802    let Some(rest) = s.strip_prefix('{') else {
803        return false;
804    };
805    let Some(close) = rest.find('}') else {
806        return false;
807    };
808    BAR_SHORT_VERB_CLASSES.contains(&rest[..close].trim())
809}
810
811fn lex_verbatim_environment(rest: &str, ctx: &ParseCtx, out: &mut Vec<Token>) -> Option<usize> {
812    let (name, prefix_len) = begin_name(rest)?;
813    let args: &[ArgSpec] = match ctx.verbatim_environment_args(name) {
814        Some(args) => args,
815        None => {
816            &builtin()
817                .environment(name)
818                .filter(|e| e.verbatim_body)?
819                .args
820        }
821    };
822
823    push_env_delimiter(out, "\\begin", name);
824
825    let args_region = &rest[prefix_len..];
826    let args_len = scan_verbatim_args(args_region, args);
827    lex_into(&args_region[..args_len], out);
828
829    let body_region = &args_region[args_len..];
830    let body_len = verbatim_body_len(body_region, name);
831    if body_len > 0 {
832        out.push(Token {
833            kind: SyntaxKind::VERBATIM_BODY,
834            text: SmolStr::new(&body_region[..body_len]),
835        });
836    }
837    Some(prefix_len + args_len + body_len)
838}
839
840fn verbatim_body_len(body: &str, name: &str) -> usize {
841    const LEAD: &str = "\\end{";
842    let mut from = 0;
843    while let Some(rel) = body[from..].find(LEAD) {
844        let at = from + rel;
845        let after = &body[at + LEAD.len()..];
846        if let Some(tail) = after.strip_prefix(name)
847            && tail.starts_with('}')
848        {
849            return at;
850        }
851        from = at + LEAD.len();
852    }
853    body.len()
854}
855
856fn lex_verbatim_arg_environment(rest: &str, out: &mut Vec<Token>) -> Option<usize> {
857    let (name, prefix_len) = begin_name(rest)?;
858    builtin().environment(name).filter(|e| e.verbatim_arg)?;
859
860    let region = &rest[prefix_len..];
861    let mut args_len = 0;
862    if let Some(after) = region.strip_prefix('[') {
863        let i = after.find([']', '\n', '\r'])?;
864        if after.as_bytes()[i] != b']' {
865            return None;
866        }
867        args_len = 1 + i + 1;
868    }
869    let arg_region = &region[args_len..];
870    let delim = arg_region.chars().next()?;
871    let braced_content_len = if delim == '{' {
872        Some(braced_verb_content_len(&arg_region[1..])?)
873    } else {
874        if !delim.is_ascii_punctuation()
875            || matches!(delim, '\\' | '}' | '[' | ']' | '%' | '*' | '$')
876        {
877            return None;
878        }
879        None
880    };
881
882    push_env_delimiter(out, "\\begin", name);
883    lex_into(&region[..args_len], out);
884    let verb_len = match braced_content_len {
885        Some(content_len) => {
886            out.push(Token {
887                kind: SyntaxKind::L_BRACE,
888                text: SmolStr::new("{"),
889            });
890            out.push(Token {
891                kind: SyntaxKind::VERB,
892                text: SmolStr::new(&arg_region[1..1 + content_len]),
893            });
894            out.push(Token {
895                kind: SyntaxKind::R_BRACE,
896                text: SmolStr::new("}"),
897            });
898            1 + content_len + 1
899        }
900        None => {
901            let verb_len = delimited_len(arg_region)?;
902            out.push(Token {
903                kind: SyntaxKind::VERB,
904                text: SmolStr::new(&arg_region[..verb_len]),
905            });
906            verb_len
907        }
908    };
909    Some(prefix_len + args_len + verb_len)
910}
911
912fn braced_verb_content_len(content: &str) -> Option<usize> {
913    let mut depth = 1usize;
914    let mut chars = content.char_indices();
915    while let Some((i, c)) = chars.next() {
916        match c {
917            '\\' => {
918                chars.next()?;
919            }
920            '{' => depth += 1,
921            '}' => {
922                depth -= 1;
923                if depth == 0 {
924                    return (i > 0).then_some(i);
925                }
926            }
927            '\n' | '\r' => return None,
928            _ => {}
929        }
930    }
931    None
932}
933
934fn lex_macrocode_frame(rest: &str, want_begin: bool, out: &mut Vec<Token>) -> Option<usize> {
935    let indent = if want_begin { inline_ws_len(rest) } else { 0 };
936    let after_pct = rest[indent..].strip_prefix('%')?;
937    let ws_len = inline_ws_len(after_pct);
938    let body = &after_pct[ws_len..];
939    let (control, open) = if want_begin {
940        ("\\begin", "\\begin{")
941    } else {
942        ("\\end", "\\end{")
943    };
944    let after_open = body.strip_prefix(open)?;
945    let close = after_open.find('}')?;
946    let name = &after_open[..close];
947    if name != "macrocode" && name != "macrocode*" {
948        return None;
949    }
950    let after_close = &after_open[close + 1..];
951    let tail = skip_inline_ws(after_close);
952    let comment_tail = !want_begin && tail.starts_with('%');
953    if !(tail.is_empty() || tail.starts_with('\n') || tail.starts_with('\r') || comment_tail) {
954        return None;
955    }
956
957    if indent > 0 {
958        out.push(Token {
959            kind: SyntaxKind::WHITESPACE,
960            text: SmolStr::new(&rest[..indent]),
961        });
962    }
963    out.push(Token {
964        kind: SyntaxKind::DOC_MARGIN,
965        text: SmolStr::new("%"),
966    });
967    if ws_len > 0 {
968        out.push(Token {
969            kind: SyntaxKind::WHITESPACE,
970            text: SmolStr::new(&after_pct[..ws_len]),
971        });
972    }
973    push_env_delimiter(out, control, name);
974    Some(indent + 1 + ws_len + control.len() + 1 + name.len() + 1)
975}
976
977fn lex_verbatim_command(
978    rest: &str,
979    word_len: Option<usize>,
980    ctx: &ParseCtx,
981    on_dtx_doc_line: bool,
982    out: &mut Vec<Token>,
983) -> Option<usize> {
984    let word_len = word_len?;
985    let name = &rest[1..word_len];
986    if name == "verb" {
987        return None;
988    }
989    let (leading, delimited): (&[ArgSpec], bool) = match ctx.leading_args(name) {
990        Some(args) => (args, false),
991        None => {
992            if ctx.is_suppressed(name) {
993                return None;
994            }
995            let sig = builtin().command(name)?;
996            if sig.verbatim {
997                (&sig.args, sig.verbatim_delimited)
998            } else {
999                let raw = sig.args.iter().position(|arg| arg.verbatim)?;
1000                if sig.args[raw].kind != ArgKind::Brace {
1001                    return None;
1002                }
1003                (&sig.args[..raw], false)
1004            }
1005        }
1006    };
1007
1008    let after_word = &rest[word_len..];
1009    let args_len = scan_verbatim_args(after_word, leading);
1010
1011    let region = &after_word[args_len..];
1012    let dtx_gap = (!delimited && on_dtx_doc_line)
1013        .then(|| dtx_doc_argument_gap_len(region))
1014        .flatten();
1015    let ws_len = if let Some(len) = dtx_gap {
1016        len
1017    } else if delimited {
1018        inline_ws_len(region)
1019    } else {
1020        tex_whitespace_len(region)
1021    };
1022    let arg_region = &region[ws_len..];
1023    let arg_len = match arg_region.bytes().next() {
1024        Some(b'{') => balanced_group_len(arg_region, b'}')?,
1025        Some(_) if delimited => delimited_len(arg_region)?,
1026        _ => return None,
1027    };
1028
1029    out.push(Token {
1030        kind: SyntaxKind::CONTROL_WORD,
1031        text: SmolStr::new(&rest[..word_len]),
1032    });
1033    lex_into(&after_word[..args_len], out);
1034    if ws_len > 0 {
1035        if dtx_gap.is_some() {
1036            lex_dtx_doc_argument_gap(&region[..ws_len], out);
1037        } else {
1038            out.push(Token {
1039                kind: SyntaxKind::WHITESPACE,
1040                text: SmolStr::new(&region[..ws_len]),
1041            });
1042        }
1043    }
1044    out.push(Token {
1045        kind: SyntaxKind::VERB,
1046        text: SmolStr::new(&arg_region[..arg_len]),
1047    });
1048    Some(word_len + args_len + ws_len + arg_len)
1049}
1050
1051fn scan_verbatim_args(region: &str, args: &[ArgSpec]) -> usize {
1052    let bytes = region.as_bytes();
1053    let mut pos = 0;
1054    for arg in args {
1055        let probe = pos + inline_ws_len(&region[pos..]);
1056        let (open, close) = match arg.kind {
1057            ArgKind::Bracket => (b'[', b']'),
1058            ArgKind::Brace => (b'{', b'}'),
1059        };
1060        if bytes.get(probe) != Some(&open) {
1061            continue;
1062        }
1063        match balanced_group_len(&region[probe..], close) {
1064            Some(len) => pos = probe + len,
1065            None => break, // unbalanced: treat the remainder as body
1066        }
1067    }
1068    pos
1069}
1070
1071fn balanced_group_len(s: &str, close: u8) -> Option<usize> {
1072    let bytes = s.as_bytes();
1073    let mut stack = vec![close];
1074    let mut i = 1;
1075    while i < bytes.len() {
1076        match bytes[i] {
1077            b'\\' => {
1078                i += 2;
1079                continue;
1080            }
1081            b'{' => stack.push(b'}'),
1082            b'[' => stack.push(b']'),
1083            c @ (b'}' | b']') if stack.last() == Some(&c) => {
1084                stack.pop();
1085                if stack.is_empty() {
1086                    return Some(i + 1);
1087                }
1088            }
1089            _ => {}
1090        }
1091        i += 1;
1092    }
1093    None
1094}
1095
1096fn lex_into(region: &str, out: &mut Vec<Token>) {
1097    let mut pos = 0;
1098    while pos < region.len() {
1099        let rest = &region[pos..];
1100        let (kind, len) = next_token(rest, control_word_len(rest, false, false), false);
1101        debug_assert!(len > 0, "lexer made no progress in verbatim args");
1102        out.push(Token {
1103            kind,
1104            text: SmolStr::new(&region[pos..pos + len]),
1105        });
1106        pos += len;
1107    }
1108}
1109
1110fn begin_name(rest: &str) -> Option<(&str, usize)> {
1111    let after = rest.strip_prefix("\\begin{")?;
1112    let close = after.find('}')?;
1113    Some((&after[..close], "\\begin{".len() + close + 1))
1114}
1115
1116fn push_env_delimiter(out: &mut Vec<Token>, control: &str, name: &str) {
1117    out.push(Token {
1118        kind: SyntaxKind::CONTROL_WORD,
1119        text: SmolStr::new(control),
1120    });
1121    out.push(Token {
1122        kind: SyntaxKind::L_BRACE,
1123        text: SmolStr::new("{"),
1124    });
1125    out.push(Token {
1126        kind: SyntaxKind::WORD,
1127        text: SmolStr::new(name),
1128    });
1129    out.push(Token {
1130        kind: SyntaxKind::R_BRACE,
1131        text: SmolStr::new("}"),
1132    });
1133}
1134
1135fn inline_ws_len(s: &str) -> usize {
1136    s.bytes().take_while(|&b| b == b' ' || b == b'\t').count()
1137}
1138
1139fn tex_whitespace_len(s: &str) -> usize {
1140    s.bytes()
1141        .take_while(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r'))
1142        .count()
1143}
1144
1145fn dtx_doc_argument_gap_len(s: &str) -> Option<usize> {
1146    let inline = inline_ws_len(s);
1147    let rest = &s[inline..];
1148    let newline = if rest.starts_with("\r\n") {
1149        2
1150    } else if rest.starts_with(['\n', '\r']) {
1151        1
1152    } else {
1153        return None;
1154    };
1155    let after_newline = &rest[newline..];
1156    let after_margin = after_newline.strip_prefix('%')?;
1157    Some(inline + newline + 1 + inline_ws_len(after_margin))
1158}
1159
1160fn lex_dtx_doc_argument_gap(gap: &str, out: &mut Vec<Token>) {
1161    let inline = inline_ws_len(gap);
1162    if inline > 0 {
1163        out.push(Token {
1164            kind: SyntaxKind::WHITESPACE,
1165            text: SmolStr::new(&gap[..inline]),
1166        });
1167    }
1168    let rest = &gap[inline..];
1169    let newline = if rest.starts_with("\r\n") { 2 } else { 1 };
1170    out.push(Token {
1171        kind: SyntaxKind::NEWLINE,
1172        text: SmolStr::new(&rest[..newline]),
1173    });
1174    out.push(Token {
1175        kind: SyntaxKind::DOC_MARGIN,
1176        text: SmolStr::new("%"),
1177    });
1178    let trailing = &rest[newline + 1..];
1179    if !trailing.is_empty() {
1180        out.push(Token {
1181            kind: SyntaxKind::WHITESPACE,
1182            text: SmolStr::new(trailing),
1183        });
1184    }
1185}
1186
1187fn skip_inline_ws(s: &str) -> &str {
1188    &s[inline_ws_len(s)..]
1189}
1190
1191fn run_len(s: &str, pred: impl Fn(char) -> bool) -> usize {
1192    let mut len = 0;
1193    for c in s.chars() {
1194        if pred(c) {
1195            len += c.len_utf8();
1196        } else {
1197            break;
1198        }
1199    }
1200    len
1201}
1202
1203fn is_letter(c: char, at_letter: bool, expl_syntax: bool) -> bool {
1204    c.is_ascii_alphabetic() || (at_letter && c == '@') || (expl_syntax && (c == '_' || c == ':'))
1205}
1206
1207/// Returns whether `name` can be a control word in any supported mode.
1208pub fn is_control_word_name(name: &str) -> bool {
1209    !name.is_empty() && name.chars().all(|c| is_letter(c, true, true))
1210}
1211
1212/// Returns whether `c` belongs to an ordinary text token.
1213pub fn is_word_char(c: char) -> bool {
1214    !matches!(
1215        c,
1216        '\\' | '%'
1217            | '{'
1218            | '}'
1219            | '['
1220            | ']'
1221            | '$'
1222            | '&'
1223            | '#'
1224            | '^'
1225            | '_'
1226            | '~'
1227            | ' '
1228            | '\t'
1229            | '\n'
1230            | '\r'
1231    )
1232}
1233
1234#[cfg(test)]
1235mod tests {
1236    use super::*;
1237
1238    fn assert_lossless(input: &str) {
1239        let joined: String = lex(input).iter().map(|t| t.text.as_str()).collect();
1240        assert_eq!(joined, input);
1241    }
1242
1243    #[test]
1244    fn the_pending_arming_sets_are_disjoint() {
1245        for name in [
1246            "\\left",
1247            "\\right",
1248            "\\newcommand",
1249            "\\renewcommand",
1250            "\\providecommand",
1251            "\\DeclareRobustCommand",
1252            "\\NewDocumentCommand",
1253            "\\RenewDocumentCommand",
1254            "\\ProvideDocumentCommand",
1255            "\\DeclareDocumentCommand",
1256            "\\def",
1257            "\\edef",
1258            "\\gdef",
1259            "\\xdef",
1260            "\\let",
1261            "\\char",
1262            "\\catcode",
1263            "\\lccode",
1264            "\\uccode",
1265            "\\sfcode",
1266            "\\mathcode",
1267            "\\delcode",
1268            "\\number",
1269            "\\the",
1270            "\\romannumeral",
1271            "\\numexpr",
1272            "\\dimexpr",
1273            "\\ifnum",
1274            "\\ifodd",
1275            "\\ifdim",
1276            "\\string",
1277            "\\noexpand",
1278            "\\meaning",
1279            "\\expandafter",
1280            "\\show",
1281        ] {
1282            let armed = [
1283                name == "\\left" || name == "\\right",
1284                is_definition_keyword(name),
1285                is_char_constant_command(name),
1286                is_literal_token_command(name),
1287            ];
1288            assert_eq!(
1289                armed.iter().filter(|&&x| x).count(),
1290                1,
1291                "{name} arms {armed:?} — the `Pending` slot needs disjoint sets"
1292            );
1293        }
1294    }
1295
1296    #[test]
1297    fn a_claimed_construct_spends_the_armed_char_constant_mode() {
1298        let direct = lex("\\char `\\%");
1299        assert!(
1300            direct
1301                .iter()
1302                .any(|t| t.kind == SyntaxKind::WORD && t.text == "`\\%")
1303        );
1304        let intervened = lex("\\MakeShortVerb{\\|} \\char |a| `\\%");
1305        assert!(
1306            intervened
1307                .iter()
1308                .any(|t| t.kind == SyntaxKind::VERB && t.text == "|a|")
1309        );
1310        assert!(
1311            !intervened
1312                .iter()
1313                .any(|t| t.kind == SyntaxKind::WORD && t.text == "`\\%")
1314        );
1315        assert_lossless("\\MakeShortVerb{\\|} \\char |a| `\\%");
1316    }
1317
1318    #[test]
1319    fn block_environment_classification() {
1320        let ctx = ParseCtx::default();
1321        assert!(ctx.is_block_environment("figure"));
1322        assert!(ctx.is_block_environment("itemize")); // derived via `list`
1323        assert!(!ctx.is_block_environment("myenv")); // unknown
1324    }
1325
1326    #[test]
1327    fn lossless_on_assorted_inputs() {
1328        for input in [
1329            "",
1330            "plain text",
1331            r"\section{Hi}[x]",
1332            "$a^2_b$",
1333            "a%c\n\nb",
1334            "café ∑ \\\\ \\{ \\,",
1335            "tab\tand  spaces",
1336            "trailing\\",
1337            r"\verb|$x$|",
1338            "\\begin{verbatim}\n$x$ %not a comment\n\\end{verbatim}",
1339            "\\begin{lstlisting}[language=C]\nint a[3];  % raw\n\\end{lstlisting}",
1340            "\\begin{minted}[frame=single]{python}\nprint(\"$x$\")\n\\end{minted}",
1341            "\\begin{lstlisting}\n[1,2,3]\n\\end{lstlisting}",
1342            r"\makeatletter\a@b\makeatother\a@b",
1343            r"\ExplSyntaxOn\seq_new:N \g_@@_x_tl a_b\ExplSyntaxOff\seq_new:N",
1344            r"$\left(x+y\right)^2 \left.\frac{a}{b}\right|_0$",
1345        ] {
1346            assert_lossless(input);
1347        }
1348    }
1349
1350    #[test]
1351    fn control_word_stops_at_non_letter() {
1352        let toks = lex(r"\alpha2");
1353        assert_eq!(toks[0].kind, SyntaxKind::CONTROL_WORD);
1354        assert_eq!(toks[0].text, "\\alpha");
1355        assert_eq!(toks[1].kind, SyntaxKind::WORD);
1356        assert_eq!(toks[1].text, "2");
1357    }
1358
1359    #[test]
1360    fn double_backslash_is_one_control_symbol() {
1361        let toks = lex(r"\\");
1362        assert_eq!(toks.len(), 1);
1363        assert_eq!(toks[0].kind, SyntaxKind::CONTROL_SYMBOL);
1364        assert_eq!(toks[0].text, r"\\");
1365    }
1366
1367    #[test]
1368    fn comment_stops_before_newline() {
1369        let toks = lex("% hi\nx");
1370        assert_eq!(toks[0].kind, SyntaxKind::COMMENT);
1371        assert_eq!(toks[0].text, "% hi");
1372        assert_eq!(toks[1].kind, SyntaxKind::NEWLINE);
1373    }
1374
1375    #[test]
1376    fn crlf_is_a_single_newline() {
1377        let toks = lex("a\r\nb");
1378        assert_eq!(toks[1].kind, SyntaxKind::NEWLINE);
1379        assert_eq!(toks[1].text, "\r\n");
1380    }
1381
1382    #[test]
1383    fn control_symbol_swallows_the_whole_line_ending() {
1384        for ending in ["\n", "\r", "\r\n"] {
1385            let input = format!("\\{ending}");
1386            let toks = lex(&input);
1387            assert_eq!(toks.len(), 1, "split line ending {ending:?}");
1388            assert_eq!(toks[0].kind, SyntaxKind::CONTROL_SYMBOL);
1389            assert_eq!(toks[0].text, input);
1390        }
1391    }
1392
1393    #[test]
1394    fn verb_inline_is_one_token() {
1395        let toks = lex(r"\verb|$x$|");
1396        assert_eq!(toks.len(), 1);
1397        assert_eq!(toks[0].kind, SyntaxKind::VERB);
1398        assert_eq!(toks[0].text, r"\verb|$x$|");
1399    }
1400
1401    #[test]
1402    fn verb_star_with_plus_delimiter() {
1403        let toks = lex(r"a\verb*+b+c");
1404        assert_eq!(toks[1].kind, SyntaxKind::VERB);
1405        assert_eq!(toks[1].text, r"\verb*+b+");
1406        assert_eq!(toks[2].text, "c");
1407    }
1408
1409    #[test]
1410    fn verb_without_closing_delimiter_is_a_plain_control_word() {
1411        let toks = lex(r"\verb|x");
1412        assert_eq!(toks[0].kind, SyntaxKind::CONTROL_WORD);
1413        assert_eq!(toks[0].text, r"\verb");
1414    }
1415
1416    #[test]
1417    fn left_right_isolate_word_delimiter() {
1418        let toks = lex(r"\left(x+y\right)");
1419        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1420        assert_eq!(
1421            seen,
1422            [
1423                (SyntaxKind::CONTROL_WORD, "\\left"),
1424                (SyntaxKind::WORD, "("),
1425                (SyntaxKind::WORD, "x+y"),
1426                (SyntaxKind::CONTROL_WORD, "\\right"),
1427                (SyntaxKind::WORD, ")"),
1428            ]
1429        );
1430    }
1431
1432    #[test]
1433    fn left_delimiter_carries_across_whitespace() {
1434        let toks = lex(r"\left ( a");
1435        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1436        assert_eq!(
1437            seen,
1438            [
1439                (SyntaxKind::CONTROL_WORD, "\\left"),
1440                (SyntaxKind::WHITESPACE, " "),
1441                (SyntaxKind::WORD, "("),
1442                (SyntaxKind::WHITESPACE, " "),
1443                (SyntaxKind::WORD, "a"),
1444            ]
1445        );
1446    }
1447
1448    #[test]
1449    fn left_non_word_delimiters_are_untouched() {
1450        for input in [r"\left\{", r"\left\langle", r"\left["] {
1451            assert_lossless(input);
1452        }
1453        let toks = lex(r"\left\langle x \right\rangle");
1454        assert!(toks.iter().any(|t| t.text == "\\langle"));
1455        assert!(toks.iter().any(|t| t.text == "\\rangle"));
1456    }
1457
1458    #[test]
1459    fn leftarrow_is_not_left() {
1460        let toks = lex(r"\leftarrow(x)");
1461        assert_eq!(toks[0].text, "\\leftarrow");
1462        assert_eq!(toks[1].text, "(x)");
1463    }
1464
1465    #[test]
1466    fn makeatletter_makes_at_a_letter() {
1467        let toks = lex(r"\makeatletter\foo@bar\makeatother\foo@bar");
1468        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1469        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo@bar")));
1470        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
1471    }
1472
1473    #[test]
1474    fn expl_syntax_makes_underscore_and_colon_letters() {
1475        let toks = lex(r"\ExplSyntaxOn\seq_new:N\ExplSyntaxOff\seq_new:N");
1476        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1477        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
1478        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq")));
1479    }
1480
1481    #[test]
1482    fn expl_syntax_lexes_internal_double_underscore_name() {
1483        let toks = lex(r"\ExplSyntaxOn\__module_internal:nn");
1484        assert_eq!(toks[1].kind, SyntaxKind::CONTROL_WORD);
1485        assert_eq!(toks[1].text, "\\__module_internal:nn");
1486    }
1487
1488    #[test]
1489    fn provides_expl_package_turns_on_expl_syntax() {
1490        let toks = lex(r"\ProvidesExplPackage{p}{2026/01/01}{1.0}{d}\tl_set:Nn");
1491        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1492        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\tl_set:Nn")));
1493    }
1494
1495    #[test]
1496    fn expl_syntax_composes_with_makeatletter() {
1497        let toks = lex(r"\makeatletter\ExplSyntaxOn\g_@@_frame_title_tl");
1498        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1499        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\g_@@_frame_title_tl")));
1500    }
1501
1502    #[test]
1503    fn expl_syntax_makes_bare_underscore_a_word_not_subscript() {
1504        let toks = lex(r"\ExplSyntaxOn a_b");
1505        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1506        assert!(seen.contains(&(SyntaxKind::WORD, "a_b")));
1507        assert!(!seen.iter().any(|(k, _)| *k == SyntaxKind::UNDERSCORE));
1508    }
1509
1510    fn lex_dtx(input: &str) -> Vec<Token> {
1511        lex_with(
1512            input,
1513            &ParseCtx::default(),
1514            LexConfig {
1515                flavor: LatexFlavor::Document,
1516                dtx: true,
1517            },
1518        )
1519    }
1520
1521    #[test]
1522    fn implicit_expl_module_guard_makes_macrocode_body_expl3() {
1523        let toks = lex_dtx(
1524            "%<@@=mod>\n\
1525             %    \\begin{macrocode}\n\
1526             \\seq_new:N\n\
1527             %    \\end{macrocode}\n",
1528        );
1529        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1530        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
1531    }
1532
1533    #[test]
1534    fn no_expl_signal_leaves_macrocode_body_plain() {
1535        let toks = lex_dtx(
1536            "%    \\begin{macrocode}\n\
1537             \\seq_new:N\n\
1538             %    \\end{macrocode}\n",
1539        );
1540        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1541        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq")));
1542        assert!(!seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
1543    }
1544
1545    #[test]
1546    fn implicit_expl_provides_expl_flags_every_body_regardless_of_order() {
1547        let toks = lex_dtx(
1548            "%    \\begin{macrocode}\n\
1549             \\seq_new:N\n\
1550             %    \\end{macrocode}\n\
1551             % \\ProvidesExplPackage{p}{2026/01/01}{1.0}{d}\n\
1552             %    \\begin{macrocode}\n\
1553             \\tl_set:Nn\n\
1554             %    \\end{macrocode}\n",
1555        );
1556        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1557        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
1558        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\tl_set:Nn")));
1559    }
1560
1561    #[test]
1562    fn implicit_expl_is_body_only_doc_layer_stays_plain() {
1563        let toks = lex_dtx(
1564            "%<@@=mod>\n\
1565             % a_b\n\
1566             %    \\begin{macrocode}\n\
1567             c_d\n\
1568             %    \\end{macrocode}\n",
1569        );
1570        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1571        assert!(seen.contains(&(SyntaxKind::WORD, "c_d")));
1572        assert!(seen.iter().any(|(k, _)| *k == SyntaxKind::UNDERSCORE));
1573    }
1574
1575    #[test]
1576    fn implicit_expl_explicit_off_wins_then_next_body_re_enters() {
1577        let toks = lex_dtx(
1578            "%<@@=mod>\n\
1579             %    \\begin{macrocode}\n\
1580             \\seq_new:N\n\
1581             \\ExplSyntaxOff\n\
1582             a_b\n\
1583             %    \\end{macrocode}\n\
1584             %    \\begin{macrocode}\n\
1585             \\tl_set:Nn\n\
1586             %    \\end{macrocode}\n",
1587        );
1588        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1589        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
1590        assert!(seen.iter().any(|(k, _)| *k == SyntaxKind::UNDERSCORE));
1591        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\tl_set:Nn")));
1592    }
1593
1594    #[test]
1595    fn implicit_expl_gated_off_outside_dtx() {
1596        let toks = lex_with(
1597            "%<@@=mod>\n\\seq_new:N",
1598            &ParseCtx::default(),
1599            LexConfig {
1600                flavor: LatexFlavor::Package,
1601                dtx: false,
1602            },
1603        );
1604        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1605        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq")));
1606        assert!(!seen.contains(&(SyntaxKind::CONTROL_WORD, "\\seq_new:N")));
1607    }
1608
1609    #[test]
1610    fn package_flavor_starts_in_letter_mode() {
1611        let toks = lex_with(
1612            r"\foo@bar",
1613            &ParseCtx::default(),
1614            LatexFlavor::Package.into(),
1615        );
1616        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1617        assert_eq!(seen, vec![(SyntaxKind::CONTROL_WORD, "\\foo@bar")]);
1618    }
1619
1620    #[test]
1621    fn package_flavor_respects_trailing_makeatother() {
1622        let toks = lex_with(
1623            r"\foo@bar\makeatother\foo@bar",
1624            &ParseCtx::default(),
1625            LatexFlavor::Package.into(),
1626        );
1627        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1628        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo@bar")));
1629        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
1630    }
1631
1632    #[test]
1633    fn document_flavor_keeps_at_non_letter() {
1634        let toks = lex(r"\foo@bar");
1635        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1636        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
1637        assert!(!seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo@bar")));
1638    }
1639
1640    #[test]
1641    fn dtx_mode_lexes_line_leading_percent_as_a_margin() {
1642        let dtx = LexConfig {
1643            flavor: LatexFlavor::Document,
1644            dtx: true,
1645        };
1646        let toks = lex_with("% \\foo\nbar % tail\n", &ParseCtx::default(), dtx);
1647        let seen: Vec<_> = toks.iter().map(|t| (t.kind, t.text.as_str())).collect();
1648        assert_eq!(seen[0], (SyntaxKind::DOC_MARGIN, "%"));
1649        assert!(seen.contains(&(SyntaxKind::CONTROL_WORD, "\\foo")));
1650        assert!(seen.contains(&(SyntaxKind::COMMENT, "% tail")));
1651        assert_eq!(
1652            seen.iter()
1653                .filter(|(k, _)| *k == SyntaxKind::DOC_MARGIN)
1654                .count(),
1655            1
1656        );
1657    }
1658
1659    #[test]
1660    fn dtx_mode_is_off_by_default_for_margins_and_guards() {
1661        let plain = lex("% \\foo\n");
1662        assert_eq!(plain[0].kind, SyntaxKind::COMMENT);
1663        let plain_guard = lex("%<*driver>\n");
1664        assert_eq!(plain_guard[0].kind, SyntaxKind::COMMENT);
1665        assert_eq!(plain_guard[0].text, "%<*driver>");
1666    }
1667
1668    #[test]
1669    fn dtx_mode_lexes_line_leading_guards() {
1670        let dtx = LexConfig {
1671            flavor: LatexFlavor::Document,
1672            dtx: true,
1673        };
1674        let block = lex_with("%<*driver>\n%</driver>\n", &ParseCtx::default(), dtx);
1675        assert_eq!(block[0].kind, SyntaxKind::GUARD);
1676        assert_eq!(block[0].text, "%<*driver>");
1677        assert!(
1678            block
1679                .iter()
1680                .any(|t| t.kind == SyntaxKind::GUARD && t.text == "%</driver>")
1681        );
1682        let inline = lex_with("%<plain>\\RequirePackage{x}\n", &ParseCtx::default(), dtx);
1683        assert_eq!(inline[0].kind, SyntaxKind::GUARD);
1684        assert_eq!(inline[0].text, "%<plain>");
1685        assert!(
1686            inline
1687                .iter()
1688                .any(|t| t.kind == SyntaxKind::CONTROL_WORD && t.text == "\\RequirePackage")
1689        );
1690        let expr = lex_with("%<*package|driver>\n", &ParseCtx::default(), dtx);
1691        assert_eq!(expr[0].kind, SyntaxKind::GUARD);
1692        assert_eq!(expr[0].text, "%<*package|driver>");
1693        let midline = lex_with("a %<x>\n", &ParseCtx::default(), dtx);
1694        assert!(
1695            midline
1696                .iter()
1697                .any(|t| t.kind == SyntaxKind::COMMENT && t.text == "%<x>")
1698        );
1699        assert!(!midline.iter().any(|t| t.kind == SyntaxKind::GUARD));
1700        let malformed = lex_with("%<unterminated\n", &ParseCtx::default(), dtx);
1701        assert_eq!(malformed[0].kind, SyntaxKind::COMMENT);
1702        assert_eq!(malformed[0].text, "%<unterminated");
1703    }
1704
1705    #[test]
1706    fn verbatim_environment_body_is_one_raw_token() {
1707        let toks = lex("\\begin{verbatim}\n$not$ %literal\n\\end{verbatim}");
1708        assert_eq!(toks[0].text, "\\begin");
1709        assert_eq!(toks[2].text, "verbatim");
1710        assert!(
1711            toks.iter()
1712                .any(|t| t.kind == SyntaxKind::VERBATIM_BODY && t.text.contains("$not$ %literal"))
1713        );
1714        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::DOLLAR));
1715        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::COMMENT));
1716    }
1717
1718    #[test]
1719    fn argument_taking_verbatim_separates_args_from_body() {
1720        let toks = lex("\\begin{minted}[frame=single]{python}\nprint(\"$x$\")\n\\end{minted}");
1721        let kinds: Vec<_> = toks.iter().map(|t| t.kind).collect();
1722        assert!(kinds.contains(&SyntaxKind::L_BRACKET));
1723        assert!(kinds.contains(&SyntaxKind::R_BRACKET));
1724        assert!(kinds.contains(&SyntaxKind::L_BRACE));
1725        assert!(
1726            toks.iter()
1727                .any(|t| t.kind == SyntaxKind::VERBATIM_BODY && t.text.contains("print(\"$x$\")"))
1728        );
1729        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::DOLLAR));
1730    }
1731
1732    #[test]
1733    fn verbatim_body_starting_with_bracket_is_not_an_argument() {
1734        let toks = lex("\\begin{lstlisting}\n[1,2,3]\n\\end{lstlisting}");
1735        assert!(
1736            !toks
1737                .iter()
1738                .take_while(|t| t.kind != SyntaxKind::VERBATIM_BODY)
1739                .any(|t| t.kind == SyntaxKind::L_BRACKET),
1740            "the bracket on the body's first line must not be lexed as an argument"
1741        );
1742        assert!(
1743            toks.iter()
1744                .any(|t| t.kind == SyntaxKind::VERBATIM_BODY && t.text.contains("[1,2,3]"))
1745        );
1746    }
1747
1748    #[test]
1749    fn make_short_verb_toggles_pipe_capture() {
1750        let toks = lex("|a| \\MakeShortVerb{\\|} |$| \\DeleteShortVerb{\\|} |b|");
1751        let verbs: Vec<_> = toks
1752            .iter()
1753            .filter(|t| t.kind == SyntaxKind::VERB)
1754            .map(|t| t.text.as_str())
1755            .collect();
1756        assert_eq!(verbs, ["|$|"]);
1757        assert_lossless("|a| \\MakeShortVerb{\\|} |$| \\DeleteShortVerb{\\|} |b|");
1758    }
1759
1760    #[test]
1761    fn documentclass_ltxguide_enables_the_pipe_short_verb() {
1762        for preamble in [
1763            "\\documentclass{ltxguide}",
1764            "\\documentclass[a4paper]{ltxdoc}",
1765            "\\documentclass{ltxguide}[1994/11/20]",
1766            "\\documentclass{l3doc}",
1767            "\\documentclass[leqno,titlepage]{amsldoc}[1999/12/13]",
1768        ] {
1769            let input = format!("{preamble}\n|}}| done");
1770            let toks = lex(&input);
1771            assert!(
1772                toks.iter()
1773                    .any(|t| t.kind == SyntaxKind::VERB && t.text == "|}|"),
1774                "no VERB captured after {preamble}"
1775            );
1776        }
1777        let toks = lex("\\documentclass{article}\n|x| done");
1778        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::VERB));
1779    }
1780
1781    #[test]
1782    fn short_verb_never_captures_a_left_right_delimiter() {
1783        let toks = lex("\\MakeShortVerb{\\|} $\\left|x\\right|$");
1784        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::VERB));
1785        assert_lossless("\\MakeShortVerb{\\|} $\\left|x\\right|$");
1786    }
1787
1788    #[test]
1789    fn unclosed_short_verb_char_stands_alone() {
1790        let toks = lex("\\MakeShortVerb{\\|} a|b\nc");
1791        assert!(!toks.iter().any(|t| t.kind == SyntaxKind::VERB));
1792        assert!(
1793            toks.iter()
1794                .any(|t| t.kind == SyntaxKind::WORD && t.text == "|")
1795        );
1796        assert_lossless("\\MakeShortVerb{\\|} a|b\nc");
1797    }
1798
1799    #[test]
1800    fn raw_capture_content_does_not_change_later_lexing() {
1801        const ENV_BODIES: &[&str] = &[
1802            "",
1803            "plain text",
1804            "\\makeatletter",
1805            "\\ExplSyntaxOn",
1806            "\\MakeShortVerb{\\|}",
1807            "{{{",
1808            "}}}",
1809            "% not a comment",
1810            "$ & # ^ _ ~",
1811            "\\end{verbatimx}",
1812            "\\begin{verbatim}",
1813            "\\char`{",
1814            "\\left(",
1815        ];
1816        const INLINE_BODIES: &[&str] = &[
1817            "",
1818            "x",
1819            "\\makeatletter",
1820            "\\ExplSyntaxOn",
1821            "{}",
1822            "$ & # ^ _ ~",
1823            "% not a comment",
1824            "\\char`",
1825        ];
1826        const SUFFIX: &str = "after \\my@cmd \\l_tmpa_tl |bar| \\char`{ \\left( x\n";
1827
1828        for (prefix, open, close, bodies) in [
1829            (
1830                "before x\n",
1831                "\\begin{verbatim}\n",
1832                "\n\\end{verbatim}\n",
1833                ENV_BODIES,
1834            ),
1835            (
1836                "before x\n",
1837                "\\begin{lstlisting}[a=b]\n",
1838                "\n\\end{lstlisting}\n",
1839                ENV_BODIES,
1840            ),
1841            ("before x ", "\\verb+", "+ ", INLINE_BODIES),
1842            ("before x ", "\\url{", "} ", INLINE_BODIES),
1843            ("before x ", "\\href{", "}{visible} ", INLINE_BODIES),
1844            ("before x ", "\\lstinline+", "+ ", INLINE_BODIES),
1845        ] {
1846            let mut expected: Option<Vec<(SyntaxKind, String)>> = None;
1847            for body in bodies {
1848                let region = format!("{open}{body}{close}");
1849                let doc = format!("{prefix}{region}{SUFFIX}");
1850                assert_lossless(&doc);
1851
1852                let toks = lex(&doc);
1853                assert!(
1854                    toks.iter()
1855                        .any(|t| matches!(t.kind, SyntaxKind::VERB | SyntaxKind::VERBATIM_BODY))
1856                        || body.is_empty(),
1857                    "no raw capture formed, so this case proves nothing\n  \
1858                     region: {region:?}",
1859                );
1860
1861                let from = prefix.len() + region.len();
1862                let mut off = 0usize;
1863                let got: Vec<(SyntaxKind, String)> = toks
1864                    .into_iter()
1865                    .filter(|t| {
1866                        let start = off;
1867                        off += t.text.len();
1868                        start >= from
1869                    })
1870                    .map(|t| (t.kind, t.text.to_string()))
1871                    .collect();
1872
1873                match &expected {
1874                    None => expected = Some(got),
1875                    Some(want) => assert_eq!(
1876                        &got, want,
1877                        "a raw body changed how the text after it lexes\n  \
1878                         region: {region:?}",
1879                    ),
1880                }
1881            }
1882        }
1883    }
1884
1885    #[test]
1886    fn a_body_that_breaks_its_capture_changes_later_lexing() {
1887        let captured = lex("\\url{x} \\char`{");
1888        assert!(captured.iter().any(|t| t.kind == SyntaxKind::VERB));
1889        assert!(
1890            captured
1891                .iter()
1892                .any(|t| t.kind == SyntaxKind::WORD && t.text == "`{")
1893        );
1894
1895        let broken = lex("\\url{{} \\char`{");
1896        assert!(!broken.iter().any(|t| t.kind == SyntaxKind::VERB));
1897        assert!(
1898            broken
1899                .iter()
1900                .any(|t| t.kind == SyntaxKind::WORD && t.text == "`")
1901        );
1902    }
1903}