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                break;
919            }
920            content.push(self.bump());
921        }
922        let tok = match headertype.as_str() {
923            "require" => Token::HeaderRequire(content),
924            "import" => Token::HeaderImport(content),
925            "stage" => {
926                // SATySFi 0.1's lexer dropped the `"stage" -> HEADER_STAGE*`
927                // arm entirely: staging is per-binding (`val ~x`, `val
928                // persistent ~x`) rather than a whole-file `@stage:` header.
929                // Seeing `@stage:` under `V0_1` is therefore a real, direct
930                // lex-level signal that the source is not valid 0.1 (mirrors
931                // `version::sniff_version`'s own use of this fact).
932                if self.version == RustyfiVersion::V0_1 {
933                    return self.error(
934                        start,
935                        "the '@stage:' header does not exist in SATySFi 0.1 \
936                         (staging is per-binding there: 'val ~x' / 'val persistent ~x')",
937                    );
938                }
939                match content.as_str() {
940                    "persistent" => Token::HeaderPersistent0,
941                    "0" => Token::HeaderStage0,
942                    "1" => Token::HeaderStage1,
943                    _ => {
944                        return self.error(
945                            start,
946                            format!(
947                                "undefined stage type '{content}'; should be 'persistent', '0', or '1'."
948                            ),
949                        )
950                    }
951                }
952            }
953            _ => return self.error(start, format!("undefined header type '{headertype}'")),
954        };
955        self.emit(start, tok);
956        Ok(())
957    }
958
959    /// Int / float / length constants starting with a digit or `.`.
960    fn lex_number(&mut self, start: Loc) -> Result<(), LexError> {
961        if self.peek() == Some('0')
962            && matches!(self.peek_at(1), Some('x') | Some('X'))
963            && self.peek_at(2).is_some_and(is_hex)
964        {
965            self.bump_n(2);
966            let hex = self.scan_while(is_hex);
967            let value = i64::from_str_radix(&hex, 16).map_err(|_| LexError {
968                span: Span::new(start, self.loc()),
969                msg: format!("malformed hexadecimal constant '0x{hex}'"),
970            })?;
971            self.emit(start, Token::IntConst(value));
972            return Ok(());
973        }
974        if let Some((_, num_len, unit_len)) = self.length_lookahead() {
975            let num = self.take(num_len);
976            let unit = self.take(unit_len);
977            let value: f64 = num.parse().unwrap_or_default();
978            self.emit(start, Token::LengthConst(value, unit));
979            return Ok(());
980        }
981        let int_part = self.scan_while(is_digit);
982        if self.peek() == Some('.')
983            && (self.peek_at(1).is_some_and(is_digit) || !int_part.is_empty())
984        {
985            self.bump();
986            let frac = self.scan_while(is_digit);
987            let text = format!("{int_part}.{frac}");
988            let value: f64 = text.parse().unwrap_or_default();
989            self.emit(start, Token::FloatConst(value));
990        } else {
991            let value: i64 = int_part.parse().map_err(|_| LexError {
992                span: Span::new(start, self.loc()),
993                msg: format!("malformed integer constant '{int_part}'"),
994            })?;
995            self.emit(start, Token::IntConst(value));
996        }
997        Ok(())
998    }
999
1000    // ---- vertical mode (vertexpr) ---------------------------------------------
1001
1002    fn lex_vertical(&mut self) -> Result<(), LexError> {
1003        loop {
1004            let start = self.loc();
1005            let Some(c) = self.peek() else {
1006                if self.stack.len() == 1 {
1007                    self.emit(start, Token::Eoi);
1008                    return Ok(());
1009                }
1010                return self.error(start, "unexpected end of input while reading a vertical area");
1011            };
1012            match c {
1013                '%' => {
1014                    self.bump();
1015                    self.comment();
1016                }
1017                _ if is_space(c) || is_break(c) => {
1018                    self.bump();
1019                }
1020                '#' => {
1021                    self.bump();
1022                    let Some((mods, name, _)) = self.scan_dotted() else {
1023                        return self.error(start, "unexpected character '#' in a vertical area");
1024                    };
1025                    self.push_mode(Mode::Active);
1026                    self.emit(start, Token::VarInVert(mods, name));
1027                    return Ok(());
1028                }
1029                '+' => {
1030                    self.bump();
1031                    let Some((mods, name, _)) = self.scan_dotted() else {
1032                        return self.error(start, "unexpected character '+' in a vertical area");
1033                    };
1034                    self.push_mode(Mode::Active);
1035                    if mods.is_empty() {
1036                        if self.peek() == Some('@') {
1037                            self.bump();
1038                            self.emit(start, Token::VertMacro(format!("+{name}@")));
1039                        } else {
1040                            self.emit(start, Token::VertCmd(format!("+{name}")));
1041                        }
1042                    } else {
1043                        self.emit(start, Token::VertCmdWithMod(mods, format!("+{name}")));
1044                    }
1045                    return Ok(());
1046                }
1047                '<' => {
1048                    self.bump();
1049                    self.push_mode(Mode::Vertical);
1050                    self.emit(start, Token::BVertGrp);
1051                    return Ok(());
1052                }
1053                '>' => {
1054                    self.bump();
1055                    self.pop_mode(start, "too many closing")?;
1056                    self.emit(start, Token::EVertGrp);
1057                    return Ok(());
1058                }
1059                '{' => {
1060                    self.bump();
1061                    self.push_mode(Mode::Horizontal);
1062                    self.skip_spaces();
1063                    self.emit(start, Token::BHorzGrp);
1064                    return Ok(());
1065                }
1066                _ => {
1067                    self.bump();
1068                    return self.error(
1069                        start,
1070                        format!("unexpected character '{c}' in a vertical area"),
1071                    );
1072                }
1073            }
1074        }
1075    }
1076
1077    // ---- horizontal mode (horzexpr) --------------------------------------------
1078
1079    fn lex_horizontal(&mut self) -> Result<(), LexError> {
1080        loop {
1081            let start = self.loc();
1082            let Some(c) = self.peek() else {
1083                if self.stack.len() == 1 {
1084                    self.emit(start, Token::Eoi);
1085                    return Ok(());
1086                }
1087                return self.error(
1088                    start,
1089                    "unexpected end of input while reading an inline text area",
1090                );
1091            };
1092
1093            if c == '%' {
1094                self.bump();
1095                self.comment();
1096                self.skip_spaces();
1097                continue;
1098            }
1099
1100            // The `(break | space)* <terminator>` family: `{`, `}`, `<`, `|`, item.
1101            let ws = self.count_at(0, |c| is_space(c) || is_break(c));
1102            match self.peek_at(ws) {
1103                Some('{') => {
1104                    self.bump_n(ws + 1);
1105                    self.push_mode(Mode::Horizontal);
1106                    self.skip_spaces();
1107                    self.emit(start, Token::BHorzGrp);
1108                    return Ok(());
1109                }
1110                Some('}') => {
1111                    self.bump_n(ws + 1);
1112                    self.pop_mode(start, "too many closing")?;
1113                    self.emit(start, Token::EHorzGrp);
1114                    return Ok(());
1115                }
1116                Some('<') => {
1117                    self.bump_n(ws + 1);
1118                    self.push_mode(Mode::Vertical);
1119                    self.emit(start, Token::BVertGrp);
1120                    return Ok(());
1121                }
1122                Some('|') => {
1123                    self.bump_n(ws + 1);
1124                    self.skip_spaces();
1125                    self.emit(start, Token::Sep);
1126                    return Ok(());
1127                }
1128                Some('*') => {
1129                    self.bump_n(ws);
1130                    let stars = self.scan_while(|c| c == '*');
1131                    self.skip_spaces();
1132                    self.emit(start, Token::Item(stars.len()));
1133                    return Ok(());
1134                }
1135                _ => {}
1136            }
1137            if ws > 0 {
1138                let first = self.peek().unwrap();
1139                self.bump_n(ws);
1140                self.skip_spaces();
1141                self.emit(start, if is_break(first) { Token::Break } else { Token::Space });
1142                return Ok(());
1143            }
1144
1145            match c {
1146                '#' => {
1147                    self.bump();
1148                    if self.peek() == Some('`') {
1149                        let quotes = self.scan_while(|c| c == '`');
1150                        self.literal_horz(start, quotes.len(), false)?;
1151                        return Ok(());
1152                    }
1153                    let Some((mods, name, _)) = self.scan_dotted() else {
1154                        return self.error(start, "illegal token '#' in an inline text area");
1155                    };
1156                    self.push_mode(Mode::Active);
1157                    self.emit(start, Token::VarInHorz(mods, name));
1158                    return Ok(());
1159                }
1160                '\\' => {
1161                    self.bump();
1162                    if let Some((mods, name, _)) = self.scan_dotted() {
1163                        if mods.is_empty() {
1164                            if self.peek() == Some('@') {
1165                                self.bump();
1166                                self.push_mode(Mode::Active);
1167                                self.emit(start, Token::HorzMacro(format!("\\{name}@")));
1168                            } else {
1169                                self.push_mode(Mode::Active);
1170                                self.emit(start, Token::HorzCmd(format!("\\{name}")));
1171                            }
1172                        } else {
1173                            self.push_mode(Mode::Active);
1174                            self.emit(start, Token::HorzCmdWithMod(mods, format!("\\{name}")));
1175                        }
1176                        return Ok(());
1177                    }
1178                    if self.peek().is_some_and(is_symbol_char) {
1179                        let sym = self.bump();
1180                        self.emit(start, Token::Char(sym.to_string()));
1181                        return Ok(());
1182                    }
1183                    return self.error(start, "illegal token '\\' in an inline text area");
1184                }
1185                '$' => {
1186                    self.bump();
1187                    if self.peek() == Some('{') {
1188                        self.bump();
1189                        self.push_mode(Mode::Math);
1190                        self.emit(start, Token::BMathGrp);
1191                        return Ok(());
1192                    }
1193                    return self.error(start, "illegal token '$' in an inline text area");
1194                }
1195                '`' => {
1196                    let quotes = self.scan_while(|c| c == '`');
1197                    self.literal_horz(start, quotes.len(), true)?;
1198                    return Ok(());
1199                }
1200                _ if is_str_char(c) => {
1201                    let text = self.scan_while(is_str_char);
1202                    self.emit(start, Token::Char(text));
1203                    return Ok(());
1204                }
1205                _ => {
1206                    self.bump();
1207                    return self.error(
1208                        start,
1209                        format!("illegal token '{c}' in an inline text area"),
1210                    );
1211                }
1212            }
1213        }
1214    }
1215
1216    // ---- active mode ------------------------------------------------------------
1217
1218    fn lex_active(&mut self) -> Result<(), LexError> {
1219        loop {
1220            let start = self.loc();
1221            let Some(c) = self.peek() else {
1222                return self.error(start, "unexpected end of input while reading an active area");
1223            };
1224            match c {
1225                '%' => {
1226                    self.bump();
1227                    self.comment();
1228                }
1229                _ if is_space(c) || is_break(c) => {
1230                    self.bump();
1231                }
1232                '?' => {
1233                    self.bump();
1234                    // SATySFi 0.1: a command
1235                    // APPLIED in an active area (`\cmd ?(l = e){…}` /
1236                    // `+cmd ?(l = e)<…>`) carries a `?(l = e, …)` labeled-
1237                    // optional bundle — the `?` is `OptionalType` and the
1238                    // `(…)` group lexes via the `(` arm on the next scan,
1239                    // exactly like program-mode application (`lex_program`'s
1240                    // `?` arm). The fused `?:`/`?*` sigils no longer exist
1241                    // under V0_1. Under V0_0 this stays byte-identical
1242                    // (`?:`/`?*` handled, a bare `?` is still an error) —
1243                    // pinned by `lex_with_version_differential.rs`.
1244                    if self.version == RustyfiVersion::V0_1 {
1245                        self.emit(start, Token::OptionalType);
1246                        return Ok(());
1247                    }
1248                    match self.peek() {
1249                        Some(':') => {
1250                            self.bump();
1251                            self.emit(start, Token::Optional);
1252                        }
1253                        Some('*') => {
1254                            self.bump();
1255                            self.emit(start, Token::Omission);
1256                        }
1257                        _ => return self.error(start, "unexpected token '?' in an active area"),
1258                    }
1259                    return Ok(());
1260                }
1261                '~' => {
1262                    self.bump();
1263                    self.emit(start, Token::ExactTilde);
1264                    return Ok(());
1265                }
1266                '(' => {
1267                    self.bump();
1268                    if self.peek() == Some('|') {
1269                        self.bump();
1270                        self.push_mode(Mode::Program);
1271                        self.emit(start, Token::BRecord);
1272                    } else {
1273                        self.push_mode(Mode::Program);
1274                        self.emit(start, Token::LParen);
1275                    }
1276                    return Ok(());
1277                }
1278                '[' => {
1279                    self.bump();
1280                    self.push_mode(Mode::Program);
1281                    self.emit(start, Token::BList);
1282                    return Ok(());
1283                }
1284                '{' => {
1285                    self.bump();
1286                    self.pop_mode(start, "BUG; this cannot happen")?;
1287                    self.push_mode(Mode::Horizontal);
1288                    self.skip_spaces();
1289                    self.emit(start, Token::BHorzGrp);
1290                    return Ok(());
1291                }
1292                '<' => {
1293                    self.bump();
1294                    self.pop_mode(start, "BUG; this cannot happen")?;
1295                    self.push_mode(Mode::Vertical);
1296                    self.emit(start, Token::BVertGrp);
1297                    return Ok(());
1298                }
1299                ';' => {
1300                    self.bump();
1301                    self.pop_mode(start, "BUG; this cannot happen")?;
1302                    self.emit(start, Token::EndActive);
1303                    return Ok(());
1304                }
1305                _ => {
1306                    self.bump();
1307                    return self.error(start, format!("unexpected token '{c}' in an active area"));
1308                }
1309            }
1310        }
1311    }
1312
1313    // ---- math mode (mathexpr) -----------------------------------------------------
1314
1315    fn lex_math(&mut self) -> Result<(), LexError> {
1316        loop {
1317            let start = self.loc();
1318            let Some(c) = self.peek() else {
1319                return self.error(start, "unexpected end of file in a math area");
1320            };
1321            match c {
1322                '%' => {
1323                    self.bump();
1324                    self.comment();
1325                }
1326                _ if is_space(c) || is_break(c) => {
1327                    self.bump();
1328                }
1329                '?' => {
1330                    self.bump();
1331                    match self.peek() {
1332                        Some(':') => {
1333                            self.bump();
1334                            self.emit(start, Token::Optional);
1335                        }
1336                        Some('*') => {
1337                            self.bump();
1338                            self.emit(start, Token::Omission);
1339                        }
1340                        _ => return self.error(start, "illegal token '?' in a math area"),
1341                    }
1342                    return Ok(());
1343                }
1344                '!' => {
1345                    self.bump();
1346                    match self.peek() {
1347                        Some('{') => {
1348                            self.bump();
1349                            self.push_mode(Mode::Horizontal);
1350                            self.skip_spaces();
1351                            self.emit(start, Token::BHorzGrp);
1352                        }
1353                        Some('<') => {
1354                            self.bump();
1355                            self.push_mode(Mode::Vertical);
1356                            self.emit(start, Token::BVertGrp);
1357                        }
1358                        Some('(') => {
1359                            self.bump();
1360                            if self.peek() == Some('|') {
1361                                self.bump();
1362                                self.push_mode(Mode::Program);
1363                                self.emit(start, Token::BRecord);
1364                            } else {
1365                                self.push_mode(Mode::Program);
1366                                self.emit(start, Token::LParen);
1367                            }
1368                        }
1369                        Some('[') => {
1370                            self.bump();
1371                            self.push_mode(Mode::Program);
1372                            self.emit(start, Token::BList);
1373                        }
1374                        _ => return self.error(start, "illegal token '!' in a math area"),
1375                    }
1376                    return Ok(());
1377                }
1378                '{' => {
1379                    self.bump();
1380                    self.push_mode(Mode::Math);
1381                    self.emit(start, Token::BMathGrp);
1382                    return Ok(());
1383                }
1384                '}' => {
1385                    self.bump();
1386                    self.pop_mode(start, "too many closing")?;
1387                    self.emit(start, Token::EMathGrp);
1388                    return Ok(());
1389                }
1390                '|' => {
1391                    self.bump();
1392                    self.emit(start, Token::Sep);
1393                    return Ok(());
1394                }
1395                '^' => {
1396                    self.bump();
1397                    self.emit(start, Token::Superscript);
1398                    return Ok(());
1399                }
1400                '_' => {
1401                    self.bump();
1402                    self.emit(start, Token::Subscript);
1403                    return Ok(());
1404                }
1405                '\'' => {
1406                    let primes = self.scan_while(|c| c == '\'');
1407                    self.emit(start, Token::Primes(primes.len()));
1408                    return Ok(());
1409                }
1410                '#' => {
1411                    self.bump();
1412                    let Some((mods, name, _)) = self.scan_dotted() else {
1413                        return self.error(start, "illegal token '#' in a math area");
1414                    };
1415                    self.emit(start, Token::VarInMath(mods, name));
1416                    return Ok(());
1417                }
1418                '\\' => {
1419                    self.bump();
1420                    if let Some((mods, name, _)) = self.scan_dotted() {
1421                        if mods.is_empty() {
1422                            self.emit(start, Token::MathCmd(format!("\\{name}")));
1423                        } else {
1424                            self.emit(start, Token::MathCmdWithMod(mods, format!("\\{name}")));
1425                        }
1426                        return Ok(());
1427                    }
1428                    if self.peek().is_some_and(is_symbol_char) {
1429                        let sym = self.bump();
1430                        self.emit(start, Token::MathChar(sym.to_string()));
1431                        return Ok(());
1432                    }
1433                    return self.error(start, "illegal token '\\' in a math area");
1434                }
1435                // ONE token per math symbol, not per RUN of them.
1436                //
1437                // v0.0.6's rule is `mathsymboltop (mathsymbol*)`
1438                // (`lexer.mll:556`), a single `MATHCHAR` for the whole run —
1439                // and that run then misses `default_math_class_map`, whose
1440                // keys are all one character, so `${-------}` set SEVEN ASCII
1441                // hyphens where the reference sets seven U+2212 MINUS SIGNs
1442                // (latexcmds' `\overbrace…{\underbrace…{-------}}`, and the
1443                // whole of that document's `chars_missing`). Splitting makes
1444                // each symbol its own math ATOM, so each one hits the class
1445                // map on its own — which is also what the reference engine
1446                // does: `${a -- b}` sets `𝑎 − −𝑏` there, with binary spacing
1447                // BETWEEN the two minuses, which one atom cannot produce.
1448                //
1449                // `is_mathsymbol`'s extra `?` is consumed by the `'?'` arm
1450                // above (`?:`/`?*`) before this one is ever reached, so
1451                // dropping the run scan loses no token shape that was
1452                // reachable.
1453                _ if is_mathsymbol_top(c) => {
1454                    self.bump();
1455                    self.emit(start, Token::MathChar(c.to_string()));
1456                    return Ok(());
1457                }
1458                _ if c.is_ascii_alphanumeric() => {
1459                    self.bump();
1460                    self.emit(start, Token::MathChar(c.to_string()));
1461                    return Ok(());
1462                }
1463                _ => {
1464                    self.bump();
1465                    return self.error(start, format!("illegal token '{c}' in a math area"));
1466                }
1467            }
1468        }
1469    }
1470}