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    /// `foo:` — a hash-literal label. Lexing this as one token is what
83    /// makes `{foo: 1}` and `{:foo => 1}` distinguishable at the parser
84    /// without lookahead games.
85    Label(String),
86    /// `=>` — the "rocket".
87    Rocket,
88    /// `|>` — the pipeline operator.
89    Pipe,
90
91    /// Any operator run: `+ - * / == != < <= > >= && || = ! %`.
92    Op(String),
93
94    // trivia — emitted, never skipped
95    Comment(String),
96    Newline,
97
98    Eof,
99}
100
101#[derive(Clone, Debug, PartialEq)]
102pub struct Token {
103    pub kind: TokenKind,
104    pub span: Span,
105}
106
107impl Token {
108    /// Is this token trivia (a comment or a newline)?
109    pub fn is_trivia(&self) -> bool {
110        matches!(self.kind, TokenKind::Comment(_) | TokenKind::Newline)
111    }
112}
113
114#[derive(Clone, Debug, PartialEq)]
115pub struct LexError {
116    pub message: String,
117    pub span: Span,
118}
119
120impl fmt::Display for LexError {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        write!(
123            f,
124            "{} at {}..{}",
125            self.message, self.span.start, self.span.end
126        )
127    }
128}
129
130impl std::error::Error for LexError {}
131
132/// Characters that may begin or continue an operator run.
133const OP_CHARS: &str = "+-*/=<>!%&|";
134
135/// Tokenize `src`, including trivia.
136pub fn lex(src: &str) -> Result<Vec<Token>, LexError> {
137    Lexer::new(src).run()
138}
139
140struct Lexer<'a> {
141    src: &'a str,
142    bytes: &'a [u8],
143    pos: usize,
144    out: Vec<Token>,
145}
146
147impl<'a> Lexer<'a> {
148    fn new(src: &'a str) -> Self {
149        Self {
150            src,
151            bytes: src.as_bytes(),
152            pos: 0,
153            out: Vec::new(),
154        }
155    }
156
157    /// Decode the whole UTF-8 character at the cursor, with its byte width.
158    ///
159    /// The lexer scans bytes because that is right for ASCII, which is all of
160    /// blue's structure. This is the seam where it stops being right: a
161    /// character outside ASCII has to be decoded before anything can classify
162    /// it, and the previous approach — treating every byte >= 0x80 as a letter
163    /// — could not tell `≠` from `文`, so an operator was unreachable and a
164    /// malformed sequence became a silent identifier.
165    fn decode_char(&self) -> Option<(char, usize)> {
166        let rest = self.src.get(self.pos..)?;
167        let ch = rest.chars().next()?;
168        Some((ch, ch.len_utf8()))
169    }
170
171    fn peek(&self) -> Option<u8> {
172        self.bytes.get(self.pos).copied()
173    }
174
175    fn peek_at(&self, n: usize) -> Option<u8> {
176        self.bytes.get(self.pos + n).copied()
177    }
178
179    fn push(&mut self, kind: TokenKind, start: usize) {
180        self.out.push(Token {
181            kind,
182            span: Span::new(start, self.pos),
183        });
184    }
185
186    fn err(&self, message: impl Into<String>, start: usize) -> LexError {
187        LexError {
188            message: message.into(),
189            span: Span::new(start, self.pos.max(start + 1)),
190        }
191    }
192
193    fn run(mut self) -> Result<Vec<Token>, LexError> {
194        while let Some(c) = self.peek() {
195            let start = self.pos;
196            match c {
197                b'\n' => {
198                    self.pos += 1;
199                    self.push(TokenKind::Newline, start);
200                }
201                // Horizontal whitespace carries no meaning in blue and is
202                // reconstructed by the formatter, so it is the one thing
203                // dropped. Newlines are kept: they are statement separators.
204                b' ' | b'\t' | b'\r' => {
205                    self.pos += 1;
206                }
207                b'#' => {
208                    while let Some(c) = self.peek() {
209                        if c == b'\n' {
210                            break;
211                        }
212                        self.pos += 1;
213                    }
214                    let text = self.src[start..self.pos].to_string();
215                    self.push(TokenKind::Comment(text), start);
216                }
217                b'"' => self.lex_string(start)?,
218                b'0'..=b'9' => self.lex_number(start)?,
219                b':' => self.lex_colon(start),
220                b'(' => self.one(TokenKind::LParen, start),
221                b')' => self.one(TokenKind::RParen, start),
222                b'[' => self.one(TokenKind::LBracket, start),
223                b']' => self.one(TokenKind::RBracket, start),
224                b'{' => self.one(TokenKind::LBrace, start),
225                b'}' => self.one(TokenKind::RBrace, start),
226                b',' => self.one(TokenKind::Comma, start),
227                b'.' => self.one(TokenKind::Dot, start),
228                // Non-ASCII goes through the UTF-8 layer, which decodes a
229                // whole CHARACTER and asks `kigou` what it means. This must
230                // precede the ident branch: a symbol like `≠` is an operator,
231                // not the first letter of a name.
232                c if c >= 0x80 => {
233                    let Some((ch, width)) = self.decode_char() else {
234                        self.pos += 1;
235                        return Err(self.err("invalid UTF-8 in source", start));
236                    };
237                    match crate::kigou::classify(ch) {
238                        crate::kigou::Class::Operator(op) => {
239                            self.pos += width;
240                            self.push(TokenKind::Op(op.to_owned()), start);
241                        }
242                        crate::kigou::Class::Word => self.lex_ident(start),
243                        crate::kigou::Class::Reject => {
244                            self.pos += width;
245                            return Err(self.err(
246                                format!(
247                                    "the character {ch:?} (U+{:04X}) is not legal in blue source \
248                                     outside a string — invisible and control characters are \
249                                     rejected because source that looks correct and is not costs \
250                                     far more than a clear error here",
251                                    ch as u32
252                                ),
253                                start,
254                            ));
255                        }
256                    }
257                }
258                c if is_ident_start(c) => self.lex_ident(start),
259                c if OP_CHARS.as_bytes().contains(&c) => self.lex_op(start),
260                _ => {
261                    self.pos += 1;
262                    return Err(self.err(format!("unexpected character {:?}", c as char), start));
263                }
264            }
265        }
266        let end = self.pos;
267        self.out.push(Token {
268            kind: TokenKind::Eof,
269            span: Span::new(end, end),
270        });
271        Ok(self.out)
272    }
273
274    fn one(&mut self, kind: TokenKind, start: usize) {
275        self.pos += 1;
276        self.push(kind, start);
277    }
278
279    fn lex_string(&mut self, start: usize) -> Result<(), LexError> {
280        self.pos += 1; // opening quote
281        let mut buf = String::new();
282        // Interpolation state. `parts` collects the literal run before each
283        // `#{…}`; `exprs` collects the raw source between the braces.
284        let mut parts: Vec<String> = Vec::new();
285        let mut exprs: Vec<String> = Vec::new();
286        loop {
287            // `#{` opens an interpolation. A bare `#` is just a character — a
288            // string full of `#` comments would otherwise be unwritable.
289            if self.peek() == Some(b'#') && self.src.as_bytes().get(self.pos + 1) == Some(&b'{') {
290                self.pos += 2;
291                let expr_start = self.pos;
292                // Track nesting so `"#{ {a: 1} }"` closes on the right brace.
293                let mut depth = 1usize;
294                while let Some(c) = self.peek() {
295                    match c {
296                        b'{' => depth += 1,
297                        b'}' => {
298                            depth -= 1;
299                            if depth == 0 {
300                                break;
301                            }
302                        }
303                        _ => {}
304                    }
305                    self.pos += 1;
306                }
307                if self.peek() != Some(b'}') {
308                    return Err(self.err("unterminated `#{` interpolation", start));
309                }
310                exprs.push(self.src[expr_start..self.pos].to_string());
311                self.pos += 1; // past '}'
312                parts.push(std::mem::take(&mut buf));
313                continue;
314            }
315            match self.peek() {
316                None => return Err(self.err("unterminated string literal", start)),
317                Some(b'"') => {
318                    self.pos += 1;
319                    break;
320                }
321                Some(b'\\') => {
322                    self.pos += 1;
323                    let esc = self
324                        .peek()
325                        .ok_or_else(|| self.err("unterminated escape", start))?;
326                    let ch = match esc {
327                        b'n' => '\n',
328                        b't' => '\t',
329                        b'r' => '\r',
330                        b'\\' => '\\',
331                        b'"' => '"',
332                        b'0' => '\0',
333                        // `\u{...}` — a Unicode scalar by codepoint. Ruby and
334                        // Elixir both have it, and without it a blue source
335                        // file can only carry a non-ASCII character literally,
336                        // which is exactly the case where an explicit escape
337                        // matters most (combining marks, zero-width joiners,
338                        // anything invisible in an editor).
339                        b'u' => {
340                            self.pos += 1; // past 'u'
341                            if self.peek() != Some(b'{') {
342                                return Err(self.err("expected `{` after \\u", start));
343                            }
344                            self.pos += 1; // past '{'
345                            let hex_start = self.pos;
346                            while self.peek().is_some_and(|c| c != b'}') {
347                                self.pos += 1;
348                            }
349                            if self.peek() != Some(b'}') {
350                                return Err(self.err("unterminated \\u{...} escape", start));
351                            }
352                            let hex = &self.src[hex_start..self.pos];
353                            let code = u32::from_str_radix(hex, 16).map_err(|_| {
354                                self.err(format!("`{hex}` is not hexadecimal"), start)
355                            })?;
356                            // A surrogate or out-of-range value is REJECTED, not
357                            // replaced with U+FFFD: silently substituting a
358                            // different character is how a codepoint typo
359                            // becomes a rendering mystery.
360                            let ch = char::from_u32(code).ok_or_else(|| {
361                                self.err(format!("`{hex}` is not a Unicode scalar value"), start)
362                            })?;
363                            buf.push(ch);
364                            self.pos += 1; // past '}'
365                            continue;
366                        }
367                        other => {
368                            return Err(
369                                self.err(format!("unknown escape \\{}", other as char), start)
370                            )
371                        }
372                    };
373                    buf.push(ch);
374                    self.pos += 1;
375                }
376                Some(_) => {
377                    let ch = self.src[self.pos..]
378                        .chars()
379                        .next()
380                        .expect("peek said there is a byte");
381                    buf.push(ch);
382                    self.pos += ch.len_utf8();
383                }
384            }
385        }
386        if exprs.is_empty() {
387            self.push(TokenKind::Str(buf), start);
388        } else {
389            parts.push(buf);
390            self.push(TokenKind::InterpolatedStr { parts, exprs }, start);
391        }
392        Ok(())
393    }
394
395    fn lex_number(&mut self, start: usize) -> Result<(), LexError> {
396        while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
397            self.pos += 1;
398        }
399        // A `.` is a decimal point only when a digit follows; otherwise it
400        // is the method-call dot and belongs to the next token. This is why
401        // `1.foo` sends `foo` to `1` rather than failing to lex.
402        let is_float = self.peek() == Some(b'.') && matches!(self.peek_at(1), Some(b'0'..=b'9'));
403        if is_float {
404            self.pos += 1;
405            while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
406                self.pos += 1;
407            }
408        }
409        let text: String = self.src[start..self.pos]
410            .chars()
411            .filter(|c| *c != '_')
412            .collect();
413        if is_float {
414            let v: f64 = text
415                .parse()
416                .map_err(|_| self.err(format!("invalid float literal {text:?}"), start))?;
417            self.push(TokenKind::Float(v), start);
418        } else {
419            let v: i64 = text
420                .parse()
421                .map_err(|_| self.err(format!("integer literal out of range: {text:?}"), start))?;
422            self.push(TokenKind::Int(v), start);
423        }
424        Ok(())
425    }
426
427    fn lex_colon(&mut self, start: usize) {
428        // `:name` is a symbol; a bare `:` is punctuation.
429        if matches!(self.peek_at(1), Some(c) if is_ident_start(c)) {
430            self.pos += 1;
431            let s = self.pos;
432            while matches!(self.peek(), Some(c) if is_ident_continue(c)) {
433                self.pos += 1;
434            }
435            let name = self.src[s..self.pos].to_string();
436            self.push(TokenKind::Sym(name), start);
437        } else {
438            self.one(TokenKind::Colon, start);
439        }
440    }
441
442    fn lex_ident(&mut self, start: usize) {
443        while matches!(self.peek(), Some(c) if is_ident_continue(c)) {
444            self.pos += 1;
445        }
446
447        // A TRAILING `?` or `!` is part of the name, as in Ruby.
448        //
449        // Without this, four builtins the runtime registers — `contains?`,
450        // `starts_with?`, `ends_with?` and `to_int!` — were unreachable from
451        // every blue program ever written: dead code in the runtime and a
452        // silent capability gap that made `moji` reimplement three of them by
453        // hand.
454        //
455        // The `=` guard is what keeps `!=` working. `a != b` is safe either way
456        // (the `!` follows a space), but `a!=b` is not: without the lookahead
457        // the `!` would be eaten into the identifier and the comparison would
458        // vanish. So a trailing `!` joins the name only when what follows is
459        // NOT `=`.
460        if matches!(self.peek(), Some(b'?'))
461            || (matches!(self.peek(), Some(b'!')) && !matches!(self.peek_at(1), Some(b'=')))
462        {
463            self.pos += 1;
464        }
465        // Ruby's trailing `?` and `!` are part of the name.
466        if matches!(self.peek(), Some(b'?') | Some(b'!')) {
467            self.pos += 1;
468        }
469        let name = self.src[start..self.pos].to_string();
470
471        // `foo:` is a hash label — one token, so `{foo: 1}` needs no
472        // lookahead in the parser. Not folded when followed by `:`, which
473        // would be `foo::bar`.
474        if self.peek() == Some(b':') && self.peek_at(1) != Some(b':') {
475            self.pos += 1;
476            self.push(TokenKind::Label(name), start);
477            return;
478        }
479
480        let kind = match name.as_str() {
481            "true" => TokenKind::True,
482            "false" => TokenKind::False,
483            "nil" => TokenKind::Nil,
484            _ => TokenKind::Ident(name),
485        };
486        self.push(kind, start);
487    }
488
489    fn lex_op(&mut self, start: usize) {
490        while matches!(self.peek(), Some(c) if OP_CHARS.as_bytes().contains(&c)) {
491            self.pos += 1;
492        }
493        let text = self.src[start..self.pos].to_string();
494        let kind = match text.as_str() {
495            "=>" => TokenKind::Rocket,
496            "|>" => TokenKind::Pipe,
497            _ => TokenKind::Op(text),
498        };
499        self.push(kind, start);
500    }
501}
502
503// An identifier may be written in ANY script, not only Latin.
504//
505// The lexer works on bytes, and the rule that makes that safe is a property of
506// UTF-8 rather than a trick: every byte of a multi-byte character is >= 0x80,
507// and every character blue gives structural meaning — operators, delimiters,
508// quotes, `#` — is ASCII, hence < 0x80. So "byte >= 0x80 is part of an
509// identifier" consumes each multi-byte character whole, leaves every ASCII
510// decision untouched, and keeps `src[start..pos]` on a char boundary.
511//
512// Without this the `yakugo` language packs cannot exist at all: `définir` died
513// on 'Ã' and `定義` on 'å' — a UTF-8 continuation byte reported as a character,
514// which is the diagnostic a byte-oriented lexer gives for text it was never
515// built to read.
516//
517// PERMISSIVE, deliberately and worth stating: this admits any non-ASCII byte,
518// so an emoji or a lone combining mark is a legal identifier. Enforcing
519// XID_Start/XID_Continue would need real char decoding through the whole
520// lexer. Accepting too much is a surface question; rejecting every non-Latin
521// script was a correctness one.
522fn is_ident_start(c: u8) -> bool {
523    c.is_ascii_alphabetic() || c == b'_'
524}
525
526fn is_ident_continue(c: u8) -> bool {
527    c.is_ascii_alphanumeric() || c == b'_' || c >= 0x80
528}
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533
534    fn kinds(src: &str) -> Vec<TokenKind> {
535        lex(src)
536            .expect("lex")
537            .into_iter()
538            .filter(|t| !t.is_trivia() && t.kind != TokenKind::Eof)
539            .map(|t| t.kind)
540            .collect()
541    }
542
543    #[test]
544    fn lexes_integers_and_floats() {
545        assert_eq!(
546            kinds("1 2.5 1_000"),
547            vec![
548                TokenKind::Int(1),
549                TokenKind::Float(2.5),
550                TokenKind::Int(1000),
551            ]
552        );
553    }
554
555    /// `1.foo` is a send, not a malformed float. The decimal point is a
556    /// decimal point only when a digit follows it.
557    #[test]
558    fn a_dot_after_a_digit_is_a_send_unless_a_digit_follows() {
559        assert_eq!(
560            kinds("1.foo"),
561            vec![
562                TokenKind::Int(1),
563                TokenKind::Dot,
564                TokenKind::Ident("foo".into()),
565            ]
566        );
567    }
568
569    #[test]
570    fn lexes_symbols_and_labels_distinctly() {
571        assert_eq!(kinds(":foo"), vec![TokenKind::Sym("foo".into())]);
572        assert_eq!(kinds("foo:"), vec![TokenKind::Label("foo".into())]);
573    }
574
575    #[test]
576    fn ruby_predicate_and_bang_suffixes_are_part_of_the_name() {
577        assert_eq!(
578            kinds("empty? save!"),
579            vec![
580                TokenKind::Ident("empty?".into()),
581                TokenKind::Ident("save!".into()),
582            ]
583        );
584    }
585
586    #[test]
587    fn lexes_strings_with_escapes() {
588        assert_eq!(kinds(r#""a\nb""#), vec![TokenKind::Str("a\nb".into())]);
589    }
590
591    #[test]
592    fn unterminated_string_is_an_error_with_a_span() {
593        let e = lex("\"oops").expect_err("must fail");
594        assert!(e.message.contains("unterminated"), "{}", e.message);
595        assert_eq!(e.span.start, 0);
596    }
597
598    /// Trivia is EMITTED, not skipped. A formatter and an LSP both need a
599    /// lossless stream, and neither can recover what the lexer discarded.
600    #[test]
601    fn comments_and_newlines_are_emitted_as_trivia() {
602        let toks = lex("1 # hi\n2").expect("lex");
603        assert!(
604            toks.iter()
605                .any(|t| matches!(&t.kind, TokenKind::Comment(c) if c == "# hi")),
606            "comment was dropped: {toks:?}"
607        );
608        assert!(
609            toks.iter().any(|t| t.kind == TokenKind::Newline),
610            "newline was dropped"
611        );
612    }
613
614    /// Anti-vacuity for the span claim: spans must be real byte offsets
615    /// into the source, not placeholders.
616    #[test]
617    fn spans_point_at_the_actual_bytes() {
618        let src = "foo + 1";
619        let toks = lex(src).expect("lex");
620        let first = &toks[0];
621        assert_eq!(&src[first.span.start..first.span.end], "foo");
622        let last_int = toks
623            .iter()
624            .find(|t| matches!(t.kind, TokenKind::Int(_)))
625            .expect("an int token");
626        assert_eq!(&src[last_int.span.start..last_int.span.end], "1");
627    }
628
629    #[test]
630    fn lexes_pipeline_and_rocket() {
631        assert_eq!(kinds("|> =>"), vec![TokenKind::Pipe, TokenKind::Rocket]);
632    }
633}