Skip to main content

praxis_parser/
lex.rs

1//! The Praxis lexer.
2//!
3//! Turns source text into a stream of [`Token`]s (each carrying a
4//! [`SyntaxKind`] and a [`Span`]) plus diagnostics. It is lossless — trivia
5//! (whitespace and comments) is kept as real tokens so the parser can fold them
6//! into the rowan tree verbatim (§13.1, ADR-003).
7//!
8//! Design notes:
9//! - **Longest match** for operators: `->`, `=>`, `==`, `!=`, `..=`, `+=`, …
10//!   are recognized before their single-character prefixes.
11//! - **Keywords** are split out of the identifier run via
12//!   [`SyntaxKind::from_keyword`]; `out`, `panic`, type names, etc. stay plain
13//!   identifiers (they are builtins, not keywords).
14//! - **Identifiers** are Unicode (§4.1): classification is per *scalar*, using
15//!   the workspace-wide [`praxis_syntax::ident`] predicates, so a leading
16//!   Unicode letter starts a name and a UTF-8 continuation byte cannot extend
17//!   one.
18//! - A bad scalar does not abort lexing: it emits a `T003` diagnostic and the
19//!   lexer advances one whole scalar so the rest of the file is still reported
20//!   (§17.1, "multiple diagnostics from one malformed file").
21//!
22//! Diagnostic codes (`T0xx`, [`DiagnosticCategory::Lex`]):
23//! - `T001` — unterminated block comment.
24//! - `T002` — unterminated backtick template.
25//! - `T003` — unexpected character in source.
26//! - `T004` — unterminated text literal.
27//! - `T005` — invalid escape in a text or character literal.
28//! - `T006` — unterminated character literal.
29//! - `T007` — a character literal that is not exactly one character.
30
31use praxis_source::{DiagCode, Diagnostic, FileId, Severity, Span};
32use praxis_syntax::{SyntaxKind, Token};
33
34use praxis_syntax::interp::{FragmentEnd, TextEnd};
35use praxis_syntax::template::TemplateEnd;
36
37/// The result of lexing one source file: the token stream and any diagnostics.
38///
39/// Diagnostics are returned alongside tokens rather than via `Result` because a
40/// single bad byte should not abort lexing the rest of the file — the LSP needs
41/// to keep reporting problems past the first one (§17.1, "multiple diagnostics
42/// from one malformed file").
43#[derive(Debug)]
44pub struct LexOutput {
45    pub tokens: Vec<Token>,
46    pub diagnostics: Vec<Diagnostic>,
47}
48
49/// Lex `text` belonging to `file`, returning tokens and diagnostics.
50///
51/// This is the stable front-end entry point the CLI and parser both call.
52pub fn lex(file: FileId, text: &str) -> LexOutput {
53    let mut lexer = Lexer::new(file, text);
54    lexer.run();
55    LexOutput {
56        tokens: lexer.tokens,
57        diagnostics: lexer.diagnostics,
58    }
59}
60
61struct Lexer<'a> {
62    file: FileId,
63    /// The source as text. Byte indexing goes through [`Lexer::bytes`]; keeping
64    /// the `&str` is what lets `eat_ident` decode scalars and slice the
65    /// identifier run without a `from_utf8` round-trip.
66    src: &'a str,
67    /// Current byte offset into `src`.
68    pos: usize,
69    tokens: Vec<Token>,
70    diagnostics: Vec<Diagnostic>,
71    /// Whether the trivia seen since the last meaningful token contained a line
72    /// break. Consumed (and cleared) by the next meaningful token, which is
73    /// where the parser reads it from (ADR-049).
74    pending_newline: bool,
75    /// The open interpolation holes, innermost last, each holding the brace
76    /// depth **within** it (§8.1, ADR-147).
77    ///
78    /// This is the lexer's only mode stack, and the one question it answers is
79    /// what a `}` is: at depth 0 of the innermost hole it closes the hole and
80    /// literal text resumes, and anywhere else it is the ordinary `R_BRACE` that
81    /// closes a block, a record literal or a set. `"{if x { 1 } else { 2 }}"`
82    /// needs exactly that and nothing more.
83    ///
84    /// A frame is pushed only by a fragment token, and a fragment token is only
85    /// emitted for a literal [`praxis_syntax::interp::text_end`] has already
86    /// proved closes on its line. So there is no newline, no EOF and no
87    /// malformed literal that can leave a frame on it (ADR-147 decision 5).
88    holes: Vec<u32>,
89}
90
91impl<'a> Lexer<'a> {
92    fn new(file: FileId, text: &'a str) -> Lexer<'a> {
93        Lexer {
94            file,
95            src: text,
96            pos: 0,
97            tokens: Vec::new(),
98            diagnostics: Vec::new(),
99            pending_newline: false,
100            holes: Vec::new(),
101        }
102    }
103
104    fn run(&mut self) {
105        while self.pos < self.src.len() {
106            let start = self.pos;
107            let (class, len) = self.classify(start);
108            match class {
109                CharClass::Whitespace => self.eat_whitespace(),
110                CharClass::LineComment => self.eat_line_comment(),
111                CharClass::BlockComment => self.eat_block_comment(start),
112                CharClass::IdentStart => self.eat_ident(start, len),
113                CharClass::Digit => self.eat_number(start),
114                CharClass::Quote => self.eat_text(start),
115                CharClass::SingleQuote => self.eat_char(start),
116                CharClass::Punct => self.eat_punct_tracking_holes(start),
117                CharClass::Backtick => self.eat_template(start),
118                CharClass::Unknown => self.diagnose_unknown(start, len),
119            }
120        }
121        self.push(SyntaxKind::EOF, self.pos);
122    }
123
124    /// The scalar beginning at `at` and its UTF-8 length. `at` is always a char
125    /// boundary: every lexer advance moves by whole scalars.
126    fn scalar_at(&self, at: usize) -> Option<(char, usize)> {
127        self.src[at..].chars().next().map(|c| (c, c.len_utf8()))
128    }
129
130    /// Classify the scalar at `at`, returning its class and byte length.
131    ///
132    /// ASCII takes a byte-level fast path; a non-ASCII scalar is decoded and
133    /// asked the one identifier question (§4.1). Returning the length is what
134    /// keeps `Unknown` from advancing into the middle of a scalar.
135    fn classify(&self, at: usize) -> (CharClass, usize) {
136        let b = self.bytes()[at];
137        if b.is_ascii() {
138            let class = match b {
139                b' ' | b'\t' | b'\n' | b'\r' => CharClass::Whitespace,
140                // A `/` is a comment only when followed by `/` or `*`; otherwise it
141                // is punctuation (the division operator / part of a comment opener).
142                b'/' if self.starts_with(b"//") => CharClass::LineComment,
143                b'/' if self.starts_with(b"/*") => CharClass::BlockComment,
144                b'_' | b'a'..=b'z' | b'A'..=b'Z' => CharClass::IdentStart,
145                b'0'..=b'9' => CharClass::Digit,
146                b'"' => CharClass::Quote,
147                // A `'` opens a character literal and nothing else — it is not
148                // a lifetime sigil, not a digit separator and not part of an
149                // identifier (ADR-141).
150                b'\'' => CharClass::SingleQuote,
151                b'`' => CharClass::Backtick,
152                // Any leading punctuation byte of an operator we recognize. The
153                // precise multi-char split happens in `eat_punct`; the class just
154                // routes the first byte here.
155                b'(' | b')' | b'{' | b'}' | b'[' | b']' | b'<' | b'>' | b'+' | b'-' | b'*'
156                | b'/' | b'%' | b'=' | b'!' | b'?' | b':' | b';' | b',' | b'.' | b'|' | b'&'
157                | b'#' => CharClass::Punct,
158                _ => CharClass::Unknown,
159            };
160            return (class, 1);
161        }
162        let (c, len) = self.scalar_at(at).expect("pos is a char boundary");
163        let class = if praxis_syntax::ident::is_ident_start(c) {
164            CharClass::IdentStart
165        } else {
166            CharClass::Unknown
167        };
168        (class, len)
169    }
170
171    fn eat_whitespace(&mut self) {
172        let start = self.pos;
173        while self.pos < self.src.len()
174            && matches!(self.bytes()[self.pos], b' ' | b'\t' | b'\n' | b'\r')
175        {
176            self.pos += 1;
177        }
178        self.push(SyntaxKind::Whitespace, start);
179    }
180
181    fn eat_line_comment(&mut self) {
182        let start = self.pos;
183        self.pos += 2; // skip leading `//`
184        while self.pos < self.src.len() && !matches!(self.bytes()[self.pos], b'\n' | b'\r') {
185            self.pos += 1;
186        }
187        self.push(SyntaxKind::LineComment, start);
188    }
189
190    fn eat_block_comment(&mut self, start: usize) {
191        // Nestable block comments (§4.1).
192        self.pos += 2; // skip leading `/*`
193        let mut depth: u32 = 1;
194        while self.pos < self.src.len() && depth > 0 {
195            if self.starts_with(b"/*") {
196                depth += 1;
197                self.pos += 2;
198            } else if self.starts_with(b"*/") {
199                depth -= 1;
200                self.pos += 2;
201            } else {
202                self.pos += 1;
203            }
204        }
205        if depth > 0 {
206            // Unterminated: emit a diagnostic but still emit the token so the
207            // rest of the file can be processed.
208            self.diagnostic(
209                Span::new(start as u32, self.pos as u32),
210                DiagCode::UnterminatedBlockComment,
211                "unterminated block comment",
212            );
213        }
214        self.push(SyntaxKind::BlockComment, start);
215    }
216
217    fn eat_ident(&mut self, start: usize, first_len: usize) {
218        // The first scalar is already known to be ident-start; advance past it
219        // and consume XID-Continue scalars (§4.1). Advancing by scalar, not by
220        // byte, is what keeps a continuation byte from extending the run.
221        self.pos += first_len;
222        while let Some((c, len)) = self.scalar_at(self.pos) {
223            if !praxis_syntax::ident::is_ident_continue(c) {
224                break;
225            }
226            self.pos += len;
227        }
228        // Look up the keyword table: `var`/`if`/… become their own kinds; the
229        // rest stay identifiers. Builtins (`out`, `panic`, type names) are
230        // intentionally not keywords.
231        //
232        // A **lone** `_` is neither: it gets its own `UNDERSCORE` kind, so the
233        // wildcard is never a *binding* named `_` (two `_` arms of one match
234        // would be a duplicate declaration, and `Point { x: 1, _: 2 }` would
235        // name a field). `is_ident_start` accepts `_` — and must, because `_x`
236        // and `snake_case` are identifiers — so the split is on the whole run:
237        // `_` followed by anything ident-continue is still an identifier.
238        let text = &self.src[start..self.pos];
239        let kind = if text == "_" {
240            SyntaxKind::UNDERSCORE
241        } else {
242            SyntaxKind::from_keyword(text).unwrap_or(SyntaxKind::Ident)
243        };
244        self.push(kind, start);
245    }
246
247    /// Lex a numeric literal starting at `start` (the first digit). Recognizes
248    /// both integers (`42`) and floats (`3.14`, `1e10`, `1.5e-3`).
249    ///
250    /// A `.` is consumed as part of the literal only when it begins a fraction
251    /// — i.e. the byte after the integer part is `.` AND the byte after that is
252    /// a digit. This excludes range syntax: `1..5` and `1..=5` lex as `IntLit`
253    /// `1` followed by `DOT2` / `DOT2EQ`, never as a malformed float.
254    ///
255    /// **A trailing dot is not part of the literal.** `2.` lexes as `IntLit` `2`
256    /// followed by `DOT`, so `var x = 2.` parses as a method call on `2` with no
257    /// method name and reports. The float spellings are `2.0` and `2e0`.
258    ///
259    /// A leading-dot float (`.5`) is not reachable here because the dispatch
260    /// routes on the first byte: `.` is `Punct`. Leading-dot floats are not
261    /// supported (a deliberate simplification); users write `0.5`.
262    ///
263    /// Every digit run admits `_` separators between its digits, so `1_000`,
264    /// `3.141_592` and `1e1_0` are each one token. The rule is
265    /// `praxis_syntax::numeric`'s, and the same module strips them back out when
266    /// lowering reads the value, so the two halves cannot disagree.
267    fn eat_number(&mut self, start: usize) {
268        // Integer part: one or more digits (the first is already known present).
269        self.eat_digit_run();
270        let mut is_float = false;
271        // Fractional part: `.` followed by a digit. The "followed by a digit"
272        // check is what disambiguates `1.5` (float) from `1..5` (range: the
273        // next byte is another `.`) and `1.method()` (the next byte is a letter).
274        // A `_` cannot open a fraction for the same reason: `1._0` is not a
275        // float, so a separator is only ever *between* digits.
276        //
277        // …and a digit run that *itself* follows a `.` is a **tuple index**, so
278        // it takes no fraction at all: `t.0.1` is `t`, `.0`, `.1` — two indices,
279        // not an index and the float `0.1`. The rule is adjacency: the
280        // immediately preceding token, with no trivia between, is a `DOT`.
281        // A `..` is `DOT2` and is a different token, so `0..1.5` is untouched,
282        // and no float literal in any program has a bare `DOT` before it — in
283        // `1.5` the `.` is consumed *inside* this function and never emitted.
284        if !self.preceded_by_dot(start) && self.peek_is_dot_then_digit() {
285            is_float = true;
286            // Consume the `.`.
287            self.pos += 1;
288            self.eat_digit_run();
289        }
290        // Exponent part: `e` or `E`, optional `+`/`-`, then one or more digits.
291        if matches!(self.bytes().get(self.pos), Some(b'e') | Some(b'E')) {
292            // Only treat as an exponent if a digit (or signed digit) follows;
293            // otherwise `1e` is `IntLit(1)` + `Ident(e)` (a name). `+`/`-` then
294            // a digit also counts.
295            if self.peek_exponent_has_digits() {
296                is_float = true;
297                self.pos += 1; // the `e`/`E`
298                if matches!(self.bytes().get(self.pos), Some(b'+') | Some(b'-')) {
299                    self.pos += 1;
300                }
301                self.eat_digit_run();
302            }
303        }
304        let kind = if is_float {
305            SyntaxKind::FloatLit
306        } else {
307            SyntaxKind::IntLit
308        };
309        self.push(kind, start);
310    }
311
312    /// Consume digits and the `_` separators between them, leaving `pos` on the
313    /// first byte that is neither.
314    ///
315    /// Each caller has already consumed at least one digit, which is what makes
316    /// a separator's left-hand digit certain; `separator_run_len` checks it
317    /// regardless. A trailing `_` is not consumed — `1_` is the literal `1`
318    /// followed by the `UNDERSCORE` token, not a literal with a dangling
319    /// separator.
320    fn eat_digit_run(&mut self) {
321        loop {
322            while self.pos < self.src.len() && self.bytes()[self.pos].is_ascii_digit() {
323                self.pos += 1;
324            }
325            let run = praxis_syntax::numeric::separator_run_len(self.bytes(), self.pos);
326            if run == 0 {
327                return;
328            }
329            self.pos += run;
330        }
331    }
332
333    /// True iff the token just emitted is a bare `DOT` ending exactly at `start`
334    /// — nothing between it and the literal now being lexed, not even
335    /// whitespace.
336    ///
337    /// The one caller is [`Self::eat_number`]: a digit run in that position is a
338    /// **tuple index** and takes no fractional part, so `t.0.1` is two indices
339    /// rather than an index and a float.
340    ///
341    /// It has to be the *token* and not the source byte. In `1.5..2.5` the byte
342    /// before `2` is a `.` too — the second one of the `..` — but that `.` was
343    /// consumed into a `DOT2`, which is a different token and leaves `2.5` the
344    /// float it is.
345    fn preceded_by_dot(&self, start: usize) -> bool {
346        self.tokens.last().is_some_and(|last| {
347            last.kind == SyntaxKind::DOT && last.span.end().to_u32() as usize == start
348        })
349    }
350
351    /// True iff the current position is a `.` immediately followed by an ASCII
352    /// digit. Used to decide whether the `.` starts a float fraction.
353    fn peek_is_dot_then_digit(&self) -> bool {
354        matches!(self.bytes().get(self.pos), Some(b'.'))
355            && matches!(self.bytes().get(self.pos + 1), Some(d) if d.is_ascii_digit())
356    }
357
358    /// True iff the current position is an `e`/`E` that begins a valid exponent:
359    /// `e`/`E` then (optionally `+`/`-`) then at least one digit.
360    fn peek_exponent_has_digits(&self) -> bool {
361        if !matches!(self.bytes().get(self.pos), Some(b'e') | Some(b'E')) {
362            return false;
363        }
364        let mut i = self.pos + 1;
365        if matches!(self.bytes().get(i), Some(b'+') | Some(b'-')) {
366            i += 1;
367        }
368        matches!(self.bytes().get(i), Some(d) if d.is_ascii_digit())
369    }
370
371    /// A `"…"` literal, which with interpolation (§8.1, ADR-147) is one of three
372    /// token shapes: a whole `TextLit`, or an `InterpOpen`/`InterpMiddle`/
373    /// `InterpClose` fragment run.
374    ///
375    /// The extent is decided **before** anything is emitted, by
376    /// [`praxis_syntax::interp::text_end`], and that ordering is the whole of
377    /// ADR-147 decision 5. The lexer enters interpolation mode — the
378    /// [`holes`](Self::holes) brace-depth stack that decides whether a later `}`
379    /// closes a hole or a block — only for a literal already proved to close on
380    /// its line with balanced holes. So no newline and no EOF can reach that
381    /// stack, and a literal that does *not* close is one `TextLit` plus `T004`.
382    ///
383    /// Escapes are still validated here (a stray `\q` is `T005`), and still only
384    /// over the *literal text*: the escape rule does not apply inside a hole,
385    /// which holds ordinary expression tokens.
386    fn eat_text(&mut self, start: usize) {
387        match praxis_syntax::interp::text_end(self.src, start) {
388            TextEnd::Closed {
389                end,
390                first_hole: None,
391            } => {
392                self.validate_escapes(start + 1, end - 1);
393                self.pos = end;
394                self.push(SyntaxKind::TextLit, start);
395            }
396            TextEnd::Closed {
397                end: _,
398                first_hole: Some(brace),
399            } => {
400                // The opening fragment: `"` … `{`. The hole's own tokens are
401                // lexed by the main loop, which is what gives every name in it a
402                // real range in the lossless tree (ADR-147 decision 1).
403                self.validate_escapes(start + 1, brace);
404                self.pos = brace + 1;
405                self.push(SyntaxKind::InterpOpen, start);
406                self.holes.push(0);
407            }
408            TextEnd::Unterminated { stopped } => {
409                self.pos = stopped;
410                self.diagnostic(
411                    Span::new(start as u32, self.pos as u32),
412                    DiagCode::UnterminatedTextLiteral,
413                    "unterminated text literal",
414                );
415                self.push(SyntaxKind::TextLit, start);
416            }
417        }
418    }
419
420    /// Resume literal text after the `}` at `start` closed a hole, emitting the
421    /// [`InterpMiddle`] or [`InterpClose`] fragment that follows it.
422    ///
423    /// [`InterpMiddle`]: SyntaxKind::InterpMiddle
424    /// [`InterpClose`]: SyntaxKind::InterpClose
425    ///
426    /// This reads the *same* rule the pre-scan in [`eat_text`] read, through the
427    /// same module — [`praxis_syntax::interp::fragment_end`] — entered in the
428    /// middle. One rule with a scanner-local copy is one rule with two answers.
429    ///
430    /// [`eat_text`]: Self::eat_text
431    ///
432    /// The `None` arm cannot be reached: this runs only while the `holes` stack
433    /// is non-empty, and the stack is only pushed for a literal the pre-scan
434    /// proved closes. It is written as the unterminated path anyway rather than
435    /// as a panic, because the cost of being wrong about "cannot happen" in a
436    /// lexer is a crash on a user's file.
437    fn eat_interp_resume(&mut self, start: usize) {
438        match praxis_syntax::interp::fragment_end(self.src, start) {
439            Some(FragmentEnd::Hole(brace)) => {
440                self.validate_escapes(start + 1, brace);
441                self.pos = brace + 1;
442                self.push(SyntaxKind::InterpMiddle, start);
443                self.holes.push(0);
444            }
445            Some(FragmentEnd::Close(end)) => {
446                self.validate_escapes(start + 1, end - 1);
447                self.pos = end;
448                self.push(SyntaxKind::InterpClose, start);
449            }
450            None => {
451                self.pos = self.src.len();
452                self.diagnostic(
453                    Span::new(start as u32, self.pos as u32),
454                    DiagCode::UnterminatedTextLiteral,
455                    "unterminated text literal",
456                );
457                self.push(SyntaxKind::TextLit, start);
458            }
459        }
460    }
461
462    /// Report `T005` for every unrecognized escape in the literal text spanning
463    /// `from..to`.
464    ///
465    /// Separate from the scan of *where* a literal ends
466    /// (`praxis_syntax::interp`): validation is about the characters and not the
467    /// extent, and every fragment of an interpolated literal needs it on the
468    /// same terms as a whole literal.
469    fn validate_escapes(&mut self, from: usize, to: usize) {
470        let mut pos = from;
471        while pos < to {
472            if self.bytes()[pos] != b'\\' {
473                pos += 1;
474                continue;
475            }
476            if pos + 1 >= to {
477                break;
478            }
479            // The escaped **scalar**, not the escaped byte. `"\¡"` is an invalid
480            // escape either way, but the diagnostic renderer slices the source
481            // at the span, so a span ending inside `¡` panics (`lex_never_panics`
482            // is the fuzz target that pins this).
483            let (esc, esc_len) = self.scalar_at(pos + 1).expect("pos is a char boundary");
484            let end = pos + 1 + esc_len;
485            if !esc.is_ascii() || !is_valid_escape(esc as u8) {
486                self.diagnostic(
487                    Span::new(pos as u32, end as u32),
488                    DiagCode::InvalidEscape,
489                    "invalid escape in text literal",
490                );
491            }
492            pos = end;
493        }
494    }
495
496    /// Punctuation, with the one extra question an open interpolation hole asks
497    /// (§8.1, ADR-147): is this `}` the end of the hole, or an ordinary brace
498    /// inside it?
499    ///
500    /// The brace depth is kept per hole rather than globally, because holes
501    /// nest: `"{f("{y}")}"` opens a second hole while the first is still open,
502    /// and the inner `}` must close the inner one. Only the innermost frame is
503    /// ever consulted, which is what makes that fall out rather than be arranged.
504    ///
505    /// Every other punctuation byte routes straight through to [`eat_punct`].
506    ///
507    /// [`eat_punct`]: Self::eat_punct
508    fn eat_punct_tracking_holes(&mut self, start: usize) {
509        let byte = self.bytes()[start];
510        if let Some(depth) = self.holes.last_mut() {
511            match byte {
512                b'}' if *depth == 0 => {
513                    self.holes.pop();
514                    self.eat_interp_resume(start);
515                    return;
516                }
517                b'}' => *depth -= 1,
518                b'{' => *depth += 1,
519                _ => {}
520            }
521        }
522        self.eat_punct(start);
523    }
524
525    /// A `'…'` character literal (§4.3, ADR-141): [`eat_text`]'s shape, with the
526    /// one-character rule decided here rather than downstream.
527    ///
528    /// [`eat_text`]: Self::eat_text
529    ///
530    /// Three differences from a text literal, each of which is a defect if it is
531    /// dropped:
532    ///
533    /// - the body advances by whole **scalars**, so `'é'` is one character and
534    ///   not the two bytes it is written in;
535    /// - `\'` is an escape here and `\"` is there — the shared table
536    ///   ([`praxis_syntax::literal::decode_escape`]) supplies the rest, so the
537    ///   two spellings of `\n` cannot drift;
538    /// - a *closed* literal is decoded immediately and its length checked, so
539    ///   `'ab'` is `T007` where `"ab"[0]` is a well-typed program that quietly
540    ///   means `a`, and `''` is `T007` where `""[0]` is an index fault at run
541    ///   time.
542    ///
543    /// The token is pushed on every path, including the unterminated one, so the
544    /// tokens still tile the source (ADR-003) and the parser still sees a
545    /// literal to build a node from rather than a hole.
546    fn eat_char(&mut self, start: usize) {
547        self.pos += 1; // opening `'`
548        while self.pos < self.src.len() {
549            match self.bytes()[self.pos] {
550                b'\'' => {
551                    self.pos += 1; // closing quote
552                    self.finish_char(start);
553                    return;
554                }
555                b'\\' => {
556                    // Need at least one more scalar for the escape.
557                    let esc_at = self.pos + 1;
558                    if esc_at >= self.src.len() {
559                        break;
560                    }
561                    // The escaped **scalar**, not the escaped byte. `'\¡'` is
562                    // an invalid escape either way, but stepping two bytes past
563                    // it leaves the cursor inside `¡` — and every later read,
564                    // including the slice `finish_char` decodes, then panics on
565                    // a char boundary.
566                    let (esc, esc_len) = self.scalar_at(esc_at).expect("pos is a char boundary");
567                    let bad_at = self.pos;
568                    self.pos = esc_at + esc_len;
569                    if !is_valid_char_escape(esc) {
570                        self.diagnostic(
571                            Span::new(bad_at as u32, self.pos as u32),
572                            DiagCode::InvalidEscape,
573                            "invalid escape in character literal",
574                        );
575                    }
576                }
577                b'\n' | b'\r' => {
578                    // A character literal ends at its line, for the text
579                    // literal's reason: a missing `'` should report where it was
580                    // wanted, not swallow the rest of the file.
581                    break;
582                }
583                // A whole scalar, never a byte. `pos += 1` here would put the
584                // cursor inside `é`, and the length check below would then count
585                // two characters in a literal that names one.
586                _ => {
587                    let (_, len) = self.scalar_at(self.pos).expect("pos is a char boundary");
588                    self.pos += len;
589                }
590            }
591        }
592        self.diagnostic(
593            Span::new(start as u32, self.pos as u32),
594            DiagCode::UnterminatedCharLiteral,
595            "unterminated character literal",
596        );
597        self.push(SyntaxKind::CharLit, start);
598    }
599
600    /// Push a closed `'…'` token, reporting a body that does not name exactly
601    /// one character.
602    ///
603    /// The decode is `praxis-syntax`'s, not a second count written here: the
604    /// lexer's question ("how many characters is this") and the lowerer's
605    /// ("which character is this") have to be the same walk, or `'\n'` is one
606    /// character to one of them and two to the other.
607    fn finish_char(&mut self, start: usize) {
608        use praxis_syntax::literal::CharLitError;
609
610        let raw = &self.src[start..self.pos];
611        match praxis_syntax::literal::decode_char_literal(raw).err() {
612            None => {}
613            Some(CharLitError::Empty) => self.diagnostic(
614                Span::new(start as u32, self.pos as u32),
615                DiagCode::CharLiteralIsNotOneCharacter,
616                "empty character literal: `''` names no character",
617            ),
618            Some(CharLitError::TooLong) => {
619                // The fix is mechanical and it is the one the author probably
620                // meant, so it rides as a replacement rather than a `help:`
621                // (ADR-132): `'ab'` was almost certainly a `"ab"`.
622                let span = Span::new(start as u32, self.pos as u32);
623                let body = &raw[1..raw.len() - 1];
624                let diag = Diagnostic::new(
625                    Severity::Error,
626                    DiagCode::CharLiteralIsNotOneCharacter,
627                    "a character literal holds exactly one character",
628                    praxis_source::FileSpan::new(self.file, span),
629                )
630                .with_suggestion(
631                    praxis_source::FileSpan::new(self.file, span),
632                    format!("\"{body}\""),
633                    "write it as a text literal",
634                );
635                self.diagnostics.push(diag);
636            }
637            // Not reachable from here — this function is only called on a
638            // quote the scan itself matched, and the one body that decodes as
639            // unterminated (a lone trailing `\`) is exactly the one whose
640            // escape ate that quote, so the scan ran to EOF instead. The arm
641            // exists because `CharLitError` is the decoder's answer and not
642            // this call site's, and a decoder that grows a fourth reason should
643            // report it rather than fall into `Empty`'s message.
644            Some(CharLitError::Unterminated) => self.diagnostic(
645                Span::new(start as u32, self.pos as u32),
646                DiagCode::UnterminatedCharLiteral,
647                "unterminated character literal",
648            ),
649        }
650        self.push(SyntaxKind::CharLit, start);
651    }
652
653    fn eat_punct(&mut self, start: usize) {
654        // Longest-match: try the three- and two-char operators first, then fall
655        // back to single-byte punctuation. `match_op` advances `pos` past the
656        // matched bytes and returns the kind.
657        let kind = self
658            .match_op()
659            .unwrap_or_else(|| single_punct(self.bytes()[start]).unwrap_or(SyntaxKind::ERROR));
660        self.push(kind, start);
661    }
662
663    /// Try to match the longest operator beginning at `pos`, advancing `pos`
664    /// past it. Returns the matched kind for multi-char operators, or `None`
665    /// for a bare single-byte punctuation byte (the caller then falls back to
666    /// [`single_punct`]).
667    fn match_op(&mut self) -> Option<SyntaxKind> {
668        // Three-char operators first (only `..=` so far), then two-char. Order
669        // matters: longest first so `..=` is not misread as `..` then `=`.
670        let three = self.bytes().get(self.pos..self.pos + 3);
671        if let Some([b'.', b'.', b'=']) = three {
672            self.pos += 3;
673            return Some(SyntaxKind::DOT2EQ);
674        }
675        let two = self.bytes().get(self.pos..self.pos + 2);
676        let matched = match two {
677            Some(b"->") => Some(SyntaxKind::THIN_ARROW),
678            Some(b"=>") => Some(SyntaxKind::FAT_ARROW),
679            Some(b"==") => Some(SyntaxKind::EQ2),
680            Some(b"!=") => Some(SyntaxKind::NEQ),
681            Some(b"<=") => Some(SyntaxKind::LTEQ),
682            Some(b">=") => Some(SyntaxKind::GTEQ),
683            Some(b"..") => Some(SyntaxKind::DOT2),
684            Some(b"||") => Some(SyntaxKind::PIPE2),
685            Some(b"&&") => Some(SyntaxKind::AMP2),
686            Some(b"+=") => Some(SyntaxKind::PLUS_EQ),
687            Some(b"-=") => Some(SyntaxKind::MINUS_EQ),
688            Some(b"*=") => Some(SyntaxKind::STAR_EQ),
689            Some(b"/=") => Some(SyntaxKind::SLASH_EQ),
690            Some(b"%=") => Some(SyntaxKind::PERCENT_EQ),
691            _ => None,
692        };
693        if matched.is_some() {
694            self.pos += 2;
695        } else {
696            // Single-byte operator/punct. Advance one byte and signal "no
697            // multi-char match" so the caller resolves the kind itself.
698            self.pos += 1;
699        }
700        matched
701    }
702
703    /// Consume a backtick template as **one** token, interior and all.
704    ///
705    /// The interior is opaque here; `praxis_input_parser::scan_template`
706    /// re-scans it. What is not opaque is where the token *ends*: a capture body
707    /// is a full parser expression, so `` `{g:choice(A: `{x:int}`)}` `` is one
708    /// template containing another, and a backtick closes only at brace depth 0.
709    ///
710    /// **The rule is not written here.** It lives in
711    /// [`praxis_syntax::template`], because the scanner that re-reads this
712    /// token's interior has to find the same nested templates and the same
713    /// closing backtick inside it. A scanner-local copy is how the two come to
714    /// disagree — over, for instance, whether a brace inside a string literal
715    /// counts, which decides `` `{c:one_of("{")}` ``.
716    fn eat_template(&mut self, start: usize) {
717        // **A template ends at the line it opens on** (ADR-094), so an
718        // unterminated one names its own line instead of swallowing the rest of
719        // the file into one token plus a cascade of block- and item-level
720        // faults.
721        //
722        // The two kinds are not cosmetic — see
723        // `SyntaxKind::UnterminatedBacktickTemplate` for why the alternative
724        // (one kind plus a "does it end in a backtick" test at each consumer)
725        // is a defect.
726        let kind = match praxis_syntax::template::template_end(self.src, start) {
727            TemplateEnd::Closed(end) => {
728                self.pos = end;
729                SyntaxKind::BacktickTemplate
730            }
731            TemplateEnd::Unterminated(stopped) => {
732                self.pos = stopped;
733                self.diagnostic(
734                    Span::new(start as u32, self.pos as u32),
735                    DiagCode::UnterminatedTemplate,
736                    "unterminated backtick template",
737                );
738                SyntaxKind::UnterminatedBacktickTemplate
739            }
740        };
741        self.push(kind, start);
742    }
743
744    fn diagnose_unknown(&mut self, start: usize, len: usize) {
745        // Advance one whole scalar so we make progress without splitting a
746        // multi-byte character into several "unexpected character" diagnostics.
747        self.pos += len;
748        // Emit an ERROR token covering it. The tree is lossless (ADR-003): a
749        // character the lexer cannot classify is still source text, and
750        // dropping it silently means the tree no longer reproduces the file.
751        self.push(SyntaxKind::ERROR, start);
752        let span = Span::new(start as u32, self.pos as u32);
753        self.diagnostic(
754            span,
755            DiagCode::UnexpectedCharacter,
756            "unexpected character in source",
757        );
758    }
759
760    // --- helpers ---
761
762    /// Emit a token covering `start..pos`, threading the newline fact through
763    /// it (ADR-049).
764    ///
765    /// Trivia *accumulates* the fact — a line break anywhere in the run before a
766    /// meaningful token counts, so `1 /* \n */ + 2` and `1\n+ 2` agree. A
767    /// meaningful token *consumes* it: it carries the flag and clears the
768    /// pending state, so only the first token on a line reports one.
769    fn push(&mut self, kind: SyntaxKind, start: usize) {
770        let preceded_by_newline = if kind.is_trivia() {
771            // A line comment runs to the end of its line, so reaching its end is
772            // reaching a line break even when EOF eats the `\n` itself.
773            self.pending_newline |=
774                kind == SyntaxKind::LineComment || self.src[start..self.pos].contains(['\n', '\r']);
775            self.pending_newline
776        } else {
777            std::mem::take(&mut self.pending_newline)
778        };
779        self.tokens.push(Token::new(
780            kind,
781            Span::new(start as u32, self.pos as u32),
782            preceded_by_newline,
783        ));
784    }
785
786    fn diagnostic(&mut self, span: Span, code: DiagCode, message: &str) {
787        self.diagnostics.push(Diagnostic::new(
788            Severity::Error,
789            code,
790            message,
791            praxis_source::FileSpan::new(self.file, span),
792        ));
793    }
794
795    #[inline]
796    fn bytes(&self) -> &'a [u8] {
797        self.src.as_bytes()
798    }
799
800    fn starts_with(&self, needle: &[u8]) -> bool {
801        self.bytes()[self.pos..].starts_with(needle)
802    }
803}
804
805/// The `SyntaxKind` for a single-byte punctuation/operator byte, or `None` if
806/// the byte is not punctuation at all.
807fn single_punct(b: u8) -> Option<SyntaxKind> {
808    Some(match b {
809        b'(' => SyntaxKind::L_PAREN,
810        b')' => SyntaxKind::R_PAREN,
811        b'{' => SyntaxKind::L_BRACE,
812        b'}' => SyntaxKind::R_BRACE,
813        b'[' => SyntaxKind::L_BRACK,
814        b']' => SyntaxKind::R_BRACK,
815        b',' => SyntaxKind::COMMA,
816        b'.' => SyntaxKind::DOT,
817        b':' => SyntaxKind::COLON,
818        b';' => SyntaxKind::SEMICOLON,
819        b'#' => SyntaxKind::HASH,
820        b'|' => SyntaxKind::PIPE,
821        b'&' => SyntaxKind::AMP,
822        b'+' => SyntaxKind::PLUS,
823        b'-' => SyntaxKind::MINUS,
824        b'*' => SyntaxKind::STAR,
825        b'/' => SyntaxKind::SLASH,
826        b'%' => SyntaxKind::PERCENT,
827        b'=' => SyntaxKind::EQ,
828        b'!' => SyntaxKind::BANG,
829        b'<' => SyntaxKind::LT,
830        b'>' => SyntaxKind::GT,
831        b'?' => SyntaxKind::QUESTION,
832        _ => return None,
833    })
834}
835
836/// Whether `esc` is a recognized escape character inside a text literal.
837///
838/// Asked of [`praxis_syntax::literal::decode_escape`] rather than listed again,
839/// because a lexer that *accepts* an escape the decoder does not *decode* is two
840/// answers to one question. That table includes `\{` and `\}`, the spelling of a
841/// literal brace now that `{` opens an interpolation hole (ADR-147).
842///
843/// The one row that is only here is `` \` ``, which the decoder leaves alone:
844/// the lexer accepts it so a backtick inside a text literal does not earn
845/// `T005`, and preserving it verbatim is what `unquote_text` does with any
846/// escape it does not recognize. The carve-out is deliberately no wider.
847fn is_valid_escape(esc: u8) -> bool {
848    esc.is_ascii() && (praxis_syntax::literal::decode_escape(esc as char).is_some() || esc == b'`')
849}
850
851/// Whether `esc` is a recognized escape character inside a character literal.
852///
853/// The text literal's set **plus** `\'`, and defined in terms of it rather than
854/// listed again: a language with two escape tables has two answers to what `\n`
855/// is, which is the drift `praxis_syntax::literal` exists to prevent (ADR-141).
856/// There is no `\x` or `\u{…}` here because there is none there.
857/// Takes a `char` and not a byte, because a character literal's body is scanned
858/// by scalar: `'\¡'` must be refused *and* stepped over whole.
859fn is_valid_char_escape(esc: char) -> bool {
860    esc.is_ascii() && (is_valid_escape(esc as u8) || esc == '\'')
861}
862
863#[derive(Clone, Copy)]
864enum CharClass {
865    Whitespace,
866    LineComment,
867    BlockComment,
868    IdentStart,
869    Digit,
870    Quote,
871    SingleQuote,
872    Punct,
873    Backtick,
874    Unknown,
875}
876
877#[cfg(test)]
878mod tests {
879    use super::*;
880    use praxis_source::SourceMap;
881
882    fn lex_text(text: &str) -> (Vec<SyntaxKind>, Vec<Diagnostic>) {
883        let map = SourceMap::new();
884        let id = map.intern("test.px", text);
885        let out = lex(id, text);
886        (
887            out.tokens.into_iter().map(|t| t.kind).collect(),
888            out.diagnostics,
889        )
890    }
891
892    #[test]
893    fn clean_trivial_input_has_no_diagnostics() {
894        let (kinds, diags) = lex_text("var x = 42 // hi\n");
895        assert!(diags.is_empty(), "got diagnostics: {diags:?}");
896        assert!(kinds.contains(&SyntaxKind::KW_VAR)); // keyword split out
897        assert!(kinds.contains(&SyntaxKind::Ident));
898        assert!(kinds.contains(&SyntaxKind::IntLit));
899        assert!(kinds.contains(&SyntaxKind::Whitespace));
900        assert!(kinds.contains(&SyntaxKind::LineComment));
901        assert!(kinds.last().is_some_and(|k| *k == SyntaxKind::EOF));
902    }
903
904    #[test]
905    fn unknown_byte_emits_one_diagnostic() {
906        let (kinds, diags) = lex_text("var @ = 1");
907        assert_eq!(diags.len(), 1);
908        assert_eq!(diags[0].kind(), DiagCode::UnexpectedCharacter);
909        // The `@` becomes an ERROR token and lexing continues. It must not be
910        // dropped: the tree is lossless (ADR-003), so every byte of the source
911        // has to be reachable through some token.
912        assert!(kinds.contains(&SyntaxKind::ERROR));
913        assert!(kinds.contains(&SyntaxKind::KW_VAR));
914        assert!(kinds.contains(&SyntaxKind::IntLit));
915    }
916
917    /// Every byte of the input is covered by exactly one token, in order —
918    /// including bytes the lexer cannot classify.
919    #[test]
920    fn tokens_tile_the_source_even_across_unknown_characters() {
921        // A character literal and a multi-byte scalar inside one are in here for
922        // `eat_char`'s sake: it is the second scan in the lexer that advances by
923        // whole scalars, and a `pos += 1` in it would leave the next token
924        // starting mid-scalar.
925        let src = "var x = 1 @ \u{2192} 2 'é' ''";
926        let out = lex(FileId::SYNTHETIC, src);
927        let mut at = 0usize;
928        for token in &out.tokens {
929            assert_eq!(
930                token.span.start().to_usize(),
931                at,
932                "gap before {:?}",
933                token.kind
934            );
935            at = token.span.end().to_usize();
936        }
937        assert_eq!(at, src.len(), "tokens do not cover the source");
938    }
939
940    #[test]
941    fn nested_block_comment() {
942        let (kinds, diags) = lex_text("/* outer /* inner */ still outer */ x");
943        assert!(diags.is_empty());
944        assert!(kinds.contains(&SyntaxKind::BlockComment));
945    }
946
947    #[test]
948    fn unterminated_block_comment_faults() {
949        let (_, diags) = lex_text("/* never ends");
950        assert_eq!(diags.len(), 1);
951        assert_eq!(diags[0].kind(), DiagCode::UnterminatedBlockComment);
952    }
953
954    #[test]
955    fn backtick_template_terminated() {
956        let (kinds, diags) = lex_text("var p = `{x:int}`");
957        assert!(diags.is_empty());
958        assert!(kinds.contains(&SyntaxKind::BacktickTemplate));
959    }
960
961    #[test]
962    fn unterminated_template_faults() {
963        let (_, diags) = lex_text("var p = `never closes");
964        assert_eq!(diags.len(), 1);
965        assert_eq!(diags[0].kind(), DiagCode::UnterminatedTemplate);
966    }
967
968    /// A capture body is a full parser expression, so a template may contain a
969    /// template: `` `{g:choice(A: `{x:int}`)}` `` is **one** token.
970    ///
971    /// A backtick closes only at brace depth 0; inside a capture it opens a
972    /// nested run.
973    #[test]
974    fn a_nested_backtick_template_is_one_token() {
975        let src = "var p = `{g:choice(A: `{x:int}`, B: word)}`";
976        let (kinds, diags) = lex_text(src);
977        assert!(diags.is_empty(), "{diags:?}");
978        assert_eq!(
979            kinds
980                .iter()
981                .filter(|k| **k == SyntaxKind::BacktickTemplate)
982                .count(),
983            1,
984            "the whole thing is one template token, inner backticks included"
985        );
986
987        // Two levels deep, and two nested templates side by side.
988        for src in [
989            "var p = `{a:choice(A: `{b:choice(C: `{c:int}`)}`)}`",
990            "var p = `{a:choice(A: `{x:int}`, B: `{y:word}`)}`",
991        ] {
992            let (kinds, diags) = lex_text(src);
993            assert!(diags.is_empty(), "{src}: {diags:?}");
994            assert_eq!(
995                kinds
996                    .iter()
997                    .filter(|k| **k == SyntaxKind::BacktickTemplate)
998                    .count(),
999                1,
1000                "{src}"
1001            );
1002        }
1003
1004        // An escaped backtick still cannot terminate anything, at either depth.
1005        let (_, diags) = lex_text(r"var p = `a\`b`");
1006        assert!(diags.is_empty(), "{diags:?}");
1007
1008        // And an outer template that never closes still faults.
1009        let (_, diags) = lex_text("var p = `{g:choice(A: `{x:int}`)}");
1010        assert_eq!(diags.len(), 1);
1011        assert_eq!(diags[0].kind(), DiagCode::UnterminatedTemplate);
1012    }
1013
1014    /// A brace inside a **string literal** is text, not structure: a brace
1015    /// counter with no string arm leaves `one_of("{")` — a legal §7.5 program —
1016    /// unbalanced at the closing backtick, which then reads as an *opener*.
1017    /// This is what the rule shared with the input parser's scanner prevents.
1018    #[test]
1019    fn a_brace_inside_a_string_does_not_extend_the_template() {
1020        for template in [
1021            r#"`{c:one_of("{")}`"#,
1022            r#"`{c:one_of("}")}`"#,
1023            r#"`{s:sep("{", int)}`"#,
1024            r#"`{c:one_of("`")}`"#,
1025        ] {
1026            let src = format!("var p = {template}\nvar q = 1\n");
1027            let out = lex(FileId::SYNTHETIC, &src);
1028            assert!(
1029                out.diagnostics.is_empty(),
1030                "{template}: {:?}",
1031                out.diagnostics
1032            );
1033            let templates: Vec<&Token> = out
1034                .tokens
1035                .iter()
1036                .filter(|t| t.kind == SyntaxKind::BacktickTemplate)
1037                .collect();
1038            assert_eq!(templates.len(), 1, "{template}");
1039            assert_eq!(
1040                &src[templates[0].span.start().to_usize()..templates[0].span.end().to_usize()],
1041                template,
1042                "the token is the template and nothing after it"
1043            );
1044        }
1045    }
1046
1047    /// A lexer walks whatever the file contains, so nesting is bounded — and the
1048    /// bound is *exactly* [`praxis_syntax::MAX_TEMPLATE_NESTING`]: a properly
1049    /// closed nest of `MAX_TEMPLATE_NESTING` templates is **one** token, and one
1050    /// level deeper is not.
1051    #[test]
1052    fn template_nesting_is_bounded_at_exactly_max_template_nesting() {
1053        use praxis_syntax::MAX_TEMPLATE_NESTING;
1054
1055        // `n` nested templates whose innermost holds a lone `"` as literal
1056        // text. That quote is text only if the innermost template really is
1057        // entered as a template, at capture depth 0; if the bound stopped one
1058        // level short the same byte sits inside the parent's capture, where a
1059        // quote opens a string literal that never closes.
1060        fn nested(n: usize) -> String {
1061            let mut s = String::new();
1062            for _ in 0..n - 1 {
1063                s.push_str("`{a:");
1064            }
1065            s.push_str("`\"`");
1066            for _ in 0..n - 1 {
1067                s.push_str("}`");
1068            }
1069            s
1070        }
1071
1072        let at_the_bound = nested(MAX_TEMPLATE_NESTING);
1073        let (kinds, diags) = lex_text(&format!("var p = {at_the_bound}\nvar q = 1\n"));
1074        assert!(diags.is_empty(), "{diags:?}");
1075        assert_eq!(
1076            kinds
1077                .iter()
1078                .filter(|k| **k == SyntaxKind::BacktickTemplate)
1079                .count(),
1080            1,
1081            "a nest exactly at the bound is one token"
1082        );
1083
1084        let past = nested(MAX_TEMPLATE_NESTING + 1);
1085        let (_, diags) = lex_text(&format!("var p = {past}\nvar q = 1\n"));
1086        assert!(
1087            diags
1088                .iter()
1089                .any(|d| d.kind() == DiagCode::UnterminatedTemplate),
1090            "one level past the bound the innermost template is not entered — an \
1091             unbounded lexer reports nothing here"
1092        );
1093
1094        // And the pathological case reports rather than overflowing the stack.
1095        let (_, diags) = lex_text(&format!("var p = {}", "`{a:".repeat(5_000)));
1096        assert!(
1097            diags
1098                .iter()
1099                .any(|d| d.kind() == DiagCode::UnterminatedTemplate),
1100            "deep nesting must report, not overflow"
1101        );
1102    }
1103
1104    #[test]
1105    fn diagnostic_renders_for_unknown_byte() {
1106        let map = SourceMap::new();
1107        let id = map.intern("day.px", "var @ = 1");
1108        let out = lex(id, "var @ = 1");
1109        let rendered = praxis_source::render_one(&map, &out.diagnostics[0]);
1110        insta::assert_snapshot!(rendered, @r"
1111error[T003]: unexpected character in source
1112
1113  day.px:1:5
1114  1 | var @ = 1
1115    |     ^ unexpected character in source
1116");
1117    }
1118
1119    // ---- Keywords, builtins and identifier runs ----
1120
1121    #[test]
1122    fn keywords_split_from_identifiers() {
1123        let (kinds, diags) = lex_text(
1124            "var fn if else while for in loop match return break continue read struct enum true false",
1125        );
1126        assert!(diags.is_empty());
1127        for keyword in [
1128            SyntaxKind::KW_VAR,
1129            SyntaxKind::KW_FN,
1130            SyntaxKind::KW_IF,
1131            SyntaxKind::KW_ELSE,
1132            SyntaxKind::KW_WHILE,
1133            SyntaxKind::KW_FOR,
1134            SyntaxKind::KW_IN,
1135            SyntaxKind::KW_LOOP,
1136            SyntaxKind::KW_MATCH,
1137            SyntaxKind::KW_RETURN,
1138            SyntaxKind::KW_BREAK,
1139            SyntaxKind::KW_CONTINUE,
1140            SyntaxKind::KW_READ,
1141            SyntaxKind::KW_STRUCT,
1142            SyntaxKind::KW_ENUM,
1143            SyntaxKind::KW_TRUE,
1144            SyntaxKind::KW_FALSE,
1145        ] {
1146            assert!(
1147                kinds.contains(&keyword),
1148                "missing keyword token {keyword:?}"
1149            );
1150        }
1151    }
1152
1153    #[test]
1154    fn builtins_are_not_keywords() {
1155        // `out` and `panic` are builtin calls, and type names are identifiers.
1156        // Filter out trivia so the assertion is about the real tokens only.
1157        let (kinds, _) = lex_text("out panic Int Vec");
1158        let meaningful: Vec<_> = kinds
1159            .into_iter()
1160            .filter(|k| !k.is_trivia())
1161            .filter(|k| *k != SyntaxKind::EOF)
1162            .collect();
1163        assert!(
1164            meaningful.iter().all(|k| *k == SyntaxKind::Ident),
1165            "expected all identifiers, got {meaningful:?}"
1166        );
1167    }
1168
1169    /// Identifier continuation is a per-*scalar* question (§4.1): a scalar that
1170    /// is not ident-continue ends the run rather than extending it.
1171    #[test]
1172    fn a_non_identifier_scalar_ends_an_identifier_run() {
1173        let (kinds, diags) = lex_text("ab\u{2192}cd");
1174        let meaningful: Vec<_> = kinds
1175            .into_iter()
1176            .filter(|kind| !kind.is_trivia() && *kind != SyntaxKind::EOF)
1177            .collect();
1178        assert_eq!(
1179            meaningful,
1180            vec![SyntaxKind::Ident, SyntaxKind::ERROR, SyntaxKind::Ident],
1181            "`\u{2192}` is not an identifier character, so it splits the run"
1182        );
1183        assert_eq!(diags.len(), 1, "the arrow itself is one bad character");
1184    }
1185
1186    #[test]
1187    fn regression_unicode_identifier_may_start_with_a_unicode_scalar() {
1188        let (kinds, diags) = lex_text("var λ = 1");
1189        assert!(diags.is_empty(), "Unicode identifier faulted: {diags:?}");
1190        assert_eq!(
1191            kinds
1192                .iter()
1193                .filter(|kind| **kind == SyntaxKind::Ident)
1194                .count(),
1195            1,
1196            "`λ` should be one identifier token"
1197        );
1198    }
1199
1200    #[test]
1201    fn regression_lone_underscore_has_its_dedicated_token_kind() {
1202        let (kinds, diags) = lex_text("_");
1203        assert!(diags.is_empty(), "underscore should lex cleanly: {diags:?}");
1204        let meaningful: Vec<_> = kinds
1205            .into_iter()
1206            .filter(|kind| !kind.is_trivia() && *kind != SyntaxKind::EOF)
1207            .collect();
1208        assert_eq!(meaningful, vec![SyntaxKind::UNDERSCORE]);
1209    }
1210
1211    /// …and only the lone one. An underscore is a legal identifier *character*
1212    /// (§4.1), so the split has to be on the whole run, not on the first byte.
1213    #[test]
1214    fn an_underscore_inside_a_name_is_still_an_identifier() {
1215        let (kinds, diags) = lex_text("_x __ x_ _1 snake_case");
1216        assert!(diags.is_empty(), "clean lex: {diags:?}");
1217        let meaningful: Vec<_> = kinds
1218            .into_iter()
1219            .filter(|kind| !kind.is_trivia() && *kind != SyntaxKind::EOF)
1220            .collect();
1221        assert_eq!(meaningful, vec![SyntaxKind::Ident; 5]);
1222    }
1223
1224    // --- the newline fact the parser needs (ADR-049) ------------------------
1225
1226    /// `(kind, preceded_by_newline)` for the meaningful tokens, EOF included.
1227    fn newline_flags(text: &str) -> Vec<(SyntaxKind, bool)> {
1228        let map = SourceMap::new();
1229        let id = map.intern("test.px", text);
1230        lex(id, text)
1231            .tokens
1232            .into_iter()
1233            .filter(|t| !t.kind.is_trivia())
1234            .map(|t| (t.kind, t.preceded_by_newline))
1235            .collect()
1236    }
1237
1238    #[test]
1239    fn only_the_first_token_on_a_line_is_preceded_by_a_newline() {
1240        use SyntaxKind::*;
1241        assert_eq!(
1242            newline_flags("var a\nvar b"),
1243            vec![
1244                (KW_VAR, false),
1245                (Ident, false),
1246                (KW_VAR, true),
1247                (Ident, false),
1248                (EOF, false),
1249            ]
1250        );
1251    }
1252
1253    /// Two statements on one line report no line break anywhere — which is
1254    /// exactly what the parser's statement-separator check has to be able to
1255    /// see.
1256    #[test]
1257    fn same_line_tokens_report_no_newline() {
1258        assert!(
1259            newline_flags("var a = 1 var b = 2")
1260                .iter()
1261                .all(|(_, newline)| !newline)
1262        );
1263    }
1264
1265    /// The fact belongs to the whole trivia run, not to its last token: a
1266    /// comment after the break must not hide it.
1267    #[test]
1268    fn a_line_break_anywhere_in_the_trivia_run_counts() {
1269        let flags = newline_flags("var a\n  // why\n  var b");
1270        assert_eq!(flags[2], (SyntaxKind::KW_VAR, true));
1271
1272        let commented = newline_flags("1 /* over\ntwo lines */ + 2");
1273        assert_eq!(commented[1], (SyntaxKind::PLUS, true));
1274    }
1275
1276    /// A line comment ends its line by construction, so it reads as a break even
1277    /// when EOF eats the `\n` that would otherwise follow.
1278    #[test]
1279    fn a_line_comment_ends_the_line_it_is_on() {
1280        let flags = newline_flags("var a // trailing");
1281        assert_eq!(flags.last().copied(), Some((SyntaxKind::EOF, true)));
1282    }
1283
1284    #[test]
1285    fn multi_char_operators_prefer_longest_match() {
1286        let (kinds, _) = lex_text("-> => == != <= >= += -= *= /= %= .. ..=");
1287        assert!(kinds.contains(&SyntaxKind::THIN_ARROW));
1288        assert!(kinds.contains(&SyntaxKind::FAT_ARROW));
1289        assert!(kinds.contains(&SyntaxKind::EQ2));
1290        assert!(kinds.contains(&SyntaxKind::NEQ));
1291        assert!(kinds.contains(&SyntaxKind::LTEQ));
1292        assert!(kinds.contains(&SyntaxKind::GTEQ));
1293        assert!(kinds.contains(&SyntaxKind::PLUS_EQ));
1294        assert!(kinds.contains(&SyntaxKind::MINUS_EQ));
1295        assert!(kinds.contains(&SyntaxKind::STAR_EQ));
1296        assert!(kinds.contains(&SyntaxKind::SLASH_EQ));
1297        assert!(kinds.contains(&SyntaxKind::PERCENT_EQ));
1298        assert!(kinds.contains(&SyntaxKind::DOT2));
1299        assert!(kinds.contains(&SyntaxKind::DOT2EQ));
1300        // The compound forms must NOT degrade into their single-char parts:
1301        // there is no standalone `=`, `<`, `>`, `.`, `+`, `-`, `*`, `/`, `%`
1302        // anywhere in the input.
1303        assert!(!kinds.contains(&SyntaxKind::EQ));
1304        assert!(!kinds.contains(&SyntaxKind::LT));
1305        assert!(!kinds.contains(&SyntaxKind::GT));
1306        assert!(!kinds.contains(&SyntaxKind::DOT));
1307        assert!(!kinds.contains(&SyntaxKind::PLUS));
1308        assert!(!kinds.contains(&SyntaxKind::MINUS));
1309        assert!(!kinds.contains(&SyntaxKind::STAR));
1310        assert!(!kinds.contains(&SyntaxKind::SLASH));
1311        assert!(!kinds.contains(&SyntaxKind::PERCENT));
1312    }
1313
1314    #[test]
1315    fn single_punct_classifies() {
1316        let (kinds, _) = lex_text("( ) { } [ ] , : ; | + - * / % = ! < > ?");
1317        for k in [
1318            SyntaxKind::L_PAREN,
1319            SyntaxKind::R_PAREN,
1320            SyntaxKind::L_BRACE,
1321            SyntaxKind::R_BRACE,
1322            SyntaxKind::L_BRACK,
1323            SyntaxKind::R_BRACK,
1324            SyntaxKind::COMMA,
1325            SyntaxKind::COLON,
1326            SyntaxKind::SEMICOLON,
1327            SyntaxKind::PIPE,
1328            SyntaxKind::PLUS,
1329            SyntaxKind::MINUS,
1330            SyntaxKind::STAR,
1331            SyntaxKind::SLASH,
1332            SyntaxKind::PERCENT,
1333            SyntaxKind::EQ,
1334            SyntaxKind::BANG,
1335            SyntaxKind::LT,
1336            SyntaxKind::GT,
1337            SyntaxKind::QUESTION,
1338        ] {
1339            assert!(kinds.contains(&k), "missing punct kind {k:?}");
1340        }
1341    }
1342
1343    #[test]
1344    fn pipe2_is_one_token() {
1345        let (kinds, _) = lex_text("||");
1346        assert!(kinds.contains(&SyntaxKind::PIPE2));
1347    }
1348
1349    #[test]
1350    fn text_literal_terminates() {
1351        let (kinds, diags) = lex_text("\"hello\\nworld\"");
1352        assert!(diags.is_empty());
1353        assert!(kinds.contains(&SyntaxKind::TextLit));
1354    }
1355
1356    #[test]
1357    fn unterminated_text_literal_faults() {
1358        let (_, diags) = lex_text("\"never closes");
1359        assert_eq!(diags.len(), 1);
1360        assert_eq!(diags[0].kind(), DiagCode::UnterminatedTextLiteral);
1361    }
1362
1363    #[test]
1364    fn invalid_escape_faults() {
1365        let (_, diags) = lex_text("\"bad \\q escape\"");
1366        assert_eq!(diags.len(), 1);
1367        assert_eq!(diags[0].kind(), DiagCode::InvalidEscape);
1368    }
1369
1370    // --- string interpolation (§8.1, ADR-147) -------------------------------
1371
1372    /// Lex `text` and return `(kind, source text)` for every meaningful token,
1373    /// which is how the fragment shape is asserted: the fragments carry their
1374    /// own delimiters, so the token texts are the whole story.
1375    fn lex_pieces(text: &str) -> Vec<(SyntaxKind, String)> {
1376        let map = SourceMap::new();
1377        let id = map.intern("test.px", text);
1378        lex(id, text)
1379            .tokens
1380            .into_iter()
1381            .filter(|t| !t.kind.is_trivia() && t.kind != SyntaxKind::EOF)
1382            .map(|t| {
1383                (
1384                    t.kind,
1385                    text[t.span.start().to_u32() as usize..t.span.end().to_u32() as usize]
1386                        .to_string(),
1387                )
1388            })
1389            .collect()
1390    }
1391
1392    /// A literal with no brace in it is one token, one kind, no fragments.
1393    #[test]
1394    fn a_literal_with_no_hole_is_still_one_text_lit() {
1395        assert_eq!(
1396            lex_pieces(r#""hello""#),
1397            vec![(SyntaxKind::TextLit, r#""hello""#.to_string())]
1398        );
1399        // A `}` in text closes nothing, so this is not a fragment either.
1400        assert_eq!(
1401            lex_pieces(r#""a } b""#),
1402            vec![(SyntaxKind::TextLit, r#""a } b""#.to_string())]
1403        );
1404    }
1405
1406    /// **The gate for ADR-147 decision 1.** A name inside a hole is an ordinary
1407    /// `Ident` token *at its own range*, not a substring of one opaque literal.
1408    ///
1409    /// That is the whole representation decision. `praxis-hir`'s capture
1410    /// analysis finds free variables by looking token ranges up in the
1411    /// resolver's map, so an implementation that kept the literal whole and
1412    /// re-lexed holes later would leave `|_| "{outer}"` capturing nothing — a
1413    /// silent wrong answer, not a compile error. This asserts the range, not
1414    /// merely the kind, because the kind alone would pass for a token the lexer
1415    /// synthesized at the wrong offset.
1416    #[test]
1417    fn a_name_in_a_hole_is_a_token_at_its_own_range() {
1418        let src = r#""Part 2: {part2}""#;
1419        let map = SourceMap::new();
1420        let id = map.intern("test.px", src);
1421        let ident = lex(id, src)
1422            .tokens
1423            .into_iter()
1424            .find(|t| t.kind == SyntaxKind::Ident)
1425            .expect("the hole's name is an Ident token");
1426        let start = ident.span.start().to_u32() as usize;
1427        let end = ident.span.end().to_u32() as usize;
1428        assert_eq!(&src[start..end], "part2");
1429        assert_eq!(start, src.find("part2").unwrap());
1430    }
1431
1432    /// The three fragment kinds, each carrying one delimiter at each end.
1433    #[test]
1434    fn an_interpolated_literal_is_fragments_around_ordinary_tokens() {
1435        assert_eq!(
1436            lex_pieces(r#""a{x}b{y}c""#),
1437            vec![
1438                (SyntaxKind::InterpOpen, r#""a{"#.to_string()),
1439                (SyntaxKind::Ident, "x".to_string()),
1440                (SyntaxKind::InterpMiddle, "}b{".to_string()),
1441                (SyntaxKind::Ident, "y".to_string()),
1442                (SyntaxKind::InterpClose, r#"}c""#.to_string()),
1443            ]
1444        );
1445    }
1446
1447    /// A hole holds a full expression, so its tokens are the tokens that
1448    /// expression has anywhere else — operators, calls, subscripts and all.
1449    #[test]
1450    fn a_hole_holds_ordinary_expression_tokens() {
1451        let kinds: Vec<SyntaxKind> = lex_pieces(r#""{a + b}""#)
1452            .into_iter()
1453            .map(|p| p.0)
1454            .collect();
1455        assert_eq!(
1456            kinds,
1457            vec![
1458                SyntaxKind::InterpOpen,
1459                SyntaxKind::Ident,
1460                SyntaxKind::PLUS,
1461                SyntaxKind::Ident,
1462                SyntaxKind::InterpClose,
1463            ]
1464        );
1465    }
1466
1467    /// A `{` inside a hole is an ordinary brace and the `}` matching it does not
1468    /// close the hole — which is what the per-hole depth counter is for. Without
1469    /// it `"{if c { 1 } else { 2 }}"` would end at the first `}` and the rest of
1470    /// the line would be lexed as source nobody wrote.
1471    #[test]
1472    fn a_brace_inside_a_hole_nests_rather_than_closing_it() {
1473        let pieces = lex_pieces(r#""{if c { 1 } else { 2 }}""#);
1474        assert_eq!(pieces.first().unwrap().0, SyntaxKind::InterpOpen);
1475        assert_eq!(
1476            pieces.last().unwrap(),
1477            &(SyntaxKind::InterpClose, r#"}""#.to_string())
1478        );
1479        assert_eq!(
1480            pieces
1481                .iter()
1482                .filter(|p| p.0 == SyntaxKind::L_BRACE || p.0 == SyntaxKind::R_BRACE)
1483                .count(),
1484            4,
1485            "the two blocks' braces are ordinary braces"
1486        );
1487    }
1488
1489    /// A `"` inside a hole opens a literal of its own, and a hole inside *that*
1490    /// pushes a second frame. Only the innermost frame is ever consulted, so
1491    /// nesting falls out of the stack rather than being arranged.
1492    #[test]
1493    fn a_literal_nested_in_a_hole_is_its_own_run() {
1494        let kinds: Vec<SyntaxKind> = lex_pieces(r#""{m["k"]}""#)
1495            .into_iter()
1496            .map(|p| p.0)
1497            .collect();
1498        assert_eq!(
1499            kinds,
1500            vec![
1501                SyntaxKind::InterpOpen,
1502                SyntaxKind::Ident,
1503                SyntaxKind::L_BRACK,
1504                SyntaxKind::TextLit,
1505                SyntaxKind::R_BRACK,
1506                SyntaxKind::InterpClose,
1507            ]
1508        );
1509        // …and the nested literal may itself interpolate.
1510        let nested = lex_pieces(r#""{f("{y}")}""#);
1511        assert_eq!(
1512            nested
1513                .iter()
1514                .filter(|p| p.0 == SyntaxKind::InterpOpen)
1515                .count(),
1516            2
1517        );
1518    }
1519
1520    /// **The gate for ADR-147 decision 5.** An unterminated interpolated literal
1521    /// is the *fallback*: one `TextLit` and one `T004`, exactly what an
1522    /// unterminated plain literal is. No fragment is emitted, so nothing is left
1523    /// on the lexer's hole stack and the rest of the file lexes normally.
1524    ///
1525    /// An implementation that emitted the opening fragment first and discovered
1526    /// the problem afterwards passes every other test here and produces a
1527    /// cascade on this one.
1528    #[test]
1529    fn an_unterminated_interpolated_literal_is_one_text_lit_and_t004() {
1530        for src in ["\"a {b\ncd\n", "\"{a\n", "\"{a // b}\"\n"] {
1531            let (kinds, diags) = lex_text(src);
1532            assert_eq!(diags.len(), 1, "{src:?}");
1533            assert_eq!(
1534                diags[0].kind(),
1535                DiagCode::UnterminatedTextLiteral,
1536                "{src:?}"
1537            );
1538            assert!(kinds.contains(&SyntaxKind::TextLit), "{src:?}");
1539            assert!(
1540                !kinds.contains(&SyntaxKind::InterpOpen),
1541                "no fragment is emitted for a literal that never closes: {src:?}"
1542            );
1543        }
1544    }
1545
1546    /// `\{` is a literal brace, so it opens no hole and earns no `T005`
1547    /// (ADR-147 decision 4). `\}` is accepted for symmetry.
1548    #[test]
1549    fn an_escaped_brace_is_literal_text() {
1550        let (kinds, diags) = lex_text(r#""\{ and \}""#);
1551        assert!(diags.is_empty(), "{diags:?}");
1552        assert_eq!(kinds.first(), Some(&SyntaxKind::TextLit));
1553    }
1554
1555    /// Escapes are still validated in every fragment, not just in a whole
1556    /// literal — and only in the *literal text*, since a hole holds expression
1557    /// tokens where a backslash is not an escape at all.
1558    #[test]
1559    fn an_escape_is_validated_in_every_fragment() {
1560        let (_, diags) = lex_text(r#""\q{x}\z""#);
1561        assert_eq!(diags.len(), 2, "{diags:?}");
1562        assert!(diags.iter().all(|d| d.kind() == DiagCode::InvalidEscape));
1563    }
1564
1565    // --- the character literal (ADR-141) ---
1566
1567    /// A `'…'` literal is one `CharLit` token spanning both quotes.
1568    #[test]
1569    fn a_char_literal_is_one_token() {
1570        let map = SourceMap::new();
1571        let src = "var c = '#'";
1572        let id = map.intern("test.px", src);
1573        let out = lex(id, src);
1574        assert!(out.diagnostics.is_empty(), "{:?}", out.diagnostics);
1575        let lit = out
1576            .tokens
1577            .iter()
1578            .find(|t| t.kind == SyntaxKind::CharLit)
1579            .expect("a CharLit token");
1580        assert_eq!(
1581            &src[lit.span.start().to_usize()..lit.span.end().to_usize()],
1582            "'#'"
1583        );
1584    }
1585
1586    /// The escape set is the text literal's plus `\'`, and **no more**: a `\u`
1587    /// is `T005` here exactly as it is inside `"…"`. Pinning both halves is what
1588    /// keeps the two tables from drifting into two languages.
1589    #[test]
1590    fn the_char_escapes_are_the_text_escapes_plus_the_quote() {
1591        for src in [
1592            r"var c = '\n'",
1593            r"var c = '\r'",
1594            r"var c = '\t'",
1595            r"var c = '\0'",
1596            r"var c = '\\'",
1597            r"var c = '\''",
1598            r#"var c = '\"'"#,
1599        ] {
1600            let (kinds, diags) = lex_text(src);
1601            assert!(diags.is_empty(), "{src}: {diags:?}");
1602            assert!(kinds.contains(&SyntaxKind::CharLit), "{src}");
1603        }
1604        for src in [r"var c = '\q'", r"var c = '\u{41}'", r"var c = '\x41'"] {
1605            let (_, diags) = lex_text(src);
1606            assert!(
1607                diags.iter().any(|d| d.kind() == DiagCode::InvalidEscape),
1608                "{src}: {diags:?}"
1609            );
1610        }
1611    }
1612
1613    /// `"##"[0]` is a well-typed program that quietly means `#`; `'##'` is a
1614    /// lexical error carrying the rewrite the author meant.
1615    #[test]
1616    fn a_two_character_char_literal_is_a_lex_error() {
1617        let (kinds, diags) = lex_text("var c = 'ab'");
1618        assert_eq!(diags.len(), 1, "{diags:?}");
1619        assert_eq!(diags[0].kind(), DiagCode::CharLiteralIsNotOneCharacter);
1620        assert_eq!(
1621            diags[0].message(),
1622            "a character literal holds exactly one character"
1623        );
1624        let fix = diags[0].suggestions().first().expect("a fix-it");
1625        assert_eq!(fix.replacement.as_deref(), Some("\"ab\""));
1626        // Lossless even when refused (ADR-003).
1627        assert!(kinds.contains(&SyntaxKind::CharLit));
1628    }
1629
1630    /// …and the empty one, where `""[0]` is an index fault at run time.
1631    #[test]
1632    fn an_empty_char_literal_is_a_lex_error() {
1633        let (kinds, diags) = lex_text("var c = ''");
1634        assert_eq!(diags.len(), 1, "{diags:?}");
1635        assert_eq!(diags[0].kind(), DiagCode::CharLiteralIsNotOneCharacter);
1636        assert_eq!(
1637            diags[0].message(),
1638            "empty character literal: `''` names no character"
1639        );
1640        assert!(kinds.contains(&SyntaxKind::CharLit));
1641    }
1642
1643    #[test]
1644    fn an_unterminated_char_literal_ends_at_its_line() {
1645        let (kinds, diags) = lex_text("var c = 'a\nvar d = 1\n");
1646        assert_eq!(diags.len(), 1, "{diags:?}");
1647        assert_eq!(diags[0].kind(), DiagCode::UnterminatedCharLiteral);
1648        assert!(kinds.contains(&SyntaxKind::CharLit));
1649        // The rest of the file is still lexed: the `var d` line is intact.
1650        assert_eq!(
1651            kinds.iter().filter(|k| **k == SyntaxKind::KW_VAR).count(),
1652            2
1653        );
1654    }
1655
1656    /// A scan that advanced by byte would count `'é'` as two characters and
1657    /// report a literal that names one.
1658    #[test]
1659    fn a_multibyte_scalar_is_one_character() {
1660        for src in ["var c = 'é'", "var c = '😀'", "var c = '字'"] {
1661            let (kinds, diags) = lex_text(src);
1662            assert!(diags.is_empty(), "{src}: {diags:?}");
1663            assert!(kinds.contains(&SyntaxKind::CharLit), "{src}");
1664        }
1665    }
1666
1667    #[test]
1668    fn division_operator_not_a_comment() {
1669        // A lone `/` not followed by `/` or `*` is division.
1670        let (kinds, _) = lex_text("a / b");
1671        assert!(kinds.contains(&SyntaxKind::SLASH));
1672        assert!(!kinds.contains(&SyntaxKind::LineComment));
1673        assert!(!kinds.contains(&SyntaxKind::BlockComment));
1674    }
1675
1676    #[test]
1677    fn eof_span_is_at_end() {
1678        let map = SourceMap::new();
1679        let id = map.intern("test.px", "ab");
1680        let out = lex(id, "ab");
1681        let eof = out.tokens.last().expect("eof token present");
1682        assert_eq!(eof.kind, SyntaxKind::EOF);
1683        assert_eq!(eof.span, Span::new(2, 2));
1684    }
1685
1686    // ---- Float literal lexing (§4.12) ----
1687
1688    /// Lex a single numeric token (no trivia) and assert its kind + text span.
1689    fn lex_one_number(text: &str) -> (SyntaxKind, String) {
1690        let map = SourceMap::new();
1691        let id = map.intern("test.px", text);
1692        let out = lex(id, text);
1693        let tok = out
1694            .tokens
1695            .iter()
1696            .find(|t| matches!(t.kind, SyntaxKind::IntLit | SyntaxKind::FloatLit))
1697            .expect("a numeric token");
1698        let span = tok.span.start().0 as usize..tok.span.end().0 as usize;
1699        (tok.kind, text[span].to_string())
1700    }
1701
1702    #[test]
1703    fn float_with_fraction() {
1704        let (kind, text) = lex_one_number("3.14");
1705        assert_eq!(kind, SyntaxKind::FloatLit);
1706        assert_eq!(text, "3.14");
1707    }
1708
1709    #[test]
1710    fn float_trailing_dot() {
1711        // `2.` — a digit, a dot, then EOF. Not a range (no second dot).
1712        let (kind, text) = lex_one_number("2.");
1713        assert_eq!(kind, SyntaxKind::IntLit);
1714        assert_eq!(text, "2");
1715        // `2.` alone (dot then EOF) does NOT consume the dot as a fraction
1716        // because there's no digit after it. The dot becomes a separate DOT
1717        // token — so the number is just `2` (an Int). A trailing-dot float
1718        // requires `2.0`.
1719        let map = SourceMap::new();
1720        let id = map.intern("test.px", "2.");
1721        let out = lex(id, "2.");
1722        assert!(out.tokens.iter().any(|t| t.kind == SyntaxKind::DOT));
1723    }
1724
1725    #[test]
1726    fn float_with_exponent() {
1727        let (kind, text) = lex_one_number("1e10");
1728        assert_eq!(kind, SyntaxKind::FloatLit);
1729        assert_eq!(text, "1e10");
1730    }
1731
1732    #[test]
1733    fn float_with_fraction_and_signed_exponent() {
1734        let (kind, text) = lex_one_number("1.5e-3");
1735        assert_eq!(kind, SyntaxKind::FloatLit);
1736        assert_eq!(text, "1.5e-3");
1737    }
1738
1739    #[test]
1740    fn float_with_uppercase_exponent_and_plus() {
1741        let (kind, text) = lex_one_number("2.0E+5");
1742        assert_eq!(kind, SyntaxKind::FloatLit);
1743        assert_eq!(text, "2.0E+5");
1744    }
1745
1746    #[test]
1747    fn range_not_lexed_as_float() {
1748        // `1..5` must lex as IntLit(1), DOT2, IntLit(5) — never a float.
1749        let (kinds, diags) = lex_text("1..5");
1750        assert!(diags.is_empty(), "got diagnostics: {diags:?}");
1751        assert!(kinds.contains(&SyntaxKind::IntLit));
1752        assert!(kinds.contains(&SyntaxKind::DOT2));
1753        assert!(!kinds.contains(&SyntaxKind::FloatLit));
1754    }
1755
1756    #[test]
1757    fn inclusive_range_not_lexed_as_float() {
1758        // `1..=5` must lex as IntLit(1), DOT2EQ, IntLit(5).
1759        let (kinds, diags) = lex_text("1..=5");
1760        assert!(diags.is_empty(), "got diagnostics: {diags:?}");
1761        assert!(kinds.contains(&SyntaxKind::IntLit));
1762        assert!(kinds.contains(&SyntaxKind::DOT2EQ));
1763        assert!(!kinds.contains(&SyntaxKind::FloatLit));
1764    }
1765
1766    #[test]
1767    fn float_then_range_boundary() {
1768        // `1.5..2.5` — the first float ends at the second dot; then DOT2.
1769        let (kinds, diags) = lex_text("1.5..2.5");
1770        assert!(diags.is_empty(), "got diagnostics: {diags:?}");
1771        assert_eq!(
1772            kinds.iter().filter(|k| **k == SyntaxKind::FloatLit).count(),
1773            2,
1774            "expected two FloatLit tokens"
1775        );
1776        assert!(kinds.contains(&SyntaxKind::DOT2));
1777    }
1778
1779    #[test]
1780    fn bare_integer_stays_int() {
1781        let (kind, text) = lex_one_number("42");
1782        assert_eq!(kind, SyntaxKind::IntLit);
1783        assert_eq!(text, "42");
1784    }
1785
1786    #[test]
1787    fn trailing_e_without_digits_stays_int() {
1788        // `1e` — exponent with no digits is `IntLit(1)` then `Ident(e)`.
1789        let (kinds, _diags) = lex_text("1e");
1790        assert!(kinds.contains(&SyntaxKind::IntLit));
1791        assert!(kinds.contains(&SyntaxKind::Ident));
1792        assert!(!kinds.contains(&SyntaxKind::FloatLit));
1793    }
1794
1795    #[test]
1796    fn dot_method_call_not_lexed_as_float() {
1797        // `1.method()` — dot followed by a letter is a DOT + Ident, not a float.
1798        let (kinds, _diags) = lex_text("1.method()");
1799        assert!(kinds.contains(&SyntaxKind::IntLit));
1800        assert!(kinds.contains(&SyntaxKind::DOT));
1801        assert!(kinds.contains(&SyntaxKind::Ident));
1802        assert!(!kinds.contains(&SyntaxKind::FloatLit));
1803    }
1804
1805    /// A `_` between digits belongs to the literal, in every digit run.
1806    ///
1807    /// The token text keeps the separators; removing them is the decoder's half.
1808    #[test]
1809    fn a_digit_separator_belongs_to_the_literal() {
1810        for (src, kind) in [
1811            ("1_000", SyntaxKind::IntLit),
1812            ("1_0_0", SyntaxKind::IntLit),
1813            // A run is one separator: nothing here counts `_`s.
1814            ("1__0", SyntaxKind::IntLit),
1815            // A long run of separators, past `Int`'s range.
1816            ("9_223_372_036_854_775_808", SyntaxKind::IntLit),
1817            // Every digit run, not just the integer part.
1818            ("3.141_592", SyntaxKind::FloatLit),
1819            ("1_0.5", SyntaxKind::FloatLit),
1820            ("1e1_0", SyntaxKind::FloatLit),
1821            ("1.5e-1_0", SyntaxKind::FloatLit),
1822        ] {
1823            let (got, text) = lex_one_number(src);
1824            assert_eq!(got, kind, "{src} lexed as {got:?}");
1825            assert_eq!(text, src, "{src} did not lex as one token");
1826            let (_, diags) = lex_text(src);
1827            assert!(diags.is_empty(), "{src} reported {diags:?}");
1828        }
1829    }
1830
1831    /// …and a `_` with no digit after it is not part of the literal at all.
1832    ///
1833    /// `1_` is the literal `1` followed by the `UNDERSCORE` token — which the
1834    /// parser rejects where it stands. A separator has digits on *both* sides,
1835    /// so the number never ends in punctuation.
1836    #[test]
1837    fn a_trailing_separator_is_not_part_of_the_literal() {
1838        let (kind, text) = lex_one_number("1_");
1839        assert_eq!(kind, SyntaxKind::IntLit);
1840        assert_eq!(text, "1");
1841        let (kinds, _) = lex_text("1_");
1842        assert!(kinds.contains(&SyntaxKind::UNDERSCORE));
1843
1844        // A name after the `_` is a name: `1_a` is `1` then `_a`.
1845        let (kind, text) = lex_one_number("1_a");
1846        assert_eq!(kind, SyntaxKind::IntLit);
1847        assert_eq!(text, "1");
1848        let (kinds, _) = lex_text("1_a");
1849        assert!(kinds.contains(&SyntaxKind::Ident));
1850
1851        // A separator cannot open a fraction — `1._0` is not a float.
1852        let (kind, text) = lex_one_number("1._0");
1853        assert_eq!(kind, SyntaxKind::IntLit);
1854        assert_eq!(text, "1");
1855        let (kinds, _) = lex_text("1._0");
1856        assert!(!kinds.contains(&SyntaxKind::FloatLit));
1857    }
1858
1859    /// A digit run immediately after a `.` is a **tuple index**, so it takes no
1860    /// fractional part: `t.0.1` is two indices, not an index and the float
1861    /// `0.1`.
1862    ///
1863    /// The rule is adjacency to a bare `DOT` **token**, which is why `1.5..2.5`
1864    /// is untouched: the byte before its `2` is a `.` too, but that one was
1865    /// consumed into a `DOT2`.
1866    #[test]
1867    fn a_digit_run_after_a_dot_is_an_index_and_takes_no_fraction() {
1868        // Two indices in a row.
1869        let (kinds, _) = lex_text("t.0.1");
1870        assert!(
1871            !kinds.contains(&SyntaxKind::FloatLit),
1872            "`t.0.1` is two indices: {kinds:?}"
1873        );
1874        assert_eq!(
1875            kinds.iter().filter(|k| **k == SyntaxKind::IntLit).count(),
1876            2
1877        );
1878        assert_eq!(kinds.iter().filter(|k| **k == SyntaxKind::DOT).count(), 2);
1879
1880        // A single index, and a wider one.
1881        for src in ["p.0", "x.10", "p.0 + 1"] {
1882            let (kinds, diags) = lex_text(src);
1883            assert!(diags.is_empty(), "{src}: {diags:?}");
1884            assert!(kinds.contains(&SyntaxKind::IntLit), "{src}");
1885            assert!(!kinds.contains(&SyntaxKind::FloatLit), "{src}");
1886        }
1887
1888        // …and every float that is *not* in that position still lexes as one.
1889        for src in ["3.0", "1.5", "var x = 0.25", "1.5e3", "3.141_592"] {
1890            let (kinds, diags) = lex_text(src);
1891            assert!(diags.is_empty(), "{src}: {diags:?}");
1892            assert!(kinds.contains(&SyntaxKind::FloatLit), "{src}: {kinds:?}");
1893        }
1894        // The case that makes the rule a *token* rule: the byte before `2` is
1895        // the second `.` of the `..`, but that `.` is inside a `DOT2`.
1896        let (kinds, _) = lex_text("1.5..2.5");
1897        assert_eq!(
1898            kinds.iter().filter(|k| **k == SyntaxKind::FloatLit).count(),
1899            2,
1900            "both bounds are floats: {kinds:?}"
1901        );
1902        assert!(kinds.contains(&SyntaxKind::DOT2));
1903        // A range whose upper bound is a float, with an integer lower bound —
1904        // the same trap one token earlier.
1905        let (kinds, _) = lex_text("0..1.5");
1906        assert!(kinds.contains(&SyntaxKind::FloatLit), "{kinds:?}");
1907        // A method call on a float literal is unaffected: the `.` before `sqrt`
1908        // is not before a digit.
1909        let (kinds, _) = lex_text("1.5.sqrt()");
1910        assert!(kinds.contains(&SyntaxKind::FloatLit), "{kinds:?}");
1911    }
1912
1913    /// A separated bound is still a bound: `1_000..2_000` is a range, not a
1914    /// float and not one token.
1915    #[test]
1916    fn a_separated_literal_is_still_a_range_bound() {
1917        let (kinds, diags) = lex_text("1_000..2_000");
1918        assert!(diags.is_empty(), "got diagnostics: {diags:?}");
1919        assert_eq!(
1920            kinds.iter().filter(|k| **k == SyntaxKind::IntLit).count(),
1921            2,
1922            "expected two IntLit tokens"
1923        );
1924        assert!(kinds.contains(&SyntaxKind::DOT2));
1925        assert!(!kinds.contains(&SyntaxKind::FloatLit));
1926    }
1927}