Skip to main content

rustyfi_syntax/
lexer.rs

1//! Stateful mode-stack lexer, a direct port of the v0.0.6 `lexer.mll`.
2//!
3//! The transitions between the five states are driven entirely by the lexer's
4//! own stack (see the transition table in the OCaml header comment); the whole
5//! file is lexed eagerly so that parser backtracking can never desynchronize
6//! the mode stack.
7
8use crate::span::{Loc, Span};
9use crate::token::{Atom, Token};
10use crate::version::RustyfiVersion;
11
12#[derive(Debug, thiserror::Error)]
13#[error("{span}: {msg}")]
14pub struct LexError {
15    pub span: Span,
16    pub msg: String,
17}
18
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20enum Mode {
21    Program,
22    Vertical,
23    Horizontal,
24    Active,
25    Math,
26}
27
28/// Lex a whole source file under SATySFi 0.0.6's grammar (a `.saty` document,
29/// i.e. program mode at the top) — a thin wrapper over
30/// [`lex_with_version`].
31pub fn lex(src: &str) -> Result<Vec<Atom>, LexError> {
32    lex_with_version(src, RustyfiVersion::V0_0)
33}
34
35/// Lex a whole source file under an explicit target version. `V0_0`
36/// through this entry point is byte-for-byte [`lex`]'s own behavior — pinned
37/// by a differential test that lexes every vendored 0.0.6 package and
38/// fixture through both `lex(src)` and `lex_with_version(src, V0_0)`,
39/// asserting identical token streams
40/// (`crates/rustyfi-syntax/tests/lex_with_version_differential.rs`).
41pub fn lex_with_version(src: &str, version: RustyfiVersion) -> Result<Vec<Atom>, LexError> {
42    match Lexer::new(src, Mode::Program, version).run() {
43        (atoms, None) => Ok(atoms),
44        (_, Some(e)) => Err(e),
45    }
46}
47
48/// [`lex_with_version`], keeping the tokens produced **before** a failure
49/// instead of discarding them.
50///
51/// The tokens are identical to [`lex_with_version`]'s up to the failure point;
52/// past it there are none, and the returned [`LexError`] is the same one
53/// [`lex_with_version`] would have returned. A successful lex returns
54/// `(atoms, None)` and ends with [`Token::Eoi`] as usual; a failed one has no
55/// `Eoi`, because the input never reached one.
56///
57/// Exists for the language server, where the buffer is half-typed by
58/// definition and the commonest failures are *local*: `{\emp` with the command
59/// not yet applied to anything is "unexpected token in an active area", and
60/// `'<+p` with no closing `>` is an unterminated group. Both leave every token
61/// before the cursor perfectly well-formed, and those are exactly the tokens
62/// that say which text area the cursor is in and which names are in scope
63/// there. Discarding them costs completion its two most important cases.
64///
65/// The compiler keeps using [`lex_with_version`]: a partial token stream is
66/// the right input for an editor question and the wrong input for a build.
67pub fn lex_partial(src: &str, version: RustyfiVersion) -> (Vec<Atom>, Option<LexError>) {
68    Lexer::new(src, Mode::Program, version).run()
69}
70
71struct Lexer {
72    chars: Vec<char>,
73    pos: usize,
74    line: u32,
75    col: u32,
76    byte: usize,
77    stack: Vec<Mode>,
78    out: Vec<Atom>,
79    version: RustyfiVersion,
80}
81
82fn is_small(c: char) -> bool {
83    c.is_ascii_lowercase()
84}
85fn is_capital(c: char) -> bool {
86    c.is_ascii_uppercase()
87}
88fn is_digit(c: char) -> bool {
89    c.is_ascii_digit()
90}
91fn is_hex(c: char) -> bool {
92    c.is_ascii_digit() || ('A'..='F').contains(&c)
93}
94fn is_ident_char(c: char) -> bool {
95    c.is_ascii_alphanumeric() || c == '-'
96}
97fn is_space(c: char) -> bool {
98    c == ' ' || c == '\t'
99}
100fn is_break(c: char) -> bool {
101    c == '\n' || c == '\r'
102}
103fn is_opsymbol(c: char) -> bool {
104    matches!(
105        c,
106        '+' | '-' | '*' | '/' | '^' | '&' | '|' | '!' | ':' | '=' | '<' | '>' | '~' | '\'' | '.'
107            | '?'
108    )
109}
110/// The `symbol` class: printable ASCII that `\`-escapes to itself in text.
111fn is_symbol_char(c: char) -> bool {
112    matches!(c, ' '..='@' | '['..='`' | '{'..='~')
113}
114/// The `str` class: a plain inline-text character.
115fn is_str_char(c: char) -> bool {
116    !matches!(
117        c,
118        ' ' | '\t' | '\n' | '\r' | '@' | '`' | '\\' | '{' | '}' | '<' | '>' | '%' | '|' | '*'
119            | '$' | '#' | ';'
120    )
121}
122fn is_mathsymbol_top(c: char) -> bool {
123    matches!(c, '+' | '-' | '*' | '/' | ':' | '=' | '<' | '>' | '~' | '.' | ',' | '`')
124}
125/// v0.0.6's `mathsymbol` (`lexer.mll:129`) — `mathsymboltop` plus `?`. Kept
126/// as documentation of the source class; the math lexer emits one token per
127/// symbol (see its `is_mathsymbol_top` arm) and `?` is claimed earlier by
128/// `?:`/`?*`, so nothing consumes a RUN of these any more.
129#[allow(dead_code)]
130fn is_mathsymbol(c: char) -> bool {
131    is_mathsymbol_top(c) || c == '?'
132}
133
134impl Lexer {
135    fn new(src: &str, initial: Mode, version: RustyfiVersion) -> Self {
136        Lexer {
137            chars: src.chars().collect(),
138            pos: 0,
139            line: 1,
140            col: 0,
141            byte: 0,
142            stack: vec![initial],
143            out: Vec::new(),
144            version,
145        }
146    }
147
148    /// Look up a scanned identifier-shaped word against the keyword table.
149    /// The base table (every 0.0.6 keyword) is version-independent — under
150    /// `V0_1` these words still lex the same way (e.g. `let-rec`/`when`/
151    /// `while`/`before` simply have no corresponding grammar rule in
152    /// `cst_v1.rs`, so using them there is a *parse* error, not a lex
153    /// error). Only the 0.1
154    /// additions (`rec`/`inline`/`block`/`mutable`/`signature`/
155    /// `include`/`use`/`package`/`math`/`persistent`) are version-gated:
156    /// SATySFi 0.1 needs them as keywords, but 0.0.6 source may use any of
157    /// them as an ordinary identifier (no 0.0.6 grammar would want them
158    /// reserved), so gating is what keeps `lex`/`lex_with_version(_, V0_0)`
159    /// byte-identical.
160    fn keyword(&self, s: &str) -> Option<Token> {
161        use Token::*;
162        if let Some(tok) = match s {
163            // `not` is deliberately NOT reserved — see the note beside
164            // `token.rs`'s `Token::Mod`.
165            "mod" => Some(Mod),
166            "if" => Some(If),
167            "then" => Some(Then),
168            "else" => Some(Else),
169            "let" => Some(Let),
170            "let-rec" => Some(LetRec),
171            "and" => Some(LetAnd),
172            "in" => Some(In),
173            "fun" => Some(Fun),
174            "true" => Some(True),
175            "false" => Some(False),
176            "before" => Some(Before),
177            "while" => Some(While),
178            "do" => Some(Do),
179            "let-mutable" => Some(LetMutable),
180            "match" => Some(Match),
181            "with" => Some(With),
182            "when" => Some(When),
183            "as" => Some(As),
184            "type" => Some(Type),
185            "of" => Some(Of),
186            "module" => Some(Module),
187            "struct" => Some(Struct),
188            "sig" => Some(Sig),
189            "val" => Some(Val),
190            "end" => Some(End),
191            "direct" => Some(Direct),
192            "constraint" => Some(Constraint),
193            "let-inline" => Some(LetHorz),
194            "let-block" => Some(LetVert),
195            "let-math" => Some(LetMath),
196            "controls" => Some(Controls),
197            "cycle" => Some(Cycle),
198            "inline-cmd" => Some(HorzCmdType),
199            "block-cmd" => Some(VertCmdType),
200            "math-cmd" => Some(MathCmdType),
201            "command" => Some(Command),
202            "open" => Some(Open),
203            _ => None,
204        } {
205            return Some(tok);
206        }
207        if self.version == RustyfiVersion::V0_1 {
208            return match s {
209                "rec" => Some(Rec),
210                "inline" => Some(Inline),
211                "block" => Some(Block),
212                "mutable" => Some(Mutable),
213                "signature" => Some(Signature),
214                "include" => Some(Include),
215                "use" => Some(Use),
216                "package" => Some(Package),
217                "math" => Some(Math),
218                "persistent" => Some(Persistent),
219                _ => None,
220            };
221        }
222        None
223    }
224
225    fn loc(&self) -> Loc {
226        Loc {
227            line: self.line,
228            col: self.col,
229            byte: self.byte,
230        }
231    }
232
233    fn peek(&self) -> Option<char> {
234        self.chars.get(self.pos).copied()
235    }
236
237    fn peek_at(&self, k: usize) -> Option<char> {
238        self.chars.get(self.pos + k).copied()
239    }
240
241    fn bump(&mut self) -> char {
242        let c = self.chars[self.pos];
243        self.pos += 1;
244        self.byte += c.len_utf8();
245        if c == '\n' || (c == '\r' && self.peek() != Some('\n')) {
246            self.line += 1;
247            self.col = 0;
248        } else {
249            self.col += 1;
250        }
251        c
252    }
253
254    fn bump_n(&mut self, n: usize) {
255        for _ in 0..n {
256            self.bump();
257        }
258    }
259
260    fn scan_while(&mut self, pred: impl Fn(char) -> bool) -> String {
261        let mut s = String::new();
262        while let Some(c) = self.peek() {
263            if pred(c) {
264                s.push(self.bump());
265            } else {
266                break;
267            }
268        }
269        s
270    }
271
272    /// Number of chars from `pos + k` matching `pred` (pure lookahead).
273    fn count_at(&self, k: usize, pred: impl Fn(char) -> bool) -> usize {
274        let mut n = 0;
275        while self.peek_at(k + n).is_some_and(&pred) {
276            n += 1;
277        }
278        n
279    }
280
281    fn emit(&mut self, start: Loc, tok: Token) {
282        self.out.push(Atom {
283            slot: tok,
284            span: Span::new(start, self.loc()),
285        });
286    }
287
288    fn error<T>(&self, start: Loc, msg: impl Into<String>) -> Result<T, LexError> {
289        Err(LexError {
290            span: Span::new(start, self.loc()),
291            msg: msg.into(),
292        })
293    }
294
295    fn push_mode(&mut self, m: Mode) {
296        self.stack.push(m);
297    }
298
299    fn pop_mode(&mut self, start: Loc, errmsg: &str) -> Result<(), LexError> {
300        if self.stack.len() > 1 {
301            self.stack.pop();
302            Ok(())
303        } else {
304            self.error(start, errmsg)
305        }
306    }
307
308    /// `comment`: skip `%` up to and including the line break (or EOF).
309    fn comment(&mut self) {
310        while let Some(c) = self.peek() {
311            self.bump();
312            if is_break(c) {
313                break;
314            }
315        }
316    }
317
318    /// `skip_spaces`: skip spaces, breaks, and `%` comments.
319    fn skip_spaces(&mut self) {
320        while let Some(c) = self.peek() {
321            if is_space(c) || is_break(c) {
322                self.bump();
323            } else if c == '%' {
324                self.bump();
325                self.comment();
326            } else {
327                break;
328            }
329        }
330    }
331
332    /// Drive the mode machine to end of input, or to the first failure.
333    ///
334    /// Returns the tokens produced either way; `lex_with_version` throws the
335    /// partial ones away and `lex_partial` keeps them, which is the whole
336    /// difference between the two.
337    fn run(mut self) -> (Vec<Atom>, Option<LexError>) {
338        loop {
339            let step = match self.stack.last().copied().expect("mode stack never empty") {
340                Mode::Program => self.lex_program(),
341                Mode::Vertical => self.lex_vertical(),
342                Mode::Horizontal => self.lex_horizontal(),
343                Mode::Active => self.lex_active(),
344                Mode::Math => self.lex_math(),
345            };
346            if let Err(e) = step {
347                return (self.out, Some(e));
348            }
349            if matches!(self.out.last(), Some(a) if a.slot == Token::Eoi) {
350                return (self.out, None);
351            }
352        }
353    }
354
355    // ---- shared sub-scanners -------------------------------------------------
356
357    /// `identifier | constructor` at offset `k`: returns its char length.
358    fn name_len_at(&self, k: usize) -> Option<usize> {
359        let c = self.peek_at(k)?;
360        if is_small(c) || is_capital(c) {
361            Some(1 + self.count_at(k + 1, is_ident_char))
362        } else {
363            None
364        }
365    }
366
367    /// `identifier` (lowercase-initial only) at offset `k`.
368    fn ident_len_at(&self, k: usize) -> Option<usize> {
369        let c = self.peek_at(k)?;
370        if is_small(c) {
371            Some(1 + self.count_at(k + 1, is_ident_char))
372        } else {
373            None
374        }
375    }
376
377    fn take(&mut self, n: usize) -> String {
378        let mut s = String::with_capacity(n);
379        for _ in 0..n {
380            s.push(self.bump());
381        }
382        s
383    }
384
385    /// `(constructor ".")* (identifier | constructor)` after a sigil: the
386    /// dotted command/variable path. Returns `(modules, last, last_is_ctor)`.
387    fn scan_dotted(&mut self) -> Option<(Vec<String>, String, bool)> {
388        let mut mods = Vec::new();
389        loop {
390            let len = self.name_len_at(0)?;
391            let is_ctor = is_capital(self.peek().unwrap());
392            // A constructor followed by `.` and another name continues the path.
393            if is_ctor
394                && self.peek_at(len) == Some('.')
395                && self.name_len_at(len + 1).is_some()
396            {
397                let seg = self.take(len);
398                self.bump(); // `.`
399                mods.push(seg);
400                continue;
401            }
402            let last = self.take(len);
403            return Some((mods, last, is_ctor));
404        }
405    }
406
407    /// Read a backtick literal body, after the opening backticks are consumed:
408    /// returns the raw body and whether trailing space is omitted (a `#`
409    /// immediately after the closing backticks sets `omit_post = false`).
410    fn read_literal_body(&mut self, start: Loc, quote_len: usize) -> Result<(String, bool), LexError> {
411        let mut body = String::new();
412        loop {
413            match self.peek() {
414                None => return self.error(start, "unexpected end of input while reading literal area"),
415                Some('`') => {
416                    let run = self.scan_while(|c| c == '`');
417                    if run.len() < quote_len {
418                        body.push_str(&run);
419                    } else if run.len() > quote_len {
420                        return self.error(start, "literal area was closed with too many '`'s");
421                    } else {
422                        let omit_post = if self.peek() == Some('#') {
423                            self.bump();
424                            false
425                        } else {
426                            true
427                        };
428                        return Ok((body, omit_post));
429                    }
430                }
431                Some(c) => {
432                    body.push(c);
433                    self.bump();
434                }
435            }
436        }
437    }
438
439    /// Program- / math-mode backtick string literal → `Token::Literal`.
440    fn literal(&mut self, start: Loc, quote_len: usize, omit_pre: bool) -> Result<(), LexError> {
441        let (body, omit_post) = self.read_literal_body(start, quote_len)?;
442        self.emit(start, Token::Literal { body, omit_pre, omit_post });
443        Ok(())
444    }
445
446    /// Horizontal-mode (inline-text) backtick literal, lexed to its own
447    /// `Token::CodeText` so the elaborator can route it through the context's
448    /// code-text command the way upstream does. (Not a plain `Token::Char`
449    /// run — see that token's doc comment for what that costs. A dedicated
450    /// token also keeps the `Vec<InlineElem>` collection parser
451    /// single-token-decidable, which a character-level `InlineElem::Literal`
452    /// arm could not manage.) The
453    /// `#`-controlled flags trim the body's leading (`omit_pre`) / trailing
454    /// (`omit_post`) spaces, mirroring the elaborator's `omit_pre_spaces`/
455    /// `omit_post_spaces` (the multi-line indent shave `omit_spaces` also does
456    /// is unnecessary for the single-line inline spans this path handles).
457    fn literal_horz(&mut self, start: Loc, quote_len: usize, omit_pre: bool) -> Result<(), LexError> {
458        let (body, omit_post) = self.read_literal_body(start, quote_len)?;
459        let mut text: &str = &body;
460        if omit_pre {
461            text = text.trim_start_matches(' ');
462        }
463        if omit_post {
464            text = text.trim_end_matches(' ');
465        }
466        self.emit(start, Token::CodeText(text.to_string()));
467        Ok(())
468    }
469
470    /// Try to match a length constant `-? (digit+ | digit+ "." digit* | "." digit+) identifier`
471    /// as pure lookahead. Returns `(total_len, numeric_len, unit_len)`.
472    fn length_lookahead(&self) -> Option<(usize, usize, usize)> {
473        let mut k = 0;
474        if self.peek_at(k) == Some('-') {
475            k += 1;
476        }
477        let int_digits = self.count_at(k, is_digit);
478        k += int_digits;
479        let mut frac_digits = 0;
480        let mut has_dot = false;
481        if self.peek_at(k) == Some('.') {
482            has_dot = true;
483            frac_digits = self.count_at(k + 1, is_digit);
484            k += 1 + frac_digits;
485        }
486        if int_digits == 0 && frac_digits == 0 {
487            return None;
488        }
489        // `.5` needs digits after the dot; `5.` is fine.
490        if int_digits == 0 && !has_dot {
491            return None;
492        }
493        let numeric_len = k;
494        let unit_len = self.ident_len_at(k)?;
495        Some((numeric_len + unit_len, numeric_len, unit_len))
496    }
497
498    // ---- program mode (progexpr) ---------------------------------------------
499
500    fn lex_program(&mut self) -> Result<(), LexError> {
501        loop {
502            let start = self.loc();
503            let Some(c) = self.peek() else {
504                if self.stack.len() == 1 {
505                    self.emit(start, Token::Eoi);
506                    return Ok(());
507                }
508                return self.error(start, "text input ended while reading a program area");
509            };
510            match c {
511                '%' => {
512                    self.bump();
513                    self.comment();
514                }
515                _ if is_space(c) || is_break(c) => {
516                    self.bump();
517                }
518                '@' => {
519                    self.bump();
520                    return self.lex_header(start);
521                }
522                '(' => {
523                    self.bump();
524                    if self.peek() == Some('|') {
525                        self.bump();
526                        self.push_mode(Mode::Program);
527                        self.emit(start, Token::BRecord);
528                    } else {
529                        self.push_mode(Mode::Program);
530                        self.emit(start, Token::LParen);
531                    }
532                    return Ok(());
533                }
534                ')' => {
535                    self.bump();
536                    self.pop_mode(start, "too many closing")?;
537                    self.emit(start, Token::RParen);
538                    return Ok(());
539                }
540                '[' => {
541                    self.bump();
542                    self.push_mode(Mode::Program);
543                    self.emit(start, Token::BList);
544                    return Ok(());
545                }
546                ']' => {
547                    self.bump();
548                    if self.peek() == Some('>') {
549                        self.bump();
550                        self.emit(start, Token::EPath);
551                    } else {
552                        self.pop_mode(start, "too many closing")?;
553                        self.emit(start, Token::EList);
554                    }
555                    return Ok(());
556                }
557                ';' => {
558                    self.bump();
559                    self.emit(start, Token::ListPunct);
560                    return Ok(());
561                }
562                '{' => {
563                    self.bump();
564                    self.push_mode(Mode::Horizontal);
565                    self.skip_spaces();
566                    self.emit(start, Token::BHorzGrp);
567                    return Ok(());
568                }
569                '\'' => {
570                    self.bump();
571                    if self.peek() == Some('<') {
572                        self.bump();
573                        self.push_mode(Mode::Vertical);
574                        self.emit(start, Token::BVertGrp);
575                    } else if let Some(len) = self.ident_len_at(0) {
576                        let name = self.take(len);
577                        self.emit(start, Token::TypeVar(name));
578                    } else {
579                        return self.error(start, "illegal token '''");
580                    }
581                    return Ok(());
582                }
583                '$' => {
584                    self.bump();
585                    if self.peek() == Some('{') {
586                        self.bump();
587                        self.push_mode(Mode::Math);
588                        self.emit(start, Token::BMathGrp);
589                        return Ok(());
590                    }
591                    return self.error(start, "illegal token '$' in a program area");
592                }
593                '`' => {
594                    let quotes = self.scan_while(|c| c == '`');
595                    self.literal(start, quotes.len(), true)?;
596                    return Ok(());
597                }
598                '#' => {
599                    self.bump();
600                    if self.peek() == Some('`') {
601                        let quotes = self.scan_while(|c| c == '`');
602                        self.literal(start, quotes.len(), false)?;
603                    } else {
604                        self.emit(start, Token::Access);
605                    }
606                    return Ok(());
607                }
608                '\\' => {
609                    // `command \cmd` / `command \Mod.cmd` (gap 4 of the
610                    // V0_1 language-completeness sweep): a first-class
611                    // `command`-value in program position must accept a
612                    // module-qualified name exactly like inline-text mode's
613                    // own `\` handling does (`lex_horizontal`, below), so
614                    // this scans a dotted path and emits
615                    // `Token::HorzCmdWithMod` on a `Mod.` prefix. No
616                    // mode-stack change either way — program mode never
617                    // pushes a new mode for a bare `\cmd` atomic, unlike
618                    // inline-text mode.
619                    self.bump();
620                    let Some((mods, name, _)) = self.scan_dotted() else {
621                        return self.error(start, "illegal token '\\' in a program area");
622                    };
623                    let cmd_name = format!("\\{name}");
624                    if mods.is_empty() {
625                        if self.peek() == Some('@') {
626                            self.bump();
627                            self.emit(start, Token::HorzMacro(format!("{cmd_name}@")));
628                        } else {
629                            self.emit(start, Token::HorzCmd(cmd_name));
630                        }
631                    } else {
632                        self.emit(start, Token::HorzCmdWithMod(mods, cmd_name));
633                    }
634                    return Ok(());
635                }
636                '+' => {
637                    self.bump();
638                    if let Some(len) = self.name_len_at(0) {
639                        let name = format!("+{}", self.take(len));
640                        if self.peek() == Some('@') {
641                            self.bump();
642                            self.emit(start, Token::VertMacro(format!("{name}@")));
643                        } else {
644                            self.emit(start, Token::VertCmd(name));
645                        }
646                    } else {
647                        let run = format!("+{}", self.scan_while(is_opsymbol));
648                        self.emit(start, Token::BinopPlus(run));
649                    }
650                    return Ok(());
651                }
652                ',' => {
653                    self.bump();
654                    self.emit(start, Token::Comma);
655                    return Ok(());
656                }
657                '_' => {
658                    self.bump();
659                    self.emit(start, Token::Wildcard);
660                    return Ok(());
661                }
662                '-' => {
663                    if let Some((total, num_len, unit_len)) = self.length_lookahead() {
664                        let num: String = self.take(num_len);
665                        let unit: String = self.take(unit_len);
666                        debug_assert_eq!(total, num.chars().count() + unit.chars().count());
667                        let value: f64 = num.parse().map_err(|_| LexError {
668                            span: Span::new(start, self.loc()),
669                            msg: format!("malformed length constant '{num}{unit}'"),
670                        })?;
671                        self.emit(start, Token::LengthConst(value, unit));
672                        return Ok(());
673                    }
674                    let run = self.scan_while(is_opsymbol);
675                    let tok = match run.as_str() {
676                        "-" => Token::ExactMinus,
677                        "--" => Token::PathLine,
678                        "->" => Token::Arrow,
679                        _ => Token::BinopMinus(run),
680                    };
681                    self.emit(start, tok);
682                    return Ok(());
683                }
684                '*' => {
685                    let run = self.scan_while(is_opsymbol);
686                    let tok = if run == "*" {
687                        Token::ExactTimes
688                    } else {
689                        Token::BinopTimes(run)
690                    };
691                    self.emit(start, tok);
692                    return Ok(());
693                }
694                '/' => {
695                    let run = self.scan_while(is_opsymbol);
696                    self.emit(start, Token::BinopDivides(run));
697                    return Ok(());
698                }
699                '=' => {
700                    let run = self.scan_while(is_opsymbol);
701                    let tok = if run == "=" {
702                        Token::DefEq
703                    } else {
704                        Token::BinopEq(run)
705                    };
706                    self.emit(start, tok);
707                    return Ok(());
708                }
709                '<' => {
710                    if self.peek_at(1) == Some('[') {
711                        self.bump_n(2);
712                        self.emit(start, Token::BPath);
713                        return Ok(());
714                    }
715                    let run = self.scan_while(is_opsymbol);
716                    let tok = if run == "<-" {
717                        Token::OverwriteEq
718                    } else {
719                        Token::BinopLt(run)
720                    };
721                    self.emit(start, tok);
722                    return Ok(());
723                }
724                '>' => {
725                    let run = self.scan_while(is_opsymbol);
726                    self.emit(start, Token::BinopGt(run));
727                    return Ok(());
728                }
729                '&' => {
730                    let run = self.scan_while(is_opsymbol);
731                    let tok = if run == "&" {
732                        Token::ExactAmp
733                    } else {
734                        Token::BinopAmp(run)
735                    };
736                    self.emit(start, tok);
737                    return Ok(());
738                }
739                '|' => {
740                    if self.peek_at(1) == Some(')') {
741                        self.bump_n(2);
742                        self.pop_mode(start, "too many closing")?;
743                        self.emit(start, Token::ERecord);
744                        return Ok(());
745                    }
746                    let run = self.scan_while(is_opsymbol);
747                    let tok = if run == "|" {
748                        Token::Bar
749                    } else {
750                        Token::BinopBar(run)
751                    };
752                    self.emit(start, tok);
753                    return Ok(());
754                }
755                '^' => {
756                    let run = self.scan_while(is_opsymbol);
757                    self.emit(start, Token::BinopHat(run));
758                    return Ok(());
759                }
760                '!' => {
761                    let run = self.scan_while(is_opsymbol);
762                    self.emit(start, Token::UnopExclam(run));
763                    return Ok(());
764                }
765                '~' => {
766                    self.bump();
767                    self.emit(start, Token::ExactTilde);
768                    return Ok(());
769                }
770                ':' => {
771                    self.bump();
772                    if self.peek() == Some(':') {
773                        self.bump();
774                        self.emit(start, Token::Cons);
775                    } else if self.version == RustyfiVersion::V0_1 && self.peek() == Some('>') {
776                        // 0.1's COERCE `:>` (lexer_v1.mll:280). Tried AFTER
777                        // `::` so `::>` still lexes `Cons` + `BinopGt`, the
778                        // same longest/first-match ocamllex resolves
779                        // (lexer_v1.mll:280 vs :288 — `::` wins on `::>`).
780                        // V0_1-gated: under V0_0, `:>` stays `Colon` +
781                        // `BinopGt(">")` — the differential test pins it.
782                        self.bump();
783                        self.emit(start, Token::Coerce);
784                    } else {
785                        self.emit(start, Token::Colon);
786                    }
787                    return Ok(());
788                }
789                '.' => {
790                    if self.peek_at(1) == Some('.') {
791                        self.bump_n(2);
792                        self.emit(start, Token::PathCurve);
793                        return Ok(());
794                    }
795                    if self.peek_at(1).is_some_and(is_digit) {
796                        return self.lex_number(start);
797                    }
798                    return self.error(start, "illegal token '.' in a program area");
799                }
800                '?' => {
801                    self.bump();
802                    // SATySFi 0.1 removed the fused `?:`/`?*`/`?->` optional
803                    // sigils: a bare `?` is the only `?`-headed token (it
804                    // heads a `?(l = e, …)` labeled-optional bundle, lexed as
805                    // `OptionalType` + a paren group). So under V0_1 emit only
806                    // `OptionalType`; `?:`/`?*`/`?->` then lex as `?` + `:`/`*`
807                    // /`->`, a downstream parse error, matching upstream.
808                    // Under V0_0 this stays byte-identical (pinned by
809                    // `lex_with_version_differential.rs`).
810                    //
811                    // Under V0_1, `?` directly
812                    // followed by `'` + a lowercase name (no space — one
813                    // lexeme, matching upstream `ROWVAR`, `lexer_v1.mll:310
814                    // -311`) is a row variable (`Token::RowVar`), e.g. `?'r`
815                    // in a row-var record tail `(| … | ?'r |)`. A SPACE
816                    // between `?` and `'r` (`? 'r`) must NOT fuse — and
817                    // naturally doesn't, since this whole match arm only
818                    // runs once per token (space-skipping happens between
819                    // token scans, not inside it), so `? 'r` lexes as two
820                    // separate tokens (`OptionalType` then `TypeVar`), same
821                    // as it always has.
822                    let tok = if self.version == RustyfiVersion::V0_1 {
823                        if self.peek() == Some('\'') {
824                            if let Some(len) = self.ident_len_at(1) {
825                                self.bump(); // consume the `'`
826                                let name = self.take(len);
827                                Token::RowVar(name)
828                            } else {
829                                Token::OptionalType
830                            }
831                        } else {
832                            Token::OptionalType
833                        }
834                    } else {
835                        match self.peek() {
836                            Some(':') => {
837                                self.bump();
838                                Token::Optional
839                            }
840                            Some('*') => {
841                                self.bump();
842                                Token::Omission
843                            }
844                            Some('-') if self.peek_at(1) == Some('>') => {
845                                self.bump_n(2);
846                                Token::OptionalArrow
847                            }
848                            _ => Token::OptionalType,
849                        }
850                    };
851                    self.emit(start, tok);
852                    return Ok(());
853                }
854                _ if is_digit(c) => {
855                    return self.lex_number(start);
856                }
857                _ if is_small(c) => {
858                    let len = self.name_len_at(0).unwrap();
859                    let name = self.take(len);
860                    let tok = self.keyword(&name).unwrap_or(Token::Var(name));
861                    self.emit(start, tok);
862                    return Ok(());
863                }
864                _ if is_capital(c) => {
865                    let (mods, last, last_is_ctor) = self.scan_dotted().unwrap();
866                    if mods.is_empty() && last_is_ctor {
867                        // `Mod.(` opens the module scope inline.
868                        if self.peek() == Some('.') && self.peek_at(1) == Some('(') {
869                            self.bump_n(2);
870                            self.push_mode(Mode::Program);
871                            self.emit(start, Token::OpenModule(last));
872                        } else {
873                            self.emit(start, Token::Constructor(last));
874                        }
875                    } else if last_is_ctor {
876                        if self.version == RustyfiVersion::V0_1 {
877                            // 0.1's LONG_UPPER (lexer_v1.mll:357-363):
878                            // `A.B.C` is a module/signature path token.
879                            self.emit(start, Token::LongUpper(mods, last));
880                        } else {
881                            // 0.0.6: unchanged — the exact error string is
882                            // pinned by the differential test (Err/Err arm).
883                            return self.error(start, "module path must end with a variable name");
884                        }
885                    } else {
886                        self.emit(start, Token::VarWithMod(mods, last));
887                    }
888                    return Ok(());
889                }
890                _ => {
891                    self.bump();
892                    return self.error(start, format!("illegal token '{c}' in a program area"));
893                }
894            }
895        }
896    }
897
898    /// `@require:`/`@import:`/`@stage:` headers (the `@` is already consumed).
899    fn lex_header(&mut self, start: Loc) -> Result<(), LexError> {
900        if self.peek() == Some('`') {
901            return self.error(start, "positioned string literals '@`' are not supported yet");
902        }
903        let Some(len) = self.ident_len_at(0) else {
904            return self.error(start, "illegal token '@' in a program area");
905        };
906        let headertype = self.take(len);
907        if self.peek() != Some(':') {
908            return self.error(start, format!("undefined header type '{headertype}'"));
909        }
910        self.bump();
911        while self.peek() == Some(' ') {
912            self.bump();
913        }
914        let mut content = String::new();
915        while let Some(c) = self.peek() {
916            if is_break(c) {
917                self.bump();
918                // A CRLF is ONE line break, and this token must take both
919                // halves of it or neither. Taking only the `\r` put a TOKEN
920                // SPAN BOUNDARY inside the pair, and everything downstream
921                // that reasons about "the byte before this gap" then saw a
922                // lone `\r` with an orphan `\n` sitting in the gap: the LSP
923                // formatter counted that `\n` as ending a blank line of its
924                // own (so a run after a header capped one line short), and
925                // with the cap at 0 it deleted the `\n` outright and left a
926                // bare `\r` in a CRLF file.
927                if c == '\r' && self.peek() == Some('\n') {
928                    self.bump();
929                }
930                break;
931            }
932            content.push(self.bump());
933        }
934        let tok = match headertype.as_str() {
935            "require" => Token::HeaderRequire(content),
936            "import" => Token::HeaderImport(content),
937            "stage" => {
938                // SATySFi 0.1's lexer dropped the `"stage" -> HEADER_STAGE*`
939                // arm entirely: staging is per-binding (`val ~x`, `val
940                // persistent ~x`) rather than a whole-file `@stage:` header.
941                // Seeing `@stage:` under `V0_1` is therefore a real, direct
942                // lex-level signal that the source is not valid 0.1 (mirrors
943                // `version::sniff_version`'s own use of this fact).
944                if self.version == RustyfiVersion::V0_1 {
945                    return self.error(
946                        start,
947                        "the '@stage:' header does not exist in SATySFi 0.1 \
948                         (staging is per-binding there: 'val ~x' / 'val persistent ~x')",
949                    );
950                }
951                match content.as_str() {
952                    "persistent" => Token::HeaderPersistent0,
953                    "0" => Token::HeaderStage0,
954                    "1" => Token::HeaderStage1,
955                    _ => {
956                        return self.error(
957                            start,
958                            format!(
959                                "undefined stage type '{content}'; should be 'persistent', '0', or '1'."
960                            ),
961                        )
962                    }
963                }
964            }
965            _ => return self.error(start, format!("undefined header type '{headertype}'")),
966        };
967        self.emit(start, tok);
968        Ok(())
969    }
970
971    /// Int / float / length constants starting with a digit or `.`.
972    fn lex_number(&mut self, start: Loc) -> Result<(), LexError> {
973        if self.peek() == Some('0')
974            && matches!(self.peek_at(1), Some('x') | Some('X'))
975            && self.peek_at(2).is_some_and(is_hex)
976        {
977            self.bump_n(2);
978            let hex = self.scan_while(is_hex);
979            let value = i64::from_str_radix(&hex, 16).map_err(|_| LexError {
980                span: Span::new(start, self.loc()),
981                msg: format!("malformed hexadecimal constant '0x{hex}'"),
982            })?;
983            self.emit(start, Token::IntConst(value));
984            return Ok(());
985        }
986        if let Some((_, num_len, unit_len)) = self.length_lookahead() {
987            let num = self.take(num_len);
988            let unit = self.take(unit_len);
989            let value: f64 = num.parse().unwrap_or_default();
990            self.emit(start, Token::LengthConst(value, unit));
991            return Ok(());
992        }
993        let int_part = self.scan_while(is_digit);
994        if self.peek() == Some('.')
995            && (self.peek_at(1).is_some_and(is_digit) || !int_part.is_empty())
996        {
997            self.bump();
998            let frac = self.scan_while(is_digit);
999            let text = format!("{int_part}.{frac}");
1000            let value: f64 = text.parse().unwrap_or_default();
1001            self.emit(start, Token::FloatConst(value));
1002        } else {
1003            let value: i64 = int_part.parse().map_err(|_| LexError {
1004                span: Span::new(start, self.loc()),
1005                msg: format!("malformed integer constant '{int_part}'"),
1006            })?;
1007            self.emit(start, Token::IntConst(value));
1008        }
1009        Ok(())
1010    }
1011
1012    // ---- vertical mode (vertexpr) ---------------------------------------------
1013
1014    fn lex_vertical(&mut self) -> Result<(), LexError> {
1015        loop {
1016            let start = self.loc();
1017            let Some(c) = self.peek() else {
1018                if self.stack.len() == 1 {
1019                    self.emit(start, Token::Eoi);
1020                    return Ok(());
1021                }
1022                return self.error(start, "unexpected end of input while reading a vertical area");
1023            };
1024            match c {
1025                '%' => {
1026                    self.bump();
1027                    self.comment();
1028                }
1029                _ if is_space(c) || is_break(c) => {
1030                    self.bump();
1031                }
1032                '#' => {
1033                    self.bump();
1034                    let Some((mods, name, _)) = self.scan_dotted() else {
1035                        return self.error(start, "unexpected character '#' in a vertical area");
1036                    };
1037                    self.push_mode(Mode::Active);
1038                    self.emit(start, Token::VarInVert(mods, name));
1039                    return Ok(());
1040                }
1041                '+' => {
1042                    self.bump();
1043                    let Some((mods, name, _)) = self.scan_dotted() else {
1044                        return self.error(start, "unexpected character '+' in a vertical area");
1045                    };
1046                    self.push_mode(Mode::Active);
1047                    if mods.is_empty() {
1048                        if self.peek() == Some('@') {
1049                            self.bump();
1050                            self.emit(start, Token::VertMacro(format!("+{name}@")));
1051                        } else {
1052                            self.emit(start, Token::VertCmd(format!("+{name}")));
1053                        }
1054                    } else {
1055                        self.emit(start, Token::VertCmdWithMod(mods, format!("+{name}")));
1056                    }
1057                    return Ok(());
1058                }
1059                '<' => {
1060                    self.bump();
1061                    self.push_mode(Mode::Vertical);
1062                    self.emit(start, Token::BVertGrp);
1063                    return Ok(());
1064                }
1065                '>' => {
1066                    self.bump();
1067                    self.pop_mode(start, "too many closing")?;
1068                    self.emit(start, Token::EVertGrp);
1069                    return Ok(());
1070                }
1071                '{' => {
1072                    self.bump();
1073                    self.push_mode(Mode::Horizontal);
1074                    self.skip_spaces();
1075                    self.emit(start, Token::BHorzGrp);
1076                    return Ok(());
1077                }
1078                _ => {
1079                    self.bump();
1080                    return self.error(
1081                        start,
1082                        format!("unexpected character '{c}' in a vertical area"),
1083                    );
1084                }
1085            }
1086        }
1087    }
1088
1089    // ---- horizontal mode (horzexpr) --------------------------------------------
1090
1091    fn lex_horizontal(&mut self) -> Result<(), LexError> {
1092        loop {
1093            let start = self.loc();
1094            let Some(c) = self.peek() else {
1095                if self.stack.len() == 1 {
1096                    self.emit(start, Token::Eoi);
1097                    return Ok(());
1098                }
1099                return self.error(
1100                    start,
1101                    "unexpected end of input while reading an inline text area",
1102                );
1103            };
1104
1105            if c == '%' {
1106                self.bump();
1107                self.comment();
1108                self.skip_spaces();
1109                continue;
1110            }
1111
1112            // The `(break | space)* <terminator>` family: `{`, `}`, `<`, `|`, item.
1113            let ws = self.count_at(0, |c| is_space(c) || is_break(c));
1114            match self.peek_at(ws) {
1115                Some('{') => {
1116                    self.bump_n(ws + 1);
1117                    self.push_mode(Mode::Horizontal);
1118                    self.skip_spaces();
1119                    self.emit(start, Token::BHorzGrp);
1120                    return Ok(());
1121                }
1122                Some('}') => {
1123                    self.bump_n(ws + 1);
1124                    self.pop_mode(start, "too many closing")?;
1125                    self.emit(start, Token::EHorzGrp);
1126                    return Ok(());
1127                }
1128                Some('<') => {
1129                    self.bump_n(ws + 1);
1130                    self.push_mode(Mode::Vertical);
1131                    self.emit(start, Token::BVertGrp);
1132                    return Ok(());
1133                }
1134                Some('|') => {
1135                    self.bump_n(ws + 1);
1136                    self.skip_spaces();
1137                    self.emit(start, Token::Sep);
1138                    return Ok(());
1139                }
1140                Some('*') => {
1141                    self.bump_n(ws);
1142                    let stars = self.scan_while(|c| c == '*');
1143                    self.skip_spaces();
1144                    self.emit(start, Token::Item(stars.len()));
1145                    return Ok(());
1146                }
1147                _ => {}
1148            }
1149            if ws > 0 {
1150                let first = self.peek().unwrap();
1151                self.bump_n(ws);
1152                self.skip_spaces();
1153                self.emit(start, if is_break(first) { Token::Break } else { Token::Space });
1154                return Ok(());
1155            }
1156
1157            match c {
1158                '#' => {
1159                    self.bump();
1160                    if self.peek() == Some('`') {
1161                        let quotes = self.scan_while(|c| c == '`');
1162                        self.literal_horz(start, quotes.len(), false)?;
1163                        return Ok(());
1164                    }
1165                    let Some((mods, name, _)) = self.scan_dotted() else {
1166                        return self.error(start, "illegal token '#' in an inline text area");
1167                    };
1168                    self.push_mode(Mode::Active);
1169                    self.emit(start, Token::VarInHorz(mods, name));
1170                    return Ok(());
1171                }
1172                '\\' => {
1173                    self.bump();
1174                    if let Some((mods, name, _)) = self.scan_dotted() {
1175                        if mods.is_empty() {
1176                            if self.peek() == Some('@') {
1177                                self.bump();
1178                                self.push_mode(Mode::Active);
1179                                self.emit(start, Token::HorzMacro(format!("\\{name}@")));
1180                            } else {
1181                                self.push_mode(Mode::Active);
1182                                self.emit(start, Token::HorzCmd(format!("\\{name}")));
1183                            }
1184                        } else {
1185                            self.push_mode(Mode::Active);
1186                            self.emit(start, Token::HorzCmdWithMod(mods, format!("\\{name}")));
1187                        }
1188                        return Ok(());
1189                    }
1190                    if self.peek().is_some_and(is_symbol_char) {
1191                        let sym = self.bump();
1192                        self.emit(start, Token::Char(sym.to_string()));
1193                        return Ok(());
1194                    }
1195                    return self.error(start, "illegal token '\\' in an inline text area");
1196                }
1197                '$' => {
1198                    self.bump();
1199                    if self.peek() == Some('{') {
1200                        self.bump();
1201                        self.push_mode(Mode::Math);
1202                        self.emit(start, Token::BMathGrp);
1203                        return Ok(());
1204                    }
1205                    return self.error(start, "illegal token '$' in an inline text area");
1206                }
1207                '`' => {
1208                    let quotes = self.scan_while(|c| c == '`');
1209                    self.literal_horz(start, quotes.len(), true)?;
1210                    return Ok(());
1211                }
1212                _ if is_str_char(c) => {
1213                    let text = self.scan_while(is_str_char);
1214                    self.emit(start, Token::Char(text));
1215                    return Ok(());
1216                }
1217                _ => {
1218                    self.bump();
1219                    return self.error(
1220                        start,
1221                        format!("illegal token '{c}' in an inline text area"),
1222                    );
1223                }
1224            }
1225        }
1226    }
1227
1228    // ---- active mode ------------------------------------------------------------
1229
1230    fn lex_active(&mut self) -> Result<(), LexError> {
1231        loop {
1232            let start = self.loc();
1233            let Some(c) = self.peek() else {
1234                return self.error(start, "unexpected end of input while reading an active area");
1235            };
1236            match c {
1237                '%' => {
1238                    self.bump();
1239                    self.comment();
1240                }
1241                _ if is_space(c) || is_break(c) => {
1242                    self.bump();
1243                }
1244                '?' => {
1245                    self.bump();
1246                    // SATySFi 0.1: a command
1247                    // APPLIED in an active area (`\cmd ?(l = e){…}` /
1248                    // `+cmd ?(l = e)<…>`) carries a `?(l = e, …)` labeled-
1249                    // optional bundle — the `?` is `OptionalType` and the
1250                    // `(…)` group lexes via the `(` arm on the next scan,
1251                    // exactly like program-mode application (`lex_program`'s
1252                    // `?` arm). The fused `?:`/`?*` sigils no longer exist
1253                    // under V0_1. Under V0_0 this stays byte-identical
1254                    // (`?:`/`?*` handled, a bare `?` is still an error) —
1255                    // pinned by `lex_with_version_differential.rs`.
1256                    if self.version == RustyfiVersion::V0_1 {
1257                        self.emit(start, Token::OptionalType);
1258                        return Ok(());
1259                    }
1260                    match self.peek() {
1261                        Some(':') => {
1262                            self.bump();
1263                            self.emit(start, Token::Optional);
1264                        }
1265                        Some('*') => {
1266                            self.bump();
1267                            self.emit(start, Token::Omission);
1268                        }
1269                        _ => return self.error(start, "unexpected token '?' in an active area"),
1270                    }
1271                    return Ok(());
1272                }
1273                '~' => {
1274                    self.bump();
1275                    self.emit(start, Token::ExactTilde);
1276                    return Ok(());
1277                }
1278                '(' => {
1279                    self.bump();
1280                    if self.peek() == Some('|') {
1281                        self.bump();
1282                        self.push_mode(Mode::Program);
1283                        self.emit(start, Token::BRecord);
1284                    } else {
1285                        self.push_mode(Mode::Program);
1286                        self.emit(start, Token::LParen);
1287                    }
1288                    return Ok(());
1289                }
1290                '[' => {
1291                    self.bump();
1292                    self.push_mode(Mode::Program);
1293                    self.emit(start, Token::BList);
1294                    return Ok(());
1295                }
1296                '{' => {
1297                    self.bump();
1298                    self.pop_mode(start, "BUG; this cannot happen")?;
1299                    self.push_mode(Mode::Horizontal);
1300                    self.skip_spaces();
1301                    self.emit(start, Token::BHorzGrp);
1302                    return Ok(());
1303                }
1304                '<' => {
1305                    self.bump();
1306                    self.pop_mode(start, "BUG; this cannot happen")?;
1307                    self.push_mode(Mode::Vertical);
1308                    self.emit(start, Token::BVertGrp);
1309                    return Ok(());
1310                }
1311                ';' => {
1312                    self.bump();
1313                    self.pop_mode(start, "BUG; this cannot happen")?;
1314                    self.emit(start, Token::EndActive);
1315                    return Ok(());
1316                }
1317                _ => {
1318                    self.bump();
1319                    return self.error(start, format!("unexpected token '{c}' in an active area"));
1320                }
1321            }
1322        }
1323    }
1324
1325    // ---- math mode (mathexpr) -----------------------------------------------------
1326
1327    fn lex_math(&mut self) -> Result<(), LexError> {
1328        loop {
1329            let start = self.loc();
1330            let Some(c) = self.peek() else {
1331                return self.error(start, "unexpected end of file in a math area");
1332            };
1333            match c {
1334                '%' => {
1335                    self.bump();
1336                    self.comment();
1337                }
1338                _ if is_space(c) || is_break(c) => {
1339                    self.bump();
1340                }
1341                '?' => {
1342                    self.bump();
1343                    match self.peek() {
1344                        Some(':') => {
1345                            self.bump();
1346                            self.emit(start, Token::Optional);
1347                        }
1348                        Some('*') => {
1349                            self.bump();
1350                            self.emit(start, Token::Omission);
1351                        }
1352                        _ => return self.error(start, "illegal token '?' in a math area"),
1353                    }
1354                    return Ok(());
1355                }
1356                '!' => {
1357                    self.bump();
1358                    match self.peek() {
1359                        Some('{') => {
1360                            self.bump();
1361                            self.push_mode(Mode::Horizontal);
1362                            self.skip_spaces();
1363                            self.emit(start, Token::BHorzGrp);
1364                        }
1365                        Some('<') => {
1366                            self.bump();
1367                            self.push_mode(Mode::Vertical);
1368                            self.emit(start, Token::BVertGrp);
1369                        }
1370                        Some('(') => {
1371                            self.bump();
1372                            if self.peek() == Some('|') {
1373                                self.bump();
1374                                self.push_mode(Mode::Program);
1375                                self.emit(start, Token::BRecord);
1376                            } else {
1377                                self.push_mode(Mode::Program);
1378                                self.emit(start, Token::LParen);
1379                            }
1380                        }
1381                        Some('[') => {
1382                            self.bump();
1383                            self.push_mode(Mode::Program);
1384                            self.emit(start, Token::BList);
1385                        }
1386                        _ => return self.error(start, "illegal token '!' in a math area"),
1387                    }
1388                    return Ok(());
1389                }
1390                '{' => {
1391                    self.bump();
1392                    self.push_mode(Mode::Math);
1393                    self.emit(start, Token::BMathGrp);
1394                    return Ok(());
1395                }
1396                '}' => {
1397                    self.bump();
1398                    self.pop_mode(start, "too many closing")?;
1399                    self.emit(start, Token::EMathGrp);
1400                    return Ok(());
1401                }
1402                '|' => {
1403                    self.bump();
1404                    self.emit(start, Token::Sep);
1405                    return Ok(());
1406                }
1407                '^' => {
1408                    self.bump();
1409                    self.emit(start, Token::Superscript);
1410                    return Ok(());
1411                }
1412                '_' => {
1413                    self.bump();
1414                    self.emit(start, Token::Subscript);
1415                    return Ok(());
1416                }
1417                '\'' => {
1418                    let primes = self.scan_while(|c| c == '\'');
1419                    self.emit(start, Token::Primes(primes.len()));
1420                    return Ok(());
1421                }
1422                '#' => {
1423                    self.bump();
1424                    let Some((mods, name, _)) = self.scan_dotted() else {
1425                        return self.error(start, "illegal token '#' in a math area");
1426                    };
1427                    self.emit(start, Token::VarInMath(mods, name));
1428                    return Ok(());
1429                }
1430                '\\' => {
1431                    self.bump();
1432                    if let Some((mods, name, _)) = self.scan_dotted() {
1433                        if mods.is_empty() {
1434                            self.emit(start, Token::MathCmd(format!("\\{name}")));
1435                        } else {
1436                            self.emit(start, Token::MathCmdWithMod(mods, format!("\\{name}")));
1437                        }
1438                        return Ok(());
1439                    }
1440                    if self.peek().is_some_and(is_symbol_char) {
1441                        let sym = self.bump();
1442                        self.emit(start, Token::MathChar(sym.to_string()));
1443                        return Ok(());
1444                    }
1445                    return self.error(start, "illegal token '\\' in a math area");
1446                }
1447                // ONE token per math symbol, not per RUN of them.
1448                //
1449                // v0.0.6's rule is `mathsymboltop (mathsymbol*)`
1450                // (`lexer.mll:556`), a single `MATHCHAR` for the whole run —
1451                // and that run then misses `default_math_class_map`, whose
1452                // keys are all one character, so `${-------}` set SEVEN ASCII
1453                // hyphens where the reference sets seven U+2212 MINUS SIGNs
1454                // (latexcmds' `\overbrace…{\underbrace…{-------}}`, and the
1455                // whole of that document's `chars_missing`). Splitting makes
1456                // each symbol its own math ATOM, so each one hits the class
1457                // map on its own — which is also what the reference engine
1458                // does: `${a -- b}` sets `𝑎 − −𝑏` there, with binary spacing
1459                // BETWEEN the two minuses, which one atom cannot produce.
1460                //
1461                // `is_mathsymbol`'s extra `?` is consumed by the `'?'` arm
1462                // above (`?:`/`?*`) before this one is ever reached, so
1463                // dropping the run scan loses no token shape that was
1464                // reachable.
1465                _ if is_mathsymbol_top(c) => {
1466                    self.bump();
1467                    self.emit(start, Token::MathChar(c.to_string()));
1468                    return Ok(());
1469                }
1470                _ if c.is_ascii_alphanumeric() => {
1471                    self.bump();
1472                    self.emit(start, Token::MathChar(c.to_string()));
1473                    return Ok(());
1474                }
1475                _ => {
1476                    self.bump();
1477                    return self.error(start, format!("illegal token '{c}' in a math area"));
1478                }
1479            }
1480        }
1481    }
1482}