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