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    /// `@doc """ … """` or `@moduledoc """ … """`: documentation text, its
99    /// indentation and trailing whitespace stripped at lex time
100    /// ([`crate::doc`]). Not trivia: the parser puts it in the tree.
101    Doc {
102        module: bool,
103        text: String,
104    },
105
106    // trivia — emitted, never skipped
107    Comment(String),
108    Newline,
109
110    Eof,
111}
112
113/// A token as an error message names it: its blue spelling in backticks, or,
114/// for the tokens that have no spelling, what it is in words.
115///
116/// **This is what a parse error says it found.** Errors used to format the
117/// token with `{:?}`, so an author who wrote `[1, 2 y]` read
118/// `found Ident("y")` — the name of a Rust enum variant, in a language whose
119/// surface has no such thing. A message is read by the person who wrote the
120/// source, so it speaks the source's vocabulary.
121impl fmt::Display for TokenKind {
122    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
123        match self {
124            Self::Int(v) => write!(f, "the number `{v}`"),
125            Self::Float(v) => write!(f, "the number `{v}`"),
126            Self::Str(_) | Self::InterpolatedStr { .. } => f.write_str("a string"),
127            Self::Sym(s) => write!(f, "the symbol `:{s}`"),
128            Self::True => f.write_str("`true`"),
129            Self::False => f.write_str("`false`"),
130            Self::Nil => f.write_str("`nil`"),
131            Self::Ident(n) => write!(f, "`{n}`"),
132            Self::LParen => f.write_str("`(`"),
133            Self::RParen => f.write_str("`)`"),
134            Self::LBracket => f.write_str("`[`"),
135            Self::RBracket => f.write_str("`]`"),
136            Self::LBrace => f.write_str("`{`"),
137            Self::RBrace => f.write_str("`}`"),
138            Self::Comma => f.write_str("`,`"),
139            Self::Dot => f.write_str("`.`"),
140            Self::Colon => f.write_str("`:`"),
141            Self::PathSep => f.write_str("`::`"),
142            Self::Label(n) => write!(f, "the label `{n}:`"),
143            Self::Rocket => f.write_str("`=>`"),
144            Self::Pipe => f.write_str("`|>`"),
145            Self::Op(o) => write!(f, "`{o}`"),
146            Self::Doc { module: false, .. } => f.write_str("`@doc`"),
147            Self::Doc { module: true, .. } => f.write_str("`@moduledoc`"),
148            Self::Comment(_) => f.write_str("a comment"),
149            Self::Newline => f.write_str("the end of the line"),
150            Self::Eof => f.write_str("the end of the file"),
151        }
152    }
153}
154
155#[derive(Clone, Debug, PartialEq)]
156pub struct Token {
157    pub kind: TokenKind,
158    pub span: Span,
159}
160
161impl Token {
162    /// Is this token trivia (a comment or a newline)?
163    pub fn is_trivia(&self) -> bool {
164        matches!(self.kind, TokenKind::Comment(_) | TokenKind::Newline)
165    }
166}
167
168#[derive(Clone, Debug, PartialEq)]
169pub struct LexError {
170    pub message: String,
171    pub span: Span,
172}
173
174impl fmt::Display for LexError {
175    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
176        write!(
177            f,
178            "{} at {}..{}",
179            self.message, self.span.start, self.span.end
180        )
181    }
182}
183
184impl std::error::Error for LexError {}
185
186/// Characters that may begin or continue an operator run.
187const OP_CHARS: &str = "+-*/=<>!%&|";
188
189/// Tokenize `src`, including trivia.
190pub fn lex(src: &str) -> Result<Vec<Token>, LexError> {
191    Lexer::new(src).run()
192}
193
194struct Lexer<'a> {
195    src: &'a str,
196    bytes: &'a [u8],
197    pos: usize,
198    out: Vec<Token>,
199}
200
201impl<'a> Lexer<'a> {
202    fn new(src: &'a str) -> Self {
203        Self {
204            src,
205            bytes: src.as_bytes(),
206            pos: if src.starts_with('\u{feff}') {
207                '\u{feff}'.len_utf8()
208            } else {
209                0
210            },
211            out: Vec::new(),
212        }
213    }
214
215    /// Decode the whole UTF-8 character at the cursor, with its byte width.
216    ///
217    /// The lexer scans bytes because that is right for ASCII, which is all of
218    /// blue's structure. This is the seam where it stops being right: a
219    /// character outside ASCII has to be decoded before anything can classify
220    /// it, and the previous approach — treating every byte >= 0x80 as a letter
221    /// — could not tell `≠` from `文`, so an operator was unreachable and a
222    /// malformed sequence became a silent identifier.
223    fn decode_char(&self) -> Option<(char, usize)> {
224        let rest = self.src.get(self.pos..)?;
225        let ch = rest.chars().next()?;
226        Some((ch, ch.len_utf8()))
227    }
228
229    fn peek(&self) -> Option<u8> {
230        self.bytes.get(self.pos).copied()
231    }
232
233    fn peek_at(&self, n: usize) -> Option<u8> {
234        self.bytes.get(self.pos + n).copied()
235    }
236
237    fn push(&mut self, kind: TokenKind, start: usize) {
238        self.out.push(Token {
239            kind,
240            span: Span::new(start, self.pos),
241        });
242    }
243
244    fn err(&self, message: impl Into<String>, start: usize) -> LexError {
245        LexError {
246            message: message.into(),
247            span: Span::new(start, self.pos.max(start + 1)),
248        }
249    }
250
251    fn run(mut self) -> Result<Vec<Token>, LexError> {
252        while let Some(c) = self.peek() {
253            let start = self.pos;
254            match c {
255                b'\n' => {
256                    self.pos += 1;
257                    self.push(TokenKind::Newline, start);
258                }
259                // Horizontal whitespace carries no meaning in blue and is
260                // reconstructed by the formatter, so it is the one thing
261                // dropped. Newlines are kept: they are statement separators.
262                b' ' | b'\t' | b'\r' => {
263                    self.pos += 1;
264                }
265                b'#' => {
266                    while let Some(c) = self.peek() {
267                        if c == b'\n' {
268                            break;
269                        }
270                        self.pos += 1;
271                    }
272                    let text = self.src[start..self.pos].to_string();
273                    self.push(TokenKind::Comment(text), start);
274                }
275                b'"' => self.lex_string(start)?,
276                b'@' => self.lex_doc(start)?,
277                b'0'..=b'9' => self.lex_number(start)?,
278                b':' => self.lex_colon(start),
279                b'(' => self.one(TokenKind::LParen, start),
280                b')' => self.one(TokenKind::RParen, start),
281                b'[' => self.one(TokenKind::LBracket, start),
282                b']' => self.one(TokenKind::RBracket, start),
283                b'{' => self.one(TokenKind::LBrace, start),
284                b'}' => self.one(TokenKind::RBrace, start),
285                b',' => self.one(TokenKind::Comma, start),
286                b'.' => self.one(TokenKind::Dot, start),
287                // Non-ASCII goes through the UTF-8 layer, which decodes a
288                // whole CHARACTER and asks `kigou` what it means. This must
289                // precede the ident branch: a symbol like `≠` is an operator,
290                // not the first letter of a name.
291                c if c >= 0x80 => {
292                    let Some((ch, width)) = self.decode_char() else {
293                        self.pos += 1;
294                        return Err(self.err("invalid UTF-8 in source", start));
295                    };
296                    match crate::kigou::classify(ch) {
297                        crate::kigou::Class::Operator(op) => {
298                            self.pos += width;
299                            self.push(TokenKind::Op(op.to_owned()), start);
300                        }
301                        crate::kigou::Class::Word => self.lex_ident(start),
302                        crate::kigou::Class::Reject => {
303                            self.pos += width;
304                            return Err(self.err(
305                                format!(
306                                    "the character {ch:?} (U+{:04X}) is not legal in blue source \
307                                     outside a string — invisible and control characters are \
308                                     rejected because source that looks correct and is not costs \
309                                     far more than a clear error here",
310                                    ch as u32
311                                ),
312                                start,
313                            ));
314                        }
315                    }
316                }
317                c if is_ident_start(c) => self.lex_ident(start),
318                c if OP_CHARS.as_bytes().contains(&c) => self.lex_op(start),
319                // `;` is refused by name: blue has exactly one statement
320                // separator, the new line, so a `;` is a habit from another
321                // language rather than a character blue could mean.
322                b';' => {
323                    self.pos += 1;
324                    return Err(self.err(
325                        "unexpected `;`: blue ends a statement at the end of the line, \
326                         so put the next statement on a new line",
327                        start,
328                    ));
329                }
330                _ => {
331                    self.pos += 1;
332                    return Err(self.err(format!("unexpected character `{}`", c as char), start));
333                }
334            }
335        }
336        let end = self.pos;
337        self.out.push(Token {
338            kind: TokenKind::Eof,
339            span: Span::new(end, end),
340        });
341        Ok(self.out)
342    }
343
344    fn one(&mut self, kind: TokenKind, start: usize) {
345        self.pos += 1;
346        self.push(kind, start);
347    }
348
349    fn lex_string(&mut self, start: usize) -> Result<(), LexError> {
350        self.pos += 1; // opening quote
351        let mut buf = String::new();
352        // Interpolation state. `parts` collects the literal run before each
353        // `#{…}`; `exprs` collects the raw source between the braces.
354        let mut parts: Vec<String> = Vec::new();
355        let mut exprs: Vec<String> = Vec::new();
356        loop {
357            // `#{` opens an interpolation. A bare `#` is just a character — a
358            // string full of `#` comments would otherwise be unwritable.
359            if self.peek() == Some(b'#') && self.src.as_bytes().get(self.pos + 1) == Some(&b'{') {
360                self.pos += 2;
361                let expr_start = self.pos;
362                let Some(after) = skip_interpolation(self.bytes, self.pos) else {
363                    return Err(self.err("unterminated `#{` interpolation", start));
364                };
365                self.pos = after - 1;
366                exprs.push(self.src[expr_start..self.pos].to_string());
367                self.pos += 1; // past '}'
368                parts.push(std::mem::take(&mut buf));
369                continue;
370            }
371            match self.peek() {
372                None => return Err(self.err("unterminated string literal", start)),
373                Some(b'"') => {
374                    self.pos += 1;
375                    break;
376                }
377                Some(b'\\') => {
378                    self.pos += 1;
379                    let esc = self
380                        .peek()
381                        .ok_or_else(|| self.err("unterminated escape", start))?;
382                    let ch = match esc {
383                        b'n' => '\n',
384                        b't' => '\t',
385                        b'r' => '\r',
386                        b'\\' => '\\',
387                        b'"' => '"',
388                        b'0' => '\0',
389                        // `\u{...}` — a Unicode scalar by codepoint. Ruby and
390                        // Elixir both have it, and without it a blue source
391                        // file can only carry a non-ASCII character literally,
392                        // which is exactly the case where an explicit escape
393                        // matters most (combining marks, zero-width joiners,
394                        // anything invisible in an editor).
395                        b'u' => {
396                            self.pos += 1; // past 'u'
397                            if self.peek() != Some(b'{') {
398                                return Err(self.err("expected `{` after \\u", start));
399                            }
400                            self.pos += 1; // past '{'
401                            let hex_start = self.pos;
402                            while self.peek().is_some_and(|c| c != b'}') {
403                                self.pos += 1;
404                            }
405                            if self.peek() != Some(b'}') {
406                                return Err(self.err("unterminated \\u{...} escape", start));
407                            }
408                            let hex = &self.src[hex_start..self.pos];
409                            let code = u32::from_str_radix(hex, 16).map_err(|_| {
410                                self.err(format!("`{hex}` is not hexadecimal"), start)
411                            })?;
412                            // A surrogate or out-of-range value is REJECTED, not
413                            // replaced with U+FFFD: silently substituting a
414                            // different character is how a codepoint typo
415                            // becomes a rendering mystery.
416                            let ch = char::from_u32(code).ok_or_else(|| {
417                                self.err(format!("`{hex}` is not a Unicode scalar value"), start)
418                            })?;
419                            buf.push(ch);
420                            self.pos += 1; // past '}'
421                            continue;
422                        }
423                        other => {
424                            return Err(
425                                self.err(format!("unknown escape \\{}", other as char), start)
426                            )
427                        }
428                    };
429                    buf.push(ch);
430                    self.pos += 1;
431                }
432                Some(_) => {
433                    let ch = self.src[self.pos..]
434                        .chars()
435                        .next()
436                        .expect("peek said there is a byte");
437                    buf.push(ch);
438                    self.pos += ch.len_utf8();
439                }
440            }
441        }
442        if exprs.is_empty() {
443            self.push(TokenKind::Str(buf), start);
444        } else {
445            parts.push(buf);
446            self.push(TokenKind::InterpolatedStr { parts, exprs }, start);
447        }
448        Ok(())
449    }
450
451    fn lex_doc(&mut self, start: usize) -> Result<(), LexError> {
452        self.pos += 1;
453        let word_start = self.pos;
454        while self.peek().is_some_and(is_ident_continue) {
455            self.pos += 1;
456        }
457        let module = match &self.src[word_start..self.pos] {
458            "doc" => false,
459            "moduledoc" => true,
460            other => {
461                return Err(self.err(
462                    format!("unexpected `@{other}`: blue's attributes are `@doc` and `@moduledoc`"),
463                    start,
464                ))
465            }
466        };
467        let word = if module { "@moduledoc" } else { "@doc" };
468        while matches!(self.peek(), Some(b' ' | b'\t')) {
469            self.pos += 1;
470        }
471        let opens = self.src[self.pos..].starts_with("\"\"\"");
472        let rest_of_line = self.src[self.pos..]
473            .split('\n')
474            .next()
475            .unwrap_or("")
476            .get(3..)
477            .unwrap_or("x");
478        if !opens || !rest_of_line.trim().is_empty() {
479            return Err(self.err(
480                format!(
481                    "`{word}` takes a heredoc: `{word} \"\"\"` alone on its line, the text, \
482                     then `\"\"\"` alone on a line"
483                ),
484                start,
485            ));
486        }
487        self.pos += 3 + rest_of_line.len();
488        let body_start = (self.pos + 1).min(self.src.len());
489        let mut lines: Vec<&str> = Vec::new();
490        let mut at = body_start;
491        let closer = loop {
492            if at >= self.src.len() {
493                self.pos = self.src.len();
494                return Err(self.err(
495                    format!("unterminated `{word}`: no `\"\"\"` line closes it"),
496                    start,
497                ));
498            }
499            let end = self.src[at..].find('\n').map_or(self.src.len(), |i| at + i);
500            let line = &self.src[at..end];
501            if line.trim() == "\"\"\"" {
502                break (line, end);
503            }
504            lines.push(line);
505            at = (end + 1).min(self.src.len() + 1);
506        };
507        let indent = &closer.0[..closer.0.len() - closer.0.trim_start().len()];
508        let mut kept: Vec<&str> = Vec::with_capacity(lines.len());
509        for line in lines {
510            let line = line.trim_end();
511            if line.is_empty() {
512                kept.push("");
513                continue;
514            }
515            match line.strip_prefix(indent) {
516                Some(l) => kept.push(l),
517                None => {
518                    return Err(self.err(
519                        format!(
520                            "a line of this `{word}` is indented less than its closing `\"\"\"`"
521                        ),
522                        start,
523                    ))
524                }
525            }
526        }
527        while kept.first() == Some(&"") {
528            kept.remove(0);
529        }
530        while kept.last() == Some(&"") {
531            kept.pop();
532        }
533        let text = kept.join("\n");
534        if text.contains("\"\"\"") {
535            return Err(self.err(format!("a `{word}` text cannot hold `\"\"\"`"), start));
536        }
537        let close_at = closer.1 - closer.0.trim_start().len() + 3;
538        self.pos = close_at;
539        self.push(TokenKind::Doc { module, text }, start);
540        Ok(())
541    }
542
543    fn lex_number(&mut self, start: usize) -> Result<(), LexError> {
544        while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
545            self.pos += 1;
546        }
547        // A `.` is a decimal point only when a digit follows; otherwise it
548        // is the method-call dot and belongs to the next token. This is why
549        // `1.foo` sends `foo` to `1` rather than failing to lex.
550        let mut is_float =
551            self.peek() == Some(b'.') && matches!(self.peek_at(1), Some(b'0'..=b'9'));
552        if is_float {
553            self.pos += 1;
554            while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
555                self.pos += 1;
556            }
557        }
558        // An exponent — `1e300`, `2.5E-3` — makes the literal a float. Only
559        // when digits follow (after an optional sign), so `e` is never taken
560        // from a following name.
561        let exponent_digits_at = match (self.peek(), self.peek_at(1)) {
562            (Some(b'e' | b'E'), Some(b'0'..=b'9')) => Some(1),
563            (Some(b'e' | b'E'), Some(b'+' | b'-'))
564                if matches!(self.peek_at(2), Some(b'0'..=b'9')) =>
565            {
566                Some(2)
567            }
568            _ => None,
569        };
570        if let Some(skip) = exponent_digits_at {
571            is_float = true;
572            self.pos += skip;
573            while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
574                self.pos += 1;
575            }
576        }
577        let text: String = self.src[start..self.pos]
578            .chars()
579            .filter(|c| *c != '_')
580            .collect();
581        if is_float {
582            let v: f64 = text
583                .parse()
584                .map_err(|_| self.err(format!("invalid float literal {text:?}"), start))?;
585            self.push(TokenKind::Float(v), start);
586        } else {
587            let v: i64 = text
588                .parse()
589                .map_err(|_| self.err(format!("integer literal out of range: {text:?}"), start))?;
590            self.push(TokenKind::Int(v), start);
591        }
592        Ok(())
593    }
594
595    fn lex_colon(&mut self, start: usize) {
596        // `::` qualifies a name (`retsu::first`); `:name` is a symbol; a bare
597        // `:` is punctuation. `::` is checked first, so `::first` is never a
598        // colon followed by the symbol `:first`.
599        if self.peek_at(1) == Some(b':') {
600            self.pos += 2;
601            self.push(TokenKind::PathSep, start);
602        } else if matches!(self.peek_at(1), Some(c) if is_ident_start(c)) {
603            self.pos += 1;
604            let s = self.pos;
605            while matches!(self.peek(), Some(c) if is_ident_continue(c)) {
606                self.pos += 1;
607            }
608            // A trailing `?` or `!` is part of the name, as in an identifier,
609            // so `:pair?` names `pair?` — the one way an import list
610            // (`use("nisshi", [:pair?])`) can list such a definition. `!=`
611            // keeps its `!`, as `lex_ident` does.
612            if matches!(self.peek(), Some(b'?'))
613                || (matches!(self.peek(), Some(b'!')) && !matches!(self.peek_at(1), Some(b'=')))
614            {
615                self.pos += 1;
616            }
617            let name = self.src[s..self.pos].to_string();
618            self.push(TokenKind::Sym(name), start);
619        } else {
620            self.one(TokenKind::Colon, start);
621        }
622    }
623
624    fn lex_ident(&mut self, start: usize) {
625        while matches!(self.peek(), Some(c) if is_ident_continue(c)) {
626            self.pos += 1;
627        }
628
629        // A TRAILING `?` or `!` is part of the name, as in Ruby.
630        //
631        // Without this, four builtins the runtime registers — `contains?`,
632        // `starts_with?`, `ends_with?` and `to_int!` — were unreachable from
633        // every blue program ever written: dead code in the runtime and a
634        // silent capability gap that made `moji` reimplement three of them by
635        // hand.
636        //
637        // The `=` guard is what keeps `!=` working. `a != b` is safe either way
638        // (the `!` follows a space), but `a!=b` is not: without the lookahead
639        // the `!` would be eaten into the identifier and the comparison would
640        // vanish. So a trailing `!` joins the name only when what follows is
641        // NOT `=`.
642        if matches!(self.peek(), Some(b'?'))
643            || (matches!(self.peek(), Some(b'!')) && !matches!(self.peek_at(1), Some(b'=')))
644        {
645            self.pos += 1;
646        }
647        let name = self.src[start..self.pos].to_string();
648
649        // `foo:` is a hash label — one token, so `{foo: 1}` needs no
650        // lookahead in the parser. Not folded when followed by `:`, which
651        // would be `foo::bar`.
652        if self.peek() == Some(b':') && self.peek_at(1) != Some(b':') {
653            self.pos += 1;
654            self.push(TokenKind::Label(name), start);
655            return;
656        }
657
658        let kind = match name.as_str() {
659            "true" => TokenKind::True,
660            "false" => TokenKind::False,
661            "nil" => TokenKind::Nil,
662            _ => TokenKind::Ident(name),
663        };
664        self.push(kind, start);
665    }
666
667    fn lex_op(&mut self, start: usize) {
668        while matches!(self.peek(), Some(c) if OP_CHARS.as_bytes().contains(&c)) {
669            self.pos += 1;
670        }
671        let text = self.src[start..self.pos].to_string();
672        let kind = match text.as_str() {
673            "=>" => TokenKind::Rocket,
674            "|>" => TokenKind::Pipe,
675            _ => TokenKind::Op(text),
676        };
677        self.push(kind, start);
678    }
679}
680
681// An identifier may be written in ANY script, not only Latin.
682//
683// The lexer works on bytes, and the rule that makes that safe is a property of
684// UTF-8 rather than a trick: every byte of a multi-byte character is >= 0x80,
685// and every character blue gives structural meaning — operators, delimiters,
686// quotes, `#` — is ASCII, hence < 0x80. So "byte >= 0x80 is part of an
687// identifier" consumes each multi-byte character whole, leaves every ASCII
688// decision untouched, and keeps `src[start..pos]` on a char boundary.
689//
690// Without this the `yakugo` language packs cannot exist at all: `définir` died
691// on 'Ã' and `定義` on 'å' — a UTF-8 continuation byte reported as a character,
692// which is the diagnostic a byte-oriented lexer gives for text it was never
693// built to read.
694//
695// PERMISSIVE, deliberately and worth stating: this admits any non-ASCII byte,
696// so an emoji or a lone combining mark is a legal identifier. Enforcing
697// XID_Start/XID_Continue would need real char decoding through the whole
698// lexer. Accepting too much is a surface question; rejecting every non-Latin
699// script was a correctness one.
700fn skip_interpolation(bytes: &[u8], mut i: usize) -> Option<usize> {
701    let mut depth = 1usize;
702    loop {
703        match *bytes.get(i)? {
704            b'{' => depth += 1,
705            b'}' => {
706                depth -= 1;
707                if depth == 0 {
708                    return Some(i + 1);
709                }
710            }
711            b'"' => {
712                i = skip_code_string(bytes, i)?;
713                continue;
714            }
715            _ => {}
716        }
717        i += 1;
718    }
719}
720
721fn skip_code_string(bytes: &[u8], mut i: usize) -> Option<usize> {
722    i += 1;
723    loop {
724        match *bytes.get(i)? {
725            b'"' => return Some(i + 1),
726            b'\\' => i += 2,
727            b'#' if bytes.get(i + 1) == Some(&b'{') => i = skip_interpolation(bytes, i + 2)?,
728            _ => i += 1,
729        }
730    }
731}
732
733fn is_ident_start(c: u8) -> bool {
734    c.is_ascii_alphabetic() || c == b'_'
735}
736
737fn is_ident_continue(c: u8) -> bool {
738    c.is_ascii_alphanumeric() || c == b'_' || c >= 0x80
739}
740
741#[cfg(test)]
742mod tests {
743    use super::*;
744
745    fn kinds(src: &str) -> Vec<TokenKind> {
746        lex(src)
747            .expect("lex")
748            .into_iter()
749            .filter(|t| !t.is_trivia() && t.kind != TokenKind::Eof)
750            .map(|t| t.kind)
751            .collect()
752    }
753
754    #[test]
755    fn a_brace_inside_a_string_inside_interpolated_code_is_text() {
756        for (src, code) in [
757            ("\"a#{f(\"}\")}b\"", "f(\"}\")"),
758            ("\"a#{\"{\" |> \"}\"}b\"", "\"{\" |> \"}\""),
759            ("\"#{ {a: \"}\"} }\"", " {a: \"}\"} "),
760            ("\"#{f(\"x\\\"}\")}\"", "f(\"x\\\"}\")"),
761            ("\"#{\"in#{\"}\"}ner\"}\"", "\"in#{\"}\"}ner\""),
762        ] {
763            match kinds(src).as_slice() {
764                [TokenKind::InterpolatedStr { exprs, .. }] => assert_eq!(exprs, &[code], "{src}"),
765                other => panic!("{src}: {other:?}"),
766            }
767        }
768        assert!(lex("\"#{\"}\"").is_err());
769    }
770
771    #[test]
772    fn a_leading_byte_order_mark_is_stepped_over_and_one_inside_the_text_is_refused() {
773        assert_eq!(kinds("\u{feff}x"), kinds("x"));
774        let at = lex("\u{feff}x").expect("lex")[0].span;
775        assert_eq!(at.start, 3, "spans still index the source bytes");
776        assert!(lex("x\n\u{feff}y").is_err());
777        assert!(lex("\"a\u{feff}b\"").is_ok());
778    }
779
780    #[test]
781    fn lexes_integers_and_floats() {
782        assert_eq!(
783            kinds("1 2.5 1_000"),
784            vec![
785                TokenKind::Int(1),
786                TokenKind::Float(2.5),
787                TokenKind::Int(1000),
788            ]
789        );
790    }
791
792    /// An exponent makes a float; `e` not followed by digits is not one.
793    /// Red run: without the exponent arm `1e300` lexed as `Int(1)` then
794    /// `Ident("e300")`, which is how the G5 probe failed to parse.
795    #[test]
796    fn lexes_float_exponents() {
797        assert_eq!(
798            kinds("1e300 2.5E-3 7e+2"),
799            vec![
800                TokenKind::Float(1e300),
801                TokenKind::Float(2.5e-3),
802                TokenKind::Float(700.0),
803            ]
804        );
805        assert_eq!(
806            kinds("1.e"),
807            vec![
808                TokenKind::Int(1),
809                TokenKind::Dot,
810                TokenKind::Ident("e".into())
811            ]
812        );
813    }
814
815    /// `1.foo` is a send, not a malformed float. The decimal point is a
816    /// decimal point only when a digit follows it.
817    #[test]
818    fn a_dot_after_a_digit_is_a_send_unless_a_digit_follows() {
819        assert_eq!(
820            kinds("1.foo"),
821            vec![
822                TokenKind::Int(1),
823                TokenKind::Dot,
824                TokenKind::Ident("foo".into()),
825            ]
826        );
827    }
828
829    #[test]
830    fn lexes_symbols_and_labels_distinctly() {
831        assert_eq!(kinds(":foo"), vec![TokenKind::Sym("foo".into())]);
832        assert_eq!(kinds("foo:"), vec![TokenKind::Label("foo".into())]);
833    }
834
835    #[test]
836    fn ruby_predicate_and_bang_suffixes_are_part_of_the_name() {
837        assert_eq!(
838            kinds("empty? save!"),
839            vec![
840                TokenKind::Ident("empty?".into()),
841                TokenKind::Ident("save!".into()),
842            ]
843        );
844    }
845
846    /// A name takes ONE trailing `?` or `!`, and never the `!` of `!=`.
847    ///
848    /// A second, unconditional suffix step used to follow the guarded one, so
849    /// `x!=3` lexed as `x!` then `=`: a statement became the binding
850    /// `(define x! 3)` and the comparison vanished without an error. The
851    /// kiwari grammar spec, which states the identifier rule once, is what
852    /// put the two steps side by side.
853    #[test]
854    fn a_bang_before_equals_is_the_operator_not_the_name() {
855        assert_eq!(
856            kinds("x!=3"),
857            vec![
858                TokenKind::Ident("x".into()),
859                TokenKind::Op("!=".into()),
860                TokenKind::Int(3),
861            ]
862        );
863        assert_eq!(
864            kinds("ok?! x"),
865            vec![
866                TokenKind::Ident("ok?".into()),
867                TokenKind::Op("!".into()),
868                TokenKind::Ident("x".into()),
869            ]
870        );
871    }
872
873    #[test]
874    fn lexes_strings_with_escapes() {
875        assert_eq!(kinds(r#""a\nb""#), vec![TokenKind::Str("a\nb".into())]);
876    }
877
878    #[test]
879    fn unterminated_string_is_an_error_with_a_span() {
880        let e = lex("\"oops").expect_err("must fail");
881        assert!(e.message.contains("unterminated"), "{}", e.message);
882        assert_eq!(e.span.start, 0);
883    }
884
885    /// Trivia is EMITTED, not skipped. A formatter and an LSP both need a
886    /// lossless stream, and neither can recover what the lexer discarded.
887    #[test]
888    fn comments_and_newlines_are_emitted_as_trivia() {
889        let toks = lex("1 # hi\n2").expect("lex");
890        assert!(
891            toks.iter()
892                .any(|t| matches!(&t.kind, TokenKind::Comment(c) if c == "# hi")),
893            "comment was dropped: {toks:?}"
894        );
895        assert!(
896            toks.iter().any(|t| t.kind == TokenKind::Newline),
897            "newline was dropped"
898        );
899    }
900
901    /// Anti-vacuity for the span claim: spans must be real byte offsets
902    /// into the source, not placeholders.
903    #[test]
904    fn spans_point_at_the_actual_bytes() {
905        let src = "foo + 1";
906        let toks = lex(src).expect("lex");
907        let first = &toks[0];
908        assert_eq!(&src[first.span.start..first.span.end], "foo");
909        let last_int = toks
910            .iter()
911            .find(|t| matches!(t.kind, TokenKind::Int(_)))
912            .expect("an int token");
913        assert_eq!(&src[last_int.span.start..last_int.span.end], "1");
914    }
915
916    #[test]
917    fn lexes_pipeline_and_rocket() {
918        assert_eq!(kinds("|> =>"), vec![TokenKind::Pipe, TokenKind::Rocket]);
919    }
920}