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 matches!(self.peek_at(1), Some(b'w' | b'i'))
324                    && self.peek_at(2) == Some(b'[') =>
325                {
326                    self.lex_words(start)?;
327                }
328                c if OP_CHARS.as_bytes().contains(&c) => self.lex_op(start),
329                // `;` is refused by name: blue has exactly one statement
330                // separator, the new line, so a `;` is a habit from another
331                // language rather than a character blue could mean.
332                b';' => {
333                    self.pos += 1;
334                    return Err(self.err(
335                        "unexpected `;`: blue ends a statement at the end of the line, \
336                         so put the next statement on a new line",
337                        start,
338                    ));
339                }
340                _ => {
341                    self.pos += 1;
342                    return Err(self.err(format!("unexpected character `{}`", c as char), start));
343                }
344            }
345        }
346        let end = self.pos;
347        self.out.push(Token {
348            kind: TokenKind::Eof,
349            span: Span::new(end, end),
350        });
351        Ok(self.out)
352    }
353
354    fn one(&mut self, kind: TokenKind, start: usize) {
355        self.pos += 1;
356        self.push(kind, start);
357    }
358
359    fn lex_string(&mut self, start: usize) -> Result<(), LexError> {
360        self.pos += 1; // opening quote
361        let mut buf = String::new();
362        // Interpolation state. `parts` collects the literal run before each
363        // `#{…}`; `exprs` collects the raw source between the braces.
364        let mut parts: Vec<String> = Vec::new();
365        let mut exprs: Vec<String> = Vec::new();
366        loop {
367            // `#{` opens an interpolation. A bare `#` is just a character — a
368            // string full of `#` comments would otherwise be unwritable.
369            if self.peek() == Some(b'#') && self.src.as_bytes().get(self.pos + 1) == Some(&b'{') {
370                self.pos += 2;
371                let expr_start = self.pos;
372                let Some(after) = skip_interpolation(self.bytes, self.pos) else {
373                    return Err(self.err("unterminated `#{` interpolation", start));
374                };
375                self.pos = after - 1;
376                exprs.push(self.src[expr_start..self.pos].to_string());
377                self.pos += 1; // past '}'
378                parts.push(std::mem::take(&mut buf));
379                continue;
380            }
381            match self.peek() {
382                None => return Err(self.err("unterminated string literal", start)),
383                Some(b'"') => {
384                    self.pos += 1;
385                    break;
386                }
387                Some(b'\\') => {
388                    self.pos += 1;
389                    let esc = self
390                        .peek()
391                        .ok_or_else(|| self.err("unterminated escape", start))?;
392                    let ch = match esc {
393                        b'n' => '\n',
394                        b't' => '\t',
395                        b'r' => '\r',
396                        b'\\' => '\\',
397                        b'"' => '"',
398                        b'0' => '\0',
399                        // `\u{...}` — a Unicode scalar by codepoint. Ruby and
400                        // Elixir both have it, and without it a blue source
401                        // file can only carry a non-ASCII character literally,
402                        // which is exactly the case where an explicit escape
403                        // matters most (combining marks, zero-width joiners,
404                        // anything invisible in an editor).
405                        b'u' => {
406                            self.pos += 1; // past 'u'
407                            if self.peek() != Some(b'{') {
408                                return Err(self.err("expected `{` after \\u", start));
409                            }
410                            self.pos += 1; // past '{'
411                            let hex_start = self.pos;
412                            while self.peek().is_some_and(|c| c != b'}') {
413                                self.pos += 1;
414                            }
415                            if self.peek() != Some(b'}') {
416                                return Err(self.err("unterminated \\u{...} escape", start));
417                            }
418                            let hex = &self.src[hex_start..self.pos];
419                            let code = u32::from_str_radix(hex, 16).map_err(|_| {
420                                self.err(format!("`{hex}` is not hexadecimal"), start)
421                            })?;
422                            // A surrogate or out-of-range value is REJECTED, not
423                            // replaced with U+FFFD: silently substituting a
424                            // different character is how a codepoint typo
425                            // becomes a rendering mystery.
426                            let ch = char::from_u32(code).ok_or_else(|| {
427                                self.err(format!("`{hex}` is not a Unicode scalar value"), start)
428                            })?;
429                            buf.push(ch);
430                            self.pos += 1; // past '}'
431                            continue;
432                        }
433                        other => {
434                            return Err(
435                                self.err(format!("unknown escape \\{}", other as char), start)
436                            )
437                        }
438                    };
439                    buf.push(ch);
440                    self.pos += 1;
441                }
442                Some(_) => {
443                    let ch = self.src[self.pos..]
444                        .chars()
445                        .next()
446                        .expect("peek said there is a byte");
447                    buf.push(ch);
448                    self.pos += ch.len_utf8();
449                }
450            }
451        }
452        if exprs.is_empty() {
453            self.push(TokenKind::Str(buf), start);
454        } else {
455            parts.push(buf);
456            self.push(TokenKind::InterpolatedStr { parts, exprs }, start);
457        }
458        Ok(())
459    }
460
461    fn lex_doc(&mut self, start: usize) -> Result<(), LexError> {
462        self.pos += 1;
463        let word_start = self.pos;
464        while self.peek().is_some_and(is_ident_continue) {
465            self.pos += 1;
466        }
467        let module = match &self.src[word_start..self.pos] {
468            "doc" => false,
469            "moduledoc" => true,
470            other => {
471                return Err(self.err(
472                    format!("unexpected `@{other}`: blue's attributes are `@doc` and `@moduledoc`"),
473                    start,
474                ))
475            }
476        };
477        let word = if module { "@moduledoc" } else { "@doc" };
478        while matches!(self.peek(), Some(b' ' | b'\t')) {
479            self.pos += 1;
480        }
481        let opens = self.src[self.pos..].starts_with("\"\"\"");
482        let rest_of_line = self.src[self.pos..]
483            .split('\n')
484            .next()
485            .unwrap_or("")
486            .get(3..)
487            .unwrap_or("x");
488        if !opens || !rest_of_line.trim().is_empty() {
489            return Err(self.err(
490                format!(
491                    "`{word}` takes a heredoc: `{word} \"\"\"` alone on its line, the text, \
492                     then `\"\"\"` alone on a line"
493                ),
494                start,
495            ));
496        }
497        self.pos += 3 + rest_of_line.len();
498        let body_start = (self.pos + 1).min(self.src.len());
499        let mut lines: Vec<&str> = Vec::new();
500        let mut at = body_start;
501        let closer = loop {
502            if at >= self.src.len() {
503                self.pos = self.src.len();
504                return Err(self.err(
505                    format!("unterminated `{word}`: no `\"\"\"` line closes it"),
506                    start,
507                ));
508            }
509            let end = self.src[at..].find('\n').map_or(self.src.len(), |i| at + i);
510            let line = &self.src[at..end];
511            if line.trim() == "\"\"\"" {
512                break (line, end);
513            }
514            lines.push(line);
515            at = (end + 1).min(self.src.len() + 1);
516        };
517        let indent = &closer.0[..closer.0.len() - closer.0.trim_start().len()];
518        let mut kept: Vec<&str> = Vec::with_capacity(lines.len());
519        for line in lines {
520            let line = line.trim_end();
521            if line.is_empty() {
522                kept.push("");
523                continue;
524            }
525            match line.strip_prefix(indent) {
526                Some(l) => kept.push(l),
527                None => {
528                    return Err(self.err(
529                        format!(
530                            "a line of this `{word}` is indented less than its closing `\"\"\"`"
531                        ),
532                        start,
533                    ))
534                }
535            }
536        }
537        while kept.first() == Some(&"") {
538            kept.remove(0);
539        }
540        while kept.last() == Some(&"") {
541            kept.pop();
542        }
543        let text = kept.join("\n");
544        if text.contains("\"\"\"") {
545            return Err(self.err(format!("a `{word}` text cannot hold `\"\"\"`"), start));
546        }
547        let close_at = closer.1 - closer.0.trim_start().len() + 3;
548        self.pos = close_at;
549        self.push(TokenKind::Doc { module, text }, start);
550        Ok(())
551    }
552
553    fn lex_words(&mut self, start: usize) -> Result<(), LexError> {
554        use crate::words::Flavor;
555        let flavor = if self.peek_at(1) == Some(b'w') {
556            Flavor::Strings
557        } else {
558            Flavor::Keywords
559        };
560        self.pos += 3;
561        let mut words = Vec::new();
562        loop {
563            while matches!(self.peek(), Some(b' ' | b'\t' | b'\r' | b'\n')) {
564                self.pos += 1;
565            }
566            match self.peek() {
567                None => {
568                    return Err(self.err(
569                        format!("unterminated `{}[` word list", flavor.sigil()),
570                        start,
571                    ))
572                }
573                Some(b']') => {
574                    self.pos += 1;
575                    break;
576                }
577                Some(_) => {}
578            }
579            let w = self.pos;
580            while let Some((ch, width)) = self.decode_char() {
581                if matches!(ch, ']' | ' ' | '\t' | '\r' | '\n') {
582                    break;
583                }
584                self.pos += width;
585            }
586            let word = &self.src[w..self.pos];
587            if let Some(why) = crate::words::refusal(flavor, word) {
588                return Err(LexError {
589                    message: why,
590                    span: Span::new(w, self.pos),
591                });
592            }
593            words.push((word.to_string(), Span::new(w, self.pos)));
594        }
595        self.push(TokenKind::Words { flavor, words }, start);
596        Ok(())
597    }
598
599    fn lex_number(&mut self, start: usize) -> Result<(), LexError> {
600        while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
601            self.pos += 1;
602        }
603        // A `.` is a decimal point only when a digit follows; otherwise it
604        // is the method-call dot and belongs to the next token. This is why
605        // `1.foo` sends `foo` to `1` rather than failing to lex.
606        let mut is_float =
607            self.peek() == Some(b'.') && matches!(self.peek_at(1), Some(b'0'..=b'9'));
608        if is_float {
609            self.pos += 1;
610            while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
611                self.pos += 1;
612            }
613        }
614        // An exponent — `1e300`, `2.5E-3` — makes the literal a float. Only
615        // when digits follow (after an optional sign), so `e` is never taken
616        // from a following name.
617        let exponent_digits_at = match (self.peek(), self.peek_at(1)) {
618            (Some(b'e' | b'E'), Some(b'0'..=b'9')) => Some(1),
619            (Some(b'e' | b'E'), Some(b'+' | b'-'))
620                if matches!(self.peek_at(2), Some(b'0'..=b'9')) =>
621            {
622                Some(2)
623            }
624            _ => None,
625        };
626        if let Some(skip) = exponent_digits_at {
627            is_float = true;
628            self.pos += skip;
629            while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
630                self.pos += 1;
631            }
632        }
633        let text: String = self.src[start..self.pos]
634            .chars()
635            .filter(|c| *c != '_')
636            .collect();
637        if is_float {
638            let v: f64 = text
639                .parse()
640                .map_err(|_| self.err(format!("invalid float literal {text:?}"), start))?;
641            self.push(TokenKind::Float(v), start);
642        } else {
643            let v: i64 = text
644                .parse()
645                .map_err(|_| self.err(format!("integer literal out of range: {text:?}"), start))?;
646            self.push(TokenKind::Int(v), start);
647        }
648        Ok(())
649    }
650
651    fn lex_colon(&mut self, start: usize) {
652        // `::` qualifies a name (`retsu::first`); `:name` is a symbol; a bare
653        // `:` is punctuation. `::` is checked first, so `::first` is never a
654        // colon followed by the symbol `:first`.
655        if self.peek_at(1) == Some(b':') {
656            self.pos += 2;
657            self.push(TokenKind::PathSep, start);
658        } else if matches!(self.peek_at(1), Some(c) if is_ident_start(c)) {
659            self.pos += 1;
660            let s = self.pos;
661            while matches!(self.peek(), Some(c) if is_ident_continue(c)) {
662                self.pos += 1;
663            }
664            // A trailing `?` or `!` is part of the name, as in an identifier,
665            // so `:pair?` names `pair?` — the one way an import list
666            // (`use("nisshi", [:pair?])`) can list such a definition. `!=`
667            // keeps its `!`, as `lex_ident` does.
668            if matches!(self.peek(), Some(b'?'))
669                || (matches!(self.peek(), Some(b'!')) && !matches!(self.peek_at(1), Some(b'=')))
670            {
671                self.pos += 1;
672            }
673            let name = self.src[s..self.pos].to_string();
674            self.push(TokenKind::Sym(name), start);
675        } else {
676            self.one(TokenKind::Colon, start);
677        }
678    }
679
680    fn lex_ident(&mut self, start: usize) {
681        while matches!(self.peek(), Some(c) if is_ident_continue(c)) {
682            self.pos += 1;
683        }
684
685        // A TRAILING `?` or `!` is part of the name, as in Ruby.
686        //
687        // Without this, four builtins the runtime registers — `contains?`,
688        // `starts_with?`, `ends_with?` and `to_int!` — were unreachable from
689        // every blue program ever written: dead code in the runtime and a
690        // silent capability gap that made `moji` reimplement three of them by
691        // hand.
692        //
693        // The `=` guard is what keeps `!=` working. `a != b` is safe either way
694        // (the `!` follows a space), but `a!=b` is not: without the lookahead
695        // the `!` would be eaten into the identifier and the comparison would
696        // vanish. So a trailing `!` joins the name only when what follows is
697        // NOT `=`.
698        if matches!(self.peek(), Some(b'?'))
699            || (matches!(self.peek(), Some(b'!')) && !matches!(self.peek_at(1), Some(b'=')))
700        {
701            self.pos += 1;
702        }
703        let name = self.src[start..self.pos].to_string();
704
705        // `foo:` is a hash label — one token, so `{foo: 1}` needs no
706        // lookahead in the parser. Not folded when followed by `:`, which
707        // would be `foo::bar`.
708        if self.peek() == Some(b':') && self.peek_at(1) != Some(b':') {
709            self.pos += 1;
710            self.push(TokenKind::Label(name), start);
711            return;
712        }
713
714        let kind = match name.as_str() {
715            "true" => TokenKind::True,
716            "false" => TokenKind::False,
717            "nil" => TokenKind::Nil,
718            _ => TokenKind::Ident(name),
719        };
720        self.push(kind, start);
721    }
722
723    fn lex_op(&mut self, start: usize) {
724        while matches!(self.peek(), Some(c) if OP_CHARS.as_bytes().contains(&c)) {
725            self.pos += 1;
726        }
727        let text = self.src[start..self.pos].to_string();
728        let kind = match text.as_str() {
729            "=>" => TokenKind::Rocket,
730            "|>" => TokenKind::Pipe,
731            _ => TokenKind::Op(text),
732        };
733        self.push(kind, start);
734    }
735}
736
737// An identifier may be written in ANY script, not only Latin.
738//
739// The lexer works on bytes, and the rule that makes that safe is a property of
740// UTF-8 rather than a trick: every byte of a multi-byte character is >= 0x80,
741// and every character blue gives structural meaning — operators, delimiters,
742// quotes, `#` — is ASCII, hence < 0x80. So "byte >= 0x80 is part of an
743// identifier" consumes each multi-byte character whole, leaves every ASCII
744// decision untouched, and keeps `src[start..pos]` on a char boundary.
745//
746// Without this the `yakugo` language packs cannot exist at all: `définir` died
747// on 'Ã' and `定義` on 'å' — a UTF-8 continuation byte reported as a character,
748// which is the diagnostic a byte-oriented lexer gives for text it was never
749// built to read.
750//
751// PERMISSIVE, deliberately and worth stating: this admits any non-ASCII byte,
752// so an emoji or a lone combining mark is a legal identifier. Enforcing
753// XID_Start/XID_Continue would need real char decoding through the whole
754// lexer. Accepting too much is a surface question; rejecting every non-Latin
755// script was a correctness one.
756fn skip_interpolation(bytes: &[u8], mut i: usize) -> Option<usize> {
757    let mut depth = 1usize;
758    loop {
759        match *bytes.get(i)? {
760            b'{' => depth += 1,
761            b'}' => {
762                depth -= 1;
763                if depth == 0 {
764                    return Some(i + 1);
765                }
766            }
767            b'"' => {
768                i = skip_code_string(bytes, i)?;
769                continue;
770            }
771            _ => {}
772        }
773        i += 1;
774    }
775}
776
777fn skip_code_string(bytes: &[u8], mut i: usize) -> Option<usize> {
778    i += 1;
779    loop {
780        match *bytes.get(i)? {
781            b'"' => return Some(i + 1),
782            b'\\' => i += 2,
783            b'#' if bytes.get(i + 1) == Some(&b'{') => i = skip_interpolation(bytes, i + 2)?,
784            _ => i += 1,
785        }
786    }
787}
788
789fn is_ident_start(c: u8) -> bool {
790    c.is_ascii_alphabetic() || c == b'_'
791}
792
793fn is_ident_continue(c: u8) -> bool {
794    c.is_ascii_alphanumeric() || c == b'_' || c >= 0x80
795}
796
797#[cfg(test)]
798mod tests {
799    use super::*;
800
801    fn kinds(src: &str) -> Vec<TokenKind> {
802        lex(src)
803            .expect("lex")
804            .into_iter()
805            .filter(|t| !t.is_trivia() && t.kind != TokenKind::Eof)
806            .map(|t| t.kind)
807            .collect()
808    }
809
810    #[test]
811    fn a_brace_inside_a_string_inside_interpolated_code_is_text() {
812        for (src, code) in [
813            ("\"a#{f(\"}\")}b\"", "f(\"}\")"),
814            ("\"a#{\"{\" |> \"}\"}b\"", "\"{\" |> \"}\""),
815            ("\"#{ {a: \"}\"} }\"", " {a: \"}\"} "),
816            ("\"#{f(\"x\\\"}\")}\"", "f(\"x\\\"}\")"),
817            ("\"#{\"in#{\"}\"}ner\"}\"", "\"in#{\"}\"}ner\""),
818        ] {
819            match kinds(src).as_slice() {
820                [TokenKind::InterpolatedStr { exprs, .. }] => assert_eq!(exprs, &[code], "{src}"),
821                other => panic!("{src}: {other:?}"),
822            }
823        }
824        assert!(lex("\"#{\"}\"").is_err());
825    }
826
827    #[test]
828    fn a_leading_byte_order_mark_is_stepped_over_and_one_inside_the_text_is_refused() {
829        assert_eq!(kinds("\u{feff}x"), kinds("x"));
830        let at = lex("\u{feff}x").expect("lex")[0].span;
831        assert_eq!(at.start, 3, "spans still index the source bytes");
832        assert!(lex("x\n\u{feff}y").is_err());
833        assert!(lex("\"a\u{feff}b\"").is_ok());
834    }
835
836    #[test]
837    fn lexes_integers_and_floats() {
838        assert_eq!(
839            kinds("1 2.5 1_000"),
840            vec![
841                TokenKind::Int(1),
842                TokenKind::Float(2.5),
843                TokenKind::Int(1000),
844            ]
845        );
846    }
847
848    /// An exponent makes a float; `e` not followed by digits is not one.
849    /// Red run: without the exponent arm `1e300` lexed as `Int(1)` then
850    /// `Ident("e300")`, which is how the G5 probe failed to parse.
851    #[test]
852    fn lexes_float_exponents() {
853        assert_eq!(
854            kinds("1e300 2.5E-3 7e+2"),
855            vec![
856                TokenKind::Float(1e300),
857                TokenKind::Float(2.5e-3),
858                TokenKind::Float(700.0),
859            ]
860        );
861        assert_eq!(
862            kinds("1.e"),
863            vec![
864                TokenKind::Int(1),
865                TokenKind::Dot,
866                TokenKind::Ident("e".into())
867            ]
868        );
869    }
870
871    /// `1.foo` is a send, not a malformed float. The decimal point is a
872    /// decimal point only when a digit follows it.
873    #[test]
874    fn a_dot_after_a_digit_is_a_send_unless_a_digit_follows() {
875        assert_eq!(
876            kinds("1.foo"),
877            vec![
878                TokenKind::Int(1),
879                TokenKind::Dot,
880                TokenKind::Ident("foo".into()),
881            ]
882        );
883    }
884
885    #[test]
886    fn lexes_symbols_and_labels_distinctly() {
887        assert_eq!(kinds(":foo"), vec![TokenKind::Sym("foo".into())]);
888        assert_eq!(kinds("foo:"), vec![TokenKind::Label("foo".into())]);
889    }
890
891    #[test]
892    fn ruby_predicate_and_bang_suffixes_are_part_of_the_name() {
893        assert_eq!(
894            kinds("empty? save!"),
895            vec![
896                TokenKind::Ident("empty?".into()),
897                TokenKind::Ident("save!".into()),
898            ]
899        );
900    }
901
902    /// A name takes ONE trailing `?` or `!`, and never the `!` of `!=`.
903    ///
904    /// A second, unconditional suffix step used to follow the guarded one, so
905    /// `x!=3` lexed as `x!` then `=`: a statement became the binding
906    /// `(define x! 3)` and the comparison vanished without an error. The
907    /// kiwari grammar spec, which states the identifier rule once, is what
908    /// put the two steps side by side.
909    #[test]
910    fn a_bang_before_equals_is_the_operator_not_the_name() {
911        assert_eq!(
912            kinds("x!=3"),
913            vec![
914                TokenKind::Ident("x".into()),
915                TokenKind::Op("!=".into()),
916                TokenKind::Int(3),
917            ]
918        );
919        assert_eq!(
920            kinds("ok?! x"),
921            vec![
922                TokenKind::Ident("ok?".into()),
923                TokenKind::Op("!".into()),
924                TokenKind::Ident("x".into()),
925            ]
926        );
927    }
928
929    #[test]
930    fn lexes_strings_with_escapes() {
931        assert_eq!(kinds(r#""a\nb""#), vec![TokenKind::Str("a\nb".into())]);
932    }
933
934    #[test]
935    fn unterminated_string_is_an_error_with_a_span() {
936        let e = lex("\"oops").expect_err("must fail");
937        assert!(e.message.contains("unterminated"), "{}", e.message);
938        assert_eq!(e.span.start, 0);
939    }
940
941    /// Trivia is EMITTED, not skipped. A formatter and an LSP both need a
942    /// lossless stream, and neither can recover what the lexer discarded.
943    #[test]
944    fn comments_and_newlines_are_emitted_as_trivia() {
945        let toks = lex("1 # hi\n2").expect("lex");
946        assert!(
947            toks.iter()
948                .any(|t| matches!(&t.kind, TokenKind::Comment(c) if c == "# hi")),
949            "comment was dropped: {toks:?}"
950        );
951        assert!(
952            toks.iter().any(|t| t.kind == TokenKind::Newline),
953            "newline was dropped"
954        );
955    }
956
957    /// Anti-vacuity for the span claim: spans must be real byte offsets
958    /// into the source, not placeholders.
959    #[test]
960    fn spans_point_at_the_actual_bytes() {
961        let src = "foo + 1";
962        let toks = lex(src).expect("lex");
963        let first = &toks[0];
964        assert_eq!(&src[first.span.start..first.span.end], "foo");
965        let last_int = toks
966            .iter()
967            .find(|t| matches!(t.kind, TokenKind::Int(_)))
968            .expect("an int token");
969        assert_eq!(&src[last_int.span.start..last_int.span.end], "1");
970    }
971
972    #[test]
973    fn lexes_word_lists() {
974        use crate::words::Flavor;
975        let src = "%w[a --force\n  x/y] %i[pair? b]";
976        let toks = lex(src).expect("lex");
977        let TokenKind::Words { flavor, words } = &toks[0].kind else {
978            panic!("{toks:?}");
979        };
980        assert_eq!(*flavor, Flavor::Strings);
981        let spelled: Vec<&str> = words.iter().map(|(_, at)| &src[at.start..at.end]).collect();
982        assert_eq!(spelled, ["a", "--force", "x/y"]);
983        assert_eq!(
984            words.iter().map(|(w, _)| w.as_str()).collect::<Vec<_>>(),
985            spelled
986        );
987        assert_eq!(
988            &src[toks[0].span.start..toks[0].span.end],
989            "%w[a --force\n  x/y]"
990        );
991        assert!(
992            matches!(&toks[1].kind, TokenKind::Words { flavor: Flavor::Keywords, words } if words.len() == 2)
993        );
994        assert_eq!(
995            kinds("%w[]"),
996            vec![TokenKind::Words {
997                flavor: Flavor::Strings,
998                words: vec![]
999            }]
1000        );
1001    }
1002
1003    #[test]
1004    fn a_percent_is_still_modulo_unless_w_or_i_and_a_bracket_follow() {
1005        assert_eq!(
1006            kinds("a %w(b)"),
1007            vec![
1008                TokenKind::Ident("a".into()),
1009                TokenKind::Op("%".into()),
1010                TokenKind::Ident("w".into()),
1011                TokenKind::LParen,
1012                TokenKind::Ident("b".into()),
1013                TokenKind::RParen,
1014            ]
1015        );
1016        assert_eq!(kinds("x%i")[1], TokenKind::Op("%".into()));
1017    }
1018
1019    #[test]
1020    fn a_word_list_refuses_commas_quotes_colons_and_an_open_end() {
1021        for (src, says) in [
1022            ("%w[a, b]", "not commas"),
1023            ("%w[\"a\" b]", "without quotes"),
1024            ("%i[:a b]", "without its colon"),
1025            ("%i[a-b]", "not a name"),
1026            ("%w[a # b]", "cannot be part of a word"),
1027            ("%w[a b", "unterminated"),
1028            ("%w[a\u{a0}b]", "cannot be part of a word"),
1029        ] {
1030            let e = lex(src).expect_err(src);
1031            assert!(e.message.contains(says), "{src}: {}", e.message);
1032        }
1033    }
1034
1035    #[test]
1036    fn lexes_pipeline_and_rocket() {
1037        assert_eq!(kinds("|> =>"), vec![TokenKind::Pipe, TokenKind::Rocket]);
1038    }
1039}