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}