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