Skip to main content

blue_lang_syntax/
lex.rs

1//! The blue lexer.
2//!
3//! Blue's surface is Ruby/Elixir-shaped, so the lexer's job is different
4//! from an s-expression reader's: it must distinguish `foo` the send from
5//! `foo(x)` the call, keep `:sym` distinct from `a ? b : c`, and record
6//! enough position to point a diagnostic at the byte the human typed.
7//!
8//! Two decisions here are load-bearing downstream and are made once:
9//!
10//! 1. **Every token carries a byte span.** `theory/BLUE.md` §0 requires
11//!    total provenance — every node in an expanded program traceable to the
12//!    source that caused it — and provenance cannot be recovered later if
13//!    the lexer drops it.
14//! 2. **Trivia is a token, not a skip.** Comments and newlines are emitted
15//!    rather than discarded, because a canonical formatter and an LSP both
16//!    need a lossless stream. The measured failure this avoids is
17//!    tatara-lisp's own reader, which discards trivia at tokenize time and
18//!    thereby makes a comment-preserving formatter unbuildable on top of it.
19//!    Callers that do not want trivia filter it; callers that need it
20//!    cannot conjure it back.
21
22use std::fmt;
23
24/// A half-open byte range into the source.
25///
26/// **tatara-lisp's `Span`, not a blue one.** blue used to declare its own
27/// `{ start, end }` pair here, which was a strict subset of the substrate's:
28/// tatara's carries the same two fields plus `synthetic()` / `is_synthetic()`,
29/// `merge`, `line_col` and a `Display`. Two structurally-identical span types
30/// in one pipeline is the duplication that forces a conversion at every
31/// boundary — and the boundary that mattered is the one where a *checker*
32/// diagnostic reaches the LSP, because a conversion that has to invent a value
33/// for "no span" is how a type error ends up reported at line 0.
34///
35/// Sharing the type also means the parser can emit `tatara_lisp::Spanned`
36/// directly: the tree blue parses into is the tree the evaluator eats, spans
37/// and all.
38pub use tatara_lisp::Span;
39
40#[derive(Clone, Debug, PartialEq)]
41pub enum TokenKind {
42    // literals
43    Int(i64),
44    Float(f64),
45    Str(String),
46    /// An interpolated string: alternating literal and expression parts.
47    ///
48    /// `"a#{x}b"` lexes to `["a", "b"]` literals with `["x"]` between them —
49    /// the expression is kept as SOURCE TEXT and parsed by the parser, which
50    /// already knows how to parse an expression. Re-implementing expression
51    /// lexing inside the string lexer would be a second parser, and the two
52    /// would drift.
53    InterpolatedStr {
54        /// `parts.len() == exprs.len() + 1`, always — the literal before each
55        /// expression, plus the tail. An empty literal is kept rather than
56        /// dropped so that invariant holds for `"#{a}#{b}"` too.
57        parts: Vec<String>,
58        exprs: Vec<String>,
59    },
60    /// `:name` — a Ruby symbol, which lowers to a tatara-lisp keyword.
61    Sym(String),
62    True,
63    False,
64    Nil,
65
66    /// An identifier, or a keyword-like head (`if`, `do`, `end`, …).
67    /// The parser decides which; the lexer does not need to know.
68    Ident(String),
69
70    // punctuation
71    LParen,
72    RParen,
73    LBracket,
74    RBracket,
75    LBrace,
76    RBrace,
77    Comma,
78    Dot,
79    /// `:` in a hash literal (`foo: 1`) is folded into `Label`; a bare
80    /// colon is retained for anything else.
81    Colon,
82    /// `::` — the qualifier in `retsu::first`, package on the left and one of
83    /// its definitions on the right. One token, so a qualified name never
84    /// depends on how two colons happened to lex.
85    PathSep,
86    /// `foo:` — a hash-literal label. Lexing this as one token is what
87    /// makes `{foo: 1}` and `{:foo => 1}` distinguishable at the parser
88    /// without lookahead games.
89    Label(String),
90    /// `=>` — the "rocket".
91    Rocket,
92    /// `|>` — the pipeline operator.
93    Pipe,
94
95    /// Any operator run: `+ - * / == != < <= > >= && || = ! %`.
96    Op(String),
97
98    // trivia — emitted, never skipped
99    Comment(String),
100    Newline,
101
102    Eof,
103}
104
105/// A token as an error message names it: its blue spelling in backticks, or,
106/// for the tokens that have no spelling, what it is in words.
107///
108/// **This is what a parse error says it found.** Errors used to format the
109/// token with `{:?}`, so an author who wrote `[1, 2 y]` read
110/// `found Ident("y")` — the name of a Rust enum variant, in a language whose
111/// surface has no such thing. A message is read by the person who wrote the
112/// source, so it speaks the source's vocabulary.
113impl fmt::Display for TokenKind {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        match self {
116            Self::Int(v) => write!(f, "the number `{v}`"),
117            Self::Float(v) => write!(f, "the number `{v}`"),
118            Self::Str(_) | Self::InterpolatedStr { .. } => f.write_str("a string"),
119            Self::Sym(s) => write!(f, "the symbol `:{s}`"),
120            Self::True => f.write_str("`true`"),
121            Self::False => f.write_str("`false`"),
122            Self::Nil => f.write_str("`nil`"),
123            Self::Ident(n) => write!(f, "`{n}`"),
124            Self::LParen => f.write_str("`(`"),
125            Self::RParen => f.write_str("`)`"),
126            Self::LBracket => f.write_str("`[`"),
127            Self::RBracket => f.write_str("`]`"),
128            Self::LBrace => f.write_str("`{`"),
129            Self::RBrace => f.write_str("`}`"),
130            Self::Comma => f.write_str("`,`"),
131            Self::Dot => f.write_str("`.`"),
132            Self::Colon => f.write_str("`:`"),
133            Self::PathSep => f.write_str("`::`"),
134            Self::Label(n) => write!(f, "the label `{n}:`"),
135            Self::Rocket => f.write_str("`=>`"),
136            Self::Pipe => f.write_str("`|>`"),
137            Self::Op(o) => write!(f, "`{o}`"),
138            Self::Comment(_) => f.write_str("a comment"),
139            Self::Newline => f.write_str("the end of the line"),
140            Self::Eof => f.write_str("the end of the file"),
141        }
142    }
143}
144
145#[derive(Clone, Debug, PartialEq)]
146pub struct Token {
147    pub kind: TokenKind,
148    pub span: Span,
149}
150
151impl Token {
152    /// Is this token trivia (a comment or a newline)?
153    pub fn is_trivia(&self) -> bool {
154        matches!(self.kind, TokenKind::Comment(_) | TokenKind::Newline)
155    }
156}
157
158#[derive(Clone, Debug, PartialEq)]
159pub struct LexError {
160    pub message: String,
161    pub span: Span,
162}
163
164impl fmt::Display for LexError {
165    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
166        write!(
167            f,
168            "{} at {}..{}",
169            self.message, self.span.start, self.span.end
170        )
171    }
172}
173
174impl std::error::Error for LexError {}
175
176/// Characters that may begin or continue an operator run.
177const OP_CHARS: &str = "+-*/=<>!%&|";
178
179/// Tokenize `src`, including trivia.
180pub fn lex(src: &str) -> Result<Vec<Token>, LexError> {
181    Lexer::new(src).run()
182}
183
184struct Lexer<'a> {
185    src: &'a str,
186    bytes: &'a [u8],
187    pos: usize,
188    out: Vec<Token>,
189}
190
191impl<'a> Lexer<'a> {
192    fn new(src: &'a str) -> Self {
193        Self {
194            src,
195            bytes: src.as_bytes(),
196            pos: if src.starts_with('\u{feff}') {
197                '\u{feff}'.len_utf8()
198            } else {
199                0
200            },
201            out: Vec::new(),
202        }
203    }
204
205    /// Decode the whole UTF-8 character at the cursor, with its byte width.
206    ///
207    /// The lexer scans bytes because that is right for ASCII, which is all of
208    /// blue's structure. This is the seam where it stops being right: a
209    /// character outside ASCII has to be decoded before anything can classify
210    /// it, and the previous approach — treating every byte >= 0x80 as a letter
211    /// — could not tell `≠` from `文`, so an operator was unreachable and a
212    /// malformed sequence became a silent identifier.
213    fn decode_char(&self) -> Option<(char, usize)> {
214        let rest = self.src.get(self.pos..)?;
215        let ch = rest.chars().next()?;
216        Some((ch, ch.len_utf8()))
217    }
218
219    fn peek(&self) -> Option<u8> {
220        self.bytes.get(self.pos).copied()
221    }
222
223    fn peek_at(&self, n: usize) -> Option<u8> {
224        self.bytes.get(self.pos + n).copied()
225    }
226
227    fn push(&mut self, kind: TokenKind, start: usize) {
228        self.out.push(Token {
229            kind,
230            span: Span::new(start, self.pos),
231        });
232    }
233
234    fn err(&self, message: impl Into<String>, start: usize) -> LexError {
235        LexError {
236            message: message.into(),
237            span: Span::new(start, self.pos.max(start + 1)),
238        }
239    }
240
241    fn run(mut self) -> Result<Vec<Token>, LexError> {
242        while let Some(c) = self.peek() {
243            let start = self.pos;
244            match c {
245                b'\n' => {
246                    self.pos += 1;
247                    self.push(TokenKind::Newline, start);
248                }
249                // Horizontal whitespace carries no meaning in blue and is
250                // reconstructed by the formatter, so it is the one thing
251                // dropped. Newlines are kept: they are statement separators.
252                b' ' | b'\t' | b'\r' => {
253                    self.pos += 1;
254                }
255                b'#' => {
256                    while let Some(c) = self.peek() {
257                        if c == b'\n' {
258                            break;
259                        }
260                        self.pos += 1;
261                    }
262                    let text = self.src[start..self.pos].to_string();
263                    self.push(TokenKind::Comment(text), start);
264                }
265                b'"' => self.lex_string(start)?,
266                b'0'..=b'9' => self.lex_number(start)?,
267                b':' => self.lex_colon(start),
268                b'(' => self.one(TokenKind::LParen, start),
269                b')' => self.one(TokenKind::RParen, start),
270                b'[' => self.one(TokenKind::LBracket, start),
271                b']' => self.one(TokenKind::RBracket, start),
272                b'{' => self.one(TokenKind::LBrace, start),
273                b'}' => self.one(TokenKind::RBrace, start),
274                b',' => self.one(TokenKind::Comma, start),
275                b'.' => self.one(TokenKind::Dot, start),
276                // Non-ASCII goes through the UTF-8 layer, which decodes a
277                // whole CHARACTER and asks `kigou` what it means. This must
278                // precede the ident branch: a symbol like `≠` is an operator,
279                // not the first letter of a name.
280                c if c >= 0x80 => {
281                    let Some((ch, width)) = self.decode_char() else {
282                        self.pos += 1;
283                        return Err(self.err("invalid UTF-8 in source", start));
284                    };
285                    match crate::kigou::classify(ch) {
286                        crate::kigou::Class::Operator(op) => {
287                            self.pos += width;
288                            self.push(TokenKind::Op(op.to_owned()), start);
289                        }
290                        crate::kigou::Class::Word => self.lex_ident(start),
291                        crate::kigou::Class::Reject => {
292                            self.pos += width;
293                            return Err(self.err(
294                                format!(
295                                    "the character {ch:?} (U+{:04X}) is not legal in blue source \
296                                     outside a string — invisible and control characters are \
297                                     rejected because source that looks correct and is not costs \
298                                     far more than a clear error here",
299                                    ch as u32
300                                ),
301                                start,
302                            ));
303                        }
304                    }
305                }
306                c if is_ident_start(c) => self.lex_ident(start),
307                c if OP_CHARS.as_bytes().contains(&c) => self.lex_op(start),
308                // `;` is refused by name: blue has exactly one statement
309                // separator, the new line, so a `;` is a habit from another
310                // language rather than a character blue could mean.
311                b';' => {
312                    self.pos += 1;
313                    return Err(self.err(
314                        "unexpected `;`: blue ends a statement at the end of the line, \
315                         so put the next statement on a new line",
316                        start,
317                    ));
318                }
319                _ => {
320                    self.pos += 1;
321                    return Err(self.err(format!("unexpected character `{}`", c as char), start));
322                }
323            }
324        }
325        let end = self.pos;
326        self.out.push(Token {
327            kind: TokenKind::Eof,
328            span: Span::new(end, end),
329        });
330        Ok(self.out)
331    }
332
333    fn one(&mut self, kind: TokenKind, start: usize) {
334        self.pos += 1;
335        self.push(kind, start);
336    }
337
338    fn lex_string(&mut self, start: usize) -> Result<(), LexError> {
339        self.pos += 1; // opening quote
340        let mut buf = String::new();
341        // Interpolation state. `parts` collects the literal run before each
342        // `#{…}`; `exprs` collects the raw source between the braces.
343        let mut parts: Vec<String> = Vec::new();
344        let mut exprs: Vec<String> = Vec::new();
345        loop {
346            // `#{` opens an interpolation. A bare `#` is just a character — a
347            // string full of `#` comments would otherwise be unwritable.
348            if self.peek() == Some(b'#') && self.src.as_bytes().get(self.pos + 1) == Some(&b'{') {
349                self.pos += 2;
350                let expr_start = self.pos;
351                let Some(after) = skip_interpolation(self.bytes, self.pos) else {
352                    return Err(self.err("unterminated `#{` interpolation", start));
353                };
354                self.pos = after - 1;
355                exprs.push(self.src[expr_start..self.pos].to_string());
356                self.pos += 1; // past '}'
357                parts.push(std::mem::take(&mut buf));
358                continue;
359            }
360            match self.peek() {
361                None => return Err(self.err("unterminated string literal", start)),
362                Some(b'"') => {
363                    self.pos += 1;
364                    break;
365                }
366                Some(b'\\') => {
367                    self.pos += 1;
368                    let esc = self
369                        .peek()
370                        .ok_or_else(|| self.err("unterminated escape", start))?;
371                    let ch = match esc {
372                        b'n' => '\n',
373                        b't' => '\t',
374                        b'r' => '\r',
375                        b'\\' => '\\',
376                        b'"' => '"',
377                        b'0' => '\0',
378                        // `\u{...}` — a Unicode scalar by codepoint. Ruby and
379                        // Elixir both have it, and without it a blue source
380                        // file can only carry a non-ASCII character literally,
381                        // which is exactly the case where an explicit escape
382                        // matters most (combining marks, zero-width joiners,
383                        // anything invisible in an editor).
384                        b'u' => {
385                            self.pos += 1; // past 'u'
386                            if self.peek() != Some(b'{') {
387                                return Err(self.err("expected `{` after \\u", start));
388                            }
389                            self.pos += 1; // past '{'
390                            let hex_start = self.pos;
391                            while self.peek().is_some_and(|c| c != b'}') {
392                                self.pos += 1;
393                            }
394                            if self.peek() != Some(b'}') {
395                                return Err(self.err("unterminated \\u{...} escape", start));
396                            }
397                            let hex = &self.src[hex_start..self.pos];
398                            let code = u32::from_str_radix(hex, 16).map_err(|_| {
399                                self.err(format!("`{hex}` is not hexadecimal"), start)
400                            })?;
401                            // A surrogate or out-of-range value is REJECTED, not
402                            // replaced with U+FFFD: silently substituting a
403                            // different character is how a codepoint typo
404                            // becomes a rendering mystery.
405                            let ch = char::from_u32(code).ok_or_else(|| {
406                                self.err(format!("`{hex}` is not a Unicode scalar value"), start)
407                            })?;
408                            buf.push(ch);
409                            self.pos += 1; // past '}'
410                            continue;
411                        }
412                        other => {
413                            return Err(
414                                self.err(format!("unknown escape \\{}", other as char), start)
415                            )
416                        }
417                    };
418                    buf.push(ch);
419                    self.pos += 1;
420                }
421                Some(_) => {
422                    let ch = self.src[self.pos..]
423                        .chars()
424                        .next()
425                        .expect("peek said there is a byte");
426                    buf.push(ch);
427                    self.pos += ch.len_utf8();
428                }
429            }
430        }
431        if exprs.is_empty() {
432            self.push(TokenKind::Str(buf), start);
433        } else {
434            parts.push(buf);
435            self.push(TokenKind::InterpolatedStr { parts, exprs }, start);
436        }
437        Ok(())
438    }
439
440    fn lex_number(&mut self, start: usize) -> Result<(), LexError> {
441        while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
442            self.pos += 1;
443        }
444        // A `.` is a decimal point only when a digit follows; otherwise it
445        // is the method-call dot and belongs to the next token. This is why
446        // `1.foo` sends `foo` to `1` rather than failing to lex.
447        let mut is_float =
448            self.peek() == Some(b'.') && matches!(self.peek_at(1), Some(b'0'..=b'9'));
449        if is_float {
450            self.pos += 1;
451            while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
452                self.pos += 1;
453            }
454        }
455        // An exponent — `1e300`, `2.5E-3` — makes the literal a float. Only
456        // when digits follow (after an optional sign), so `e` is never taken
457        // from a following name.
458        let exponent_digits_at = match (self.peek(), self.peek_at(1)) {
459            (Some(b'e' | b'E'), Some(b'0'..=b'9')) => Some(1),
460            (Some(b'e' | b'E'), Some(b'+' | b'-'))
461                if matches!(self.peek_at(2), Some(b'0'..=b'9')) =>
462            {
463                Some(2)
464            }
465            _ => None,
466        };
467        if let Some(skip) = exponent_digits_at {
468            is_float = true;
469            self.pos += skip;
470            while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
471                self.pos += 1;
472            }
473        }
474        let text: String = self.src[start..self.pos]
475            .chars()
476            .filter(|c| *c != '_')
477            .collect();
478        if is_float {
479            let v: f64 = text
480                .parse()
481                .map_err(|_| self.err(format!("invalid float literal {text:?}"), start))?;
482            self.push(TokenKind::Float(v), start);
483        } else {
484            let v: i64 = text
485                .parse()
486                .map_err(|_| self.err(format!("integer literal out of range: {text:?}"), start))?;
487            self.push(TokenKind::Int(v), start);
488        }
489        Ok(())
490    }
491
492    fn lex_colon(&mut self, start: usize) {
493        // `::` qualifies a name (`retsu::first`); `:name` is a symbol; a bare
494        // `:` is punctuation. `::` is checked first, so `::first` is never a
495        // colon followed by the symbol `:first`.
496        if self.peek_at(1) == Some(b':') {
497            self.pos += 2;
498            self.push(TokenKind::PathSep, start);
499        } else if matches!(self.peek_at(1), Some(c) if is_ident_start(c)) {
500            self.pos += 1;
501            let s = self.pos;
502            while matches!(self.peek(), Some(c) if is_ident_continue(c)) {
503                self.pos += 1;
504            }
505            // A trailing `?` or `!` is part of the name, as in an identifier,
506            // so `:pair?` names `pair?` — the one way an import list
507            // (`use("nisshi", [:pair?])`) can list such a definition. `!=`
508            // keeps its `!`, as `lex_ident` does.
509            if matches!(self.peek(), Some(b'?'))
510                || (matches!(self.peek(), Some(b'!')) && !matches!(self.peek_at(1), Some(b'=')))
511            {
512                self.pos += 1;
513            }
514            let name = self.src[s..self.pos].to_string();
515            self.push(TokenKind::Sym(name), start);
516        } else {
517            self.one(TokenKind::Colon, start);
518        }
519    }
520
521    fn lex_ident(&mut self, start: usize) {
522        while matches!(self.peek(), Some(c) if is_ident_continue(c)) {
523            self.pos += 1;
524        }
525
526        // A TRAILING `?` or `!` is part of the name, as in Ruby.
527        //
528        // Without this, four builtins the runtime registers — `contains?`,
529        // `starts_with?`, `ends_with?` and `to_int!` — were unreachable from
530        // every blue program ever written: dead code in the runtime and a
531        // silent capability gap that made `moji` reimplement three of them by
532        // hand.
533        //
534        // The `=` guard is what keeps `!=` working. `a != b` is safe either way
535        // (the `!` follows a space), but `a!=b` is not: without the lookahead
536        // the `!` would be eaten into the identifier and the comparison would
537        // vanish. So a trailing `!` joins the name only when what follows is
538        // NOT `=`.
539        if matches!(self.peek(), Some(b'?'))
540            || (matches!(self.peek(), Some(b'!')) && !matches!(self.peek_at(1), Some(b'=')))
541        {
542            self.pos += 1;
543        }
544        let name = self.src[start..self.pos].to_string();
545
546        // `foo:` is a hash label — one token, so `{foo: 1}` needs no
547        // lookahead in the parser. Not folded when followed by `:`, which
548        // would be `foo::bar`.
549        if self.peek() == Some(b':') && self.peek_at(1) != Some(b':') {
550            self.pos += 1;
551            self.push(TokenKind::Label(name), start);
552            return;
553        }
554
555        let kind = match name.as_str() {
556            "true" => TokenKind::True,
557            "false" => TokenKind::False,
558            "nil" => TokenKind::Nil,
559            _ => TokenKind::Ident(name),
560        };
561        self.push(kind, start);
562    }
563
564    fn lex_op(&mut self, start: usize) {
565        while matches!(self.peek(), Some(c) if OP_CHARS.as_bytes().contains(&c)) {
566            self.pos += 1;
567        }
568        let text = self.src[start..self.pos].to_string();
569        let kind = match text.as_str() {
570            "=>" => TokenKind::Rocket,
571            "|>" => TokenKind::Pipe,
572            _ => TokenKind::Op(text),
573        };
574        self.push(kind, start);
575    }
576}
577
578// An identifier may be written in ANY script, not only Latin.
579//
580// The lexer works on bytes, and the rule that makes that safe is a property of
581// UTF-8 rather than a trick: every byte of a multi-byte character is >= 0x80,
582// and every character blue gives structural meaning — operators, delimiters,
583// quotes, `#` — is ASCII, hence < 0x80. So "byte >= 0x80 is part of an
584// identifier" consumes each multi-byte character whole, leaves every ASCII
585// decision untouched, and keeps `src[start..pos]` on a char boundary.
586//
587// Without this the `yakugo` language packs cannot exist at all: `définir` died
588// on 'Ã' and `定義` on 'å' — a UTF-8 continuation byte reported as a character,
589// which is the diagnostic a byte-oriented lexer gives for text it was never
590// built to read.
591//
592// PERMISSIVE, deliberately and worth stating: this admits any non-ASCII byte,
593// so an emoji or a lone combining mark is a legal identifier. Enforcing
594// XID_Start/XID_Continue would need real char decoding through the whole
595// lexer. Accepting too much is a surface question; rejecting every non-Latin
596// script was a correctness one.
597fn skip_interpolation(bytes: &[u8], mut i: usize) -> Option<usize> {
598    let mut depth = 1usize;
599    loop {
600        match *bytes.get(i)? {
601            b'{' => depth += 1,
602            b'}' => {
603                depth -= 1;
604                if depth == 0 {
605                    return Some(i + 1);
606                }
607            }
608            b'"' => {
609                i = skip_code_string(bytes, i)?;
610                continue;
611            }
612            _ => {}
613        }
614        i += 1;
615    }
616}
617
618fn skip_code_string(bytes: &[u8], mut i: usize) -> Option<usize> {
619    i += 1;
620    loop {
621        match *bytes.get(i)? {
622            b'"' => return Some(i + 1),
623            b'\\' => i += 2,
624            b'#' if bytes.get(i + 1) == Some(&b'{') => i = skip_interpolation(bytes, i + 2)?,
625            _ => i += 1,
626        }
627    }
628}
629
630fn is_ident_start(c: u8) -> bool {
631    c.is_ascii_alphabetic() || c == b'_'
632}
633
634fn is_ident_continue(c: u8) -> bool {
635    c.is_ascii_alphanumeric() || c == b'_' || c >= 0x80
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    fn kinds(src: &str) -> Vec<TokenKind> {
643        lex(src)
644            .expect("lex")
645            .into_iter()
646            .filter(|t| !t.is_trivia() && t.kind != TokenKind::Eof)
647            .map(|t| t.kind)
648            .collect()
649    }
650
651    #[test]
652    fn a_brace_inside_a_string_inside_interpolated_code_is_text() {
653        for (src, code) in [
654            ("\"a#{f(\"}\")}b\"", "f(\"}\")"),
655            ("\"a#{\"{\" |> \"}\"}b\"", "\"{\" |> \"}\""),
656            ("\"#{ {a: \"}\"} }\"", " {a: \"}\"} "),
657            ("\"#{f(\"x\\\"}\")}\"", "f(\"x\\\"}\")"),
658            ("\"#{\"in#{\"}\"}ner\"}\"", "\"in#{\"}\"}ner\""),
659        ] {
660            match kinds(src).as_slice() {
661                [TokenKind::InterpolatedStr { exprs, .. }] => assert_eq!(exprs, &[code], "{src}"),
662                other => panic!("{src}: {other:?}"),
663            }
664        }
665        assert!(lex("\"#{\"}\"").is_err());
666    }
667
668    #[test]
669    fn a_leading_byte_order_mark_is_stepped_over_and_one_inside_the_text_is_refused() {
670        assert_eq!(kinds("\u{feff}x"), kinds("x"));
671        let at = lex("\u{feff}x").expect("lex")[0].span;
672        assert_eq!(at.start, 3, "spans still index the source bytes");
673        assert!(lex("x\n\u{feff}y").is_err());
674        assert!(lex("\"a\u{feff}b\"").is_ok());
675    }
676
677    #[test]
678    fn lexes_integers_and_floats() {
679        assert_eq!(
680            kinds("1 2.5 1_000"),
681            vec![
682                TokenKind::Int(1),
683                TokenKind::Float(2.5),
684                TokenKind::Int(1000),
685            ]
686        );
687    }
688
689    /// An exponent makes a float; `e` not followed by digits is not one.
690    /// Red run: without the exponent arm `1e300` lexed as `Int(1)` then
691    /// `Ident("e300")`, which is how the G5 probe failed to parse.
692    #[test]
693    fn lexes_float_exponents() {
694        assert_eq!(
695            kinds("1e300 2.5E-3 7e+2"),
696            vec![
697                TokenKind::Float(1e300),
698                TokenKind::Float(2.5e-3),
699                TokenKind::Float(700.0),
700            ]
701        );
702        assert_eq!(
703            kinds("1.e"),
704            vec![
705                TokenKind::Int(1),
706                TokenKind::Dot,
707                TokenKind::Ident("e".into())
708            ]
709        );
710    }
711
712    /// `1.foo` is a send, not a malformed float. The decimal point is a
713    /// decimal point only when a digit follows it.
714    #[test]
715    fn a_dot_after_a_digit_is_a_send_unless_a_digit_follows() {
716        assert_eq!(
717            kinds("1.foo"),
718            vec![
719                TokenKind::Int(1),
720                TokenKind::Dot,
721                TokenKind::Ident("foo".into()),
722            ]
723        );
724    }
725
726    #[test]
727    fn lexes_symbols_and_labels_distinctly() {
728        assert_eq!(kinds(":foo"), vec![TokenKind::Sym("foo".into())]);
729        assert_eq!(kinds("foo:"), vec![TokenKind::Label("foo".into())]);
730    }
731
732    #[test]
733    fn ruby_predicate_and_bang_suffixes_are_part_of_the_name() {
734        assert_eq!(
735            kinds("empty? save!"),
736            vec![
737                TokenKind::Ident("empty?".into()),
738                TokenKind::Ident("save!".into()),
739            ]
740        );
741    }
742
743    /// A name takes ONE trailing `?` or `!`, and never the `!` of `!=`.
744    ///
745    /// A second, unconditional suffix step used to follow the guarded one, so
746    /// `x!=3` lexed as `x!` then `=`: a statement became the binding
747    /// `(define x! 3)` and the comparison vanished without an error. The
748    /// kiwari grammar spec, which states the identifier rule once, is what
749    /// put the two steps side by side.
750    #[test]
751    fn a_bang_before_equals_is_the_operator_not_the_name() {
752        assert_eq!(
753            kinds("x!=3"),
754            vec![
755                TokenKind::Ident("x".into()),
756                TokenKind::Op("!=".into()),
757                TokenKind::Int(3),
758            ]
759        );
760        assert_eq!(
761            kinds("ok?! x"),
762            vec![
763                TokenKind::Ident("ok?".into()),
764                TokenKind::Op("!".into()),
765                TokenKind::Ident("x".into()),
766            ]
767        );
768    }
769
770    #[test]
771    fn lexes_strings_with_escapes() {
772        assert_eq!(kinds(r#""a\nb""#), vec![TokenKind::Str("a\nb".into())]);
773    }
774
775    #[test]
776    fn unterminated_string_is_an_error_with_a_span() {
777        let e = lex("\"oops").expect_err("must fail");
778        assert!(e.message.contains("unterminated"), "{}", e.message);
779        assert_eq!(e.span.start, 0);
780    }
781
782    /// Trivia is EMITTED, not skipped. A formatter and an LSP both need a
783    /// lossless stream, and neither can recover what the lexer discarded.
784    #[test]
785    fn comments_and_newlines_are_emitted_as_trivia() {
786        let toks = lex("1 # hi\n2").expect("lex");
787        assert!(
788            toks.iter()
789                .any(|t| matches!(&t.kind, TokenKind::Comment(c) if c == "# hi")),
790            "comment was dropped: {toks:?}"
791        );
792        assert!(
793            toks.iter().any(|t| t.kind == TokenKind::Newline),
794            "newline was dropped"
795        );
796    }
797
798    /// Anti-vacuity for the span claim: spans must be real byte offsets
799    /// into the source, not placeholders.
800    #[test]
801    fn spans_point_at_the_actual_bytes() {
802        let src = "foo + 1";
803        let toks = lex(src).expect("lex");
804        let first = &toks[0];
805        assert_eq!(&src[first.span.start..first.span.end], "foo");
806        let last_int = toks
807            .iter()
808            .find(|t| matches!(t.kind, TokenKind::Int(_)))
809            .expect("an int token");
810        assert_eq!(&src[last_int.span.start..last_int.span.end], "1");
811    }
812
813    #[test]
814    fn lexes_pipeline_and_rocket() {
815        assert_eq!(kinds("|> =>"), vec![TokenKind::Pipe, TokenKind::Rocket]);
816    }
817}