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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26pub struct Span {
27    pub start: usize,
28    pub end: usize,
29}
30
31impl Span {
32    pub fn new(start: usize, end: usize) -> Self {
33        Self { start, end }
34    }
35}
36
37#[derive(Clone, Debug, PartialEq)]
38pub enum TokenKind {
39    // literals
40    Int(i64),
41    Float(f64),
42    Str(String),
43    /// An interpolated string: alternating literal and expression parts.
44    ///
45    /// `"a#{x}b"` lexes to `["a", "b"]` literals with `["x"]` between them —
46    /// the expression is kept as SOURCE TEXT and parsed by the parser, which
47    /// already knows how to parse an expression. Re-implementing expression
48    /// lexing inside the string lexer would be a second parser, and the two
49    /// would drift.
50    InterpolatedStr {
51        /// `parts.len() == exprs.len() + 1`, always — the literal before each
52        /// expression, plus the tail. An empty literal is kept rather than
53        /// dropped so that invariant holds for `"#{a}#{b}"` too.
54        parts: Vec<String>,
55        exprs: Vec<String>,
56    },
57    /// `:name` — a Ruby symbol, which lowers to a tatara-lisp keyword.
58    Sym(String),
59    True,
60    False,
61    Nil,
62
63    /// An identifier, or a keyword-like head (`if`, `do`, `end`, …).
64    /// The parser decides which; the lexer does not need to know.
65    Ident(String),
66
67    // punctuation
68    LParen,
69    RParen,
70    LBracket,
71    RBracket,
72    LBrace,
73    RBrace,
74    Comma,
75    Dot,
76    /// `:` in a hash literal (`foo: 1`) is folded into `Label`; a bare
77    /// colon is retained for anything else.
78    Colon,
79    /// `foo:` — a hash-literal label. Lexing this as one token is what
80    /// makes `{foo: 1}` and `{:foo => 1}` distinguishable at the parser
81    /// without lookahead games.
82    Label(String),
83    /// `=>` — the "rocket".
84    Rocket,
85    /// `|>` — the pipeline operator.
86    Pipe,
87
88    /// Any operator run: `+ - * / == != < <= > >= && || = ! %`.
89    Op(String),
90
91    // trivia — emitted, never skipped
92    Comment(String),
93    Newline,
94
95    Eof,
96}
97
98#[derive(Clone, Debug, PartialEq)]
99pub struct Token {
100    pub kind: TokenKind,
101    pub span: Span,
102}
103
104impl Token {
105    /// Is this token trivia (a comment or a newline)?
106    pub fn is_trivia(&self) -> bool {
107        matches!(self.kind, TokenKind::Comment(_) | TokenKind::Newline)
108    }
109}
110
111#[derive(Clone, Debug, PartialEq)]
112pub struct LexError {
113    pub message: String,
114    pub span: Span,
115}
116
117impl fmt::Display for LexError {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        write!(f, "{} at {}..{}", self.message, self.span.start, self.span.end)
120    }
121}
122
123impl std::error::Error for LexError {}
124
125/// Characters that may begin or continue an operator run.
126const OP_CHARS: &str = "+-*/=<>!%&|";
127
128/// Tokenize `src`, including trivia.
129pub fn lex(src: &str) -> Result<Vec<Token>, LexError> {
130    Lexer::new(src).run()
131}
132
133struct Lexer<'a> {
134    src: &'a str,
135    bytes: &'a [u8],
136    pos: usize,
137    out: Vec<Token>,
138}
139
140impl<'a> Lexer<'a> {
141    fn new(src: &'a str) -> Self {
142        Self {
143            src,
144            bytes: src.as_bytes(),
145            pos: 0,
146            out: Vec::new(),
147        }
148    }
149
150    fn peek(&self) -> Option<u8> {
151        self.bytes.get(self.pos).copied()
152    }
153
154    fn peek_at(&self, n: usize) -> Option<u8> {
155        self.bytes.get(self.pos + n).copied()
156    }
157
158    fn push(&mut self, kind: TokenKind, start: usize) {
159        self.out.push(Token {
160            kind,
161            span: Span::new(start, self.pos),
162        });
163    }
164
165    fn err(&self, message: impl Into<String>, start: usize) -> LexError {
166        LexError {
167            message: message.into(),
168            span: Span::new(start, self.pos.max(start + 1)),
169        }
170    }
171
172    fn run(mut self) -> Result<Vec<Token>, LexError> {
173        while let Some(c) = self.peek() {
174            let start = self.pos;
175            match c {
176                b'\n' => {
177                    self.pos += 1;
178                    self.push(TokenKind::Newline, start);
179                }
180                // Horizontal whitespace carries no meaning in blue and is
181                // reconstructed by the formatter, so it is the one thing
182                // dropped. Newlines are kept: they are statement separators.
183                b' ' | b'\t' | b'\r' => {
184                    self.pos += 1;
185                }
186                b'#' => {
187                    while let Some(c) = self.peek() {
188                        if c == b'\n' {
189                            break;
190                        }
191                        self.pos += 1;
192                    }
193                    let text = self.src[start..self.pos].to_string();
194                    self.push(TokenKind::Comment(text), start);
195                }
196                b'"' => self.lex_string(start)?,
197                b'0'..=b'9' => self.lex_number(start)?,
198                b':' => self.lex_colon(start),
199                b'(' => self.one(TokenKind::LParen, start),
200                b')' => self.one(TokenKind::RParen, start),
201                b'[' => self.one(TokenKind::LBracket, start),
202                b']' => self.one(TokenKind::RBracket, start),
203                b'{' => self.one(TokenKind::LBrace, start),
204                b'}' => self.one(TokenKind::RBrace, start),
205                b',' => self.one(TokenKind::Comma, start),
206                b'.' => self.one(TokenKind::Dot, start),
207                c if is_ident_start(c) => self.lex_ident(start),
208                c if OP_CHARS.as_bytes().contains(&c) => self.lex_op(start),
209                _ => {
210                    self.pos += 1;
211                    return Err(self.err(
212                        format!("unexpected character {:?}", c as char),
213                        start,
214                    ));
215                }
216            }
217        }
218        let end = self.pos;
219        self.out.push(Token {
220            kind: TokenKind::Eof,
221            span: Span::new(end, end),
222        });
223        Ok(self.out)
224    }
225
226    fn one(&mut self, kind: TokenKind, start: usize) {
227        self.pos += 1;
228        self.push(kind, start);
229    }
230
231    fn lex_string(&mut self, start: usize) -> Result<(), LexError> {
232        self.pos += 1; // opening quote
233        let mut buf = String::new();
234        // Interpolation state. `parts` collects the literal run before each
235        // `#{…}`; `exprs` collects the raw source between the braces.
236        let mut parts: Vec<String> = Vec::new();
237        let mut exprs: Vec<String> = Vec::new();
238        loop {
239            // `#{` opens an interpolation. A bare `#` is just a character — a
240            // string full of `#` comments would otherwise be unwritable.
241            if self.peek() == Some(b'#') && self.src.as_bytes().get(self.pos + 1) == Some(&b'{') {
242                self.pos += 2;
243                let expr_start = self.pos;
244                // Track nesting so `"#{ {a: 1} }"` closes on the right brace.
245                let mut depth = 1usize;
246                while let Some(c) = self.peek() {
247                    match c {
248                        b'{' => depth += 1,
249                        b'}' => {
250                            depth -= 1;
251                            if depth == 0 {
252                                break;
253                            }
254                        }
255                        _ => {}
256                    }
257                    self.pos += 1;
258                }
259                if self.peek() != Some(b'}') {
260                    return Err(self.err("unterminated `#{` interpolation", start));
261                }
262                exprs.push(self.src[expr_start..self.pos].to_string());
263                self.pos += 1; // past '}'
264                parts.push(std::mem::take(&mut buf));
265                continue;
266            }
267            match self.peek() {
268                None => return Err(self.err("unterminated string literal", start)),
269                Some(b'"') => {
270                    self.pos += 1;
271                    break;
272                }
273                Some(b'\\') => {
274                    self.pos += 1;
275                    let esc = self
276                        .peek()
277                        .ok_or_else(|| self.err("unterminated escape", start))?;
278                    let ch = match esc {
279                        b'n' => '\n',
280                        b't' => '\t',
281                        b'r' => '\r',
282                        b'\\' => '\\',
283                        b'"' => '"',
284                        b'0' => '\0',
285                        // `\u{...}` — a Unicode scalar by codepoint. Ruby and
286                        // Elixir both have it, and without it a blue source
287                        // file can only carry a non-ASCII character literally,
288                        // which is exactly the case where an explicit escape
289                        // matters most (combining marks, zero-width joiners,
290                        // anything invisible in an editor).
291                        b'u' => {
292                            self.pos += 1; // past 'u'
293                            if self.peek() != Some(b'{') {
294                                return Err(self.err("expected `{` after \\u", start));
295                            }
296                            self.pos += 1; // past '{'
297                            let hex_start = self.pos;
298                            while self.peek().is_some_and(|c| c != b'}') {
299                                self.pos += 1;
300                            }
301                            if self.peek() != Some(b'}') {
302                                return Err(self.err("unterminated \\u{...} escape", start));
303                            }
304                            let hex = &self.src[hex_start..self.pos];
305                            let code = u32::from_str_radix(hex, 16).map_err(|_| {
306                                self.err(format!("`{hex}` is not hexadecimal"), start)
307                            })?;
308                            // A surrogate or out-of-range value is REJECTED, not
309                            // replaced with U+FFFD: silently substituting a
310                            // different character is how a codepoint typo
311                            // becomes a rendering mystery.
312                            let ch = char::from_u32(code).ok_or_else(|| {
313                                self.err(
314                                    format!("`{hex}` is not a Unicode scalar value"),
315                                    start,
316                                )
317                            })?;
318                            buf.push(ch);
319                            self.pos += 1; // past '}'
320                            continue;
321                        }
322                        other => {
323                            return Err(self.err(
324                                format!("unknown escape \\{}", other as char),
325                                start,
326                            ))
327                        }
328                    };
329                    buf.push(ch);
330                    self.pos += 1;
331                }
332                Some(_) => {
333                    let ch = self.src[self.pos..]
334                        .chars()
335                        .next()
336                        .expect("peek said there is a byte");
337                    buf.push(ch);
338                    self.pos += ch.len_utf8();
339                }
340            }
341        }
342        if exprs.is_empty() {
343            self.push(TokenKind::Str(buf), start);
344        } else {
345            parts.push(buf);
346            self.push(TokenKind::InterpolatedStr { parts, exprs }, start);
347        }
348        Ok(())
349    }
350
351    fn lex_number(&mut self, start: usize) -> Result<(), LexError> {
352        while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
353            self.pos += 1;
354        }
355        // A `.` is a decimal point only when a digit follows; otherwise it
356        // is the method-call dot and belongs to the next token. This is why
357        // `1.foo` sends `foo` to `1` rather than failing to lex.
358        let is_float = self.peek() == Some(b'.')
359            && matches!(self.peek_at(1), Some(b'0'..=b'9'));
360        if is_float {
361            self.pos += 1;
362            while matches!(self.peek(), Some(b'0'..=b'9' | b'_')) {
363                self.pos += 1;
364            }
365        }
366        let text: String = self.src[start..self.pos].chars().filter(|c| *c != '_').collect();
367        if is_float {
368            let v: f64 = text
369                .parse()
370                .map_err(|_| self.err(format!("invalid float literal {text:?}"), start))?;
371            self.push(TokenKind::Float(v), start);
372        } else {
373            let v: i64 = text
374                .parse()
375                .map_err(|_| self.err(format!("integer literal out of range: {text:?}"), start))?;
376            self.push(TokenKind::Int(v), start);
377        }
378        Ok(())
379    }
380
381    fn lex_colon(&mut self, start: usize) {
382        // `:name` is a symbol; a bare `:` is punctuation.
383        if matches!(self.peek_at(1), Some(c) if is_ident_start(c)) {
384            self.pos += 1;
385            let s = self.pos;
386            while matches!(self.peek(), Some(c) if is_ident_continue(c)) {
387                self.pos += 1;
388            }
389            let name = self.src[s..self.pos].to_string();
390            self.push(TokenKind::Sym(name), start);
391        } else {
392            self.one(TokenKind::Colon, start);
393        }
394    }
395
396    fn lex_ident(&mut self, start: usize) {
397        while matches!(self.peek(), Some(c) if is_ident_continue(c)) {
398            self.pos += 1;
399        }
400        // Ruby's trailing `?` and `!` are part of the name.
401        if matches!(self.peek(), Some(b'?') | Some(b'!')) {
402            self.pos += 1;
403        }
404        let name = self.src[start..self.pos].to_string();
405
406        // `foo:` is a hash label — one token, so `{foo: 1}` needs no
407        // lookahead in the parser. Not folded when followed by `:`, which
408        // would be `foo::bar`.
409        if self.peek() == Some(b':') && self.peek_at(1) != Some(b':') {
410            self.pos += 1;
411            self.push(TokenKind::Label(name), start);
412            return;
413        }
414
415        let kind = match name.as_str() {
416            "true" => TokenKind::True,
417            "false" => TokenKind::False,
418            "nil" => TokenKind::Nil,
419            _ => TokenKind::Ident(name),
420        };
421        self.push(kind, start);
422    }
423
424    fn lex_op(&mut self, start: usize) {
425        while matches!(self.peek(), Some(c) if OP_CHARS.as_bytes().contains(&c)) {
426            self.pos += 1;
427        }
428        let text = self.src[start..self.pos].to_string();
429        let kind = match text.as_str() {
430            "=>" => TokenKind::Rocket,
431            "|>" => TokenKind::Pipe,
432            _ => TokenKind::Op(text),
433        };
434        self.push(kind, start);
435    }
436}
437
438fn is_ident_start(c: u8) -> bool {
439    c.is_ascii_alphabetic() || c == b'_'
440}
441
442fn is_ident_continue(c: u8) -> bool {
443    c.is_ascii_alphanumeric() || c == b'_'
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    fn kinds(src: &str) -> Vec<TokenKind> {
451        lex(src)
452            .expect("lex")
453            .into_iter()
454            .filter(|t| !t.is_trivia() && t.kind != TokenKind::Eof)
455            .map(|t| t.kind)
456            .collect()
457    }
458
459    #[test]
460    fn lexes_integers_and_floats() {
461        assert_eq!(kinds("1 2.5 1_000"), vec![
462            TokenKind::Int(1),
463            TokenKind::Float(2.5),
464            TokenKind::Int(1000),
465        ]);
466    }
467
468    /// `1.foo` is a send, not a malformed float. The decimal point is a
469    /// decimal point only when a digit follows it.
470    #[test]
471    fn a_dot_after_a_digit_is_a_send_unless_a_digit_follows() {
472        assert_eq!(kinds("1.foo"), vec![
473            TokenKind::Int(1),
474            TokenKind::Dot,
475            TokenKind::Ident("foo".into()),
476        ]);
477    }
478
479    #[test]
480    fn lexes_symbols_and_labels_distinctly() {
481        assert_eq!(kinds(":foo"), vec![TokenKind::Sym("foo".into())]);
482        assert_eq!(kinds("foo:"), vec![TokenKind::Label("foo".into())]);
483    }
484
485    #[test]
486    fn ruby_predicate_and_bang_suffixes_are_part_of_the_name() {
487        assert_eq!(kinds("empty? save!"), vec![
488            TokenKind::Ident("empty?".into()),
489            TokenKind::Ident("save!".into()),
490        ]);
491    }
492
493    #[test]
494    fn lexes_strings_with_escapes() {
495        assert_eq!(kinds(r#""a\nb""#), vec![TokenKind::Str("a\nb".into())]);
496    }
497
498    #[test]
499    fn unterminated_string_is_an_error_with_a_span() {
500        let e = lex("\"oops").expect_err("must fail");
501        assert!(e.message.contains("unterminated"), "{}", e.message);
502        assert_eq!(e.span.start, 0);
503    }
504
505    /// Trivia is EMITTED, not skipped. A formatter and an LSP both need a
506    /// lossless stream, and neither can recover what the lexer discarded.
507    #[test]
508    fn comments_and_newlines_are_emitted_as_trivia() {
509        let toks = lex("1 # hi\n2").expect("lex");
510        assert!(
511            toks.iter().any(|t| matches!(&t.kind, TokenKind::Comment(c) if c == "# hi")),
512            "comment was dropped: {toks:?}"
513        );
514        assert!(
515            toks.iter().any(|t| t.kind == TokenKind::Newline),
516            "newline was dropped"
517        );
518    }
519
520    /// Anti-vacuity for the span claim: spans must be real byte offsets
521    /// into the source, not placeholders.
522    #[test]
523    fn spans_point_at_the_actual_bytes() {
524        let src = "foo + 1";
525        let toks = lex(src).expect("lex");
526        let first = &toks[0];
527        assert_eq!(&src[first.span.start..first.span.end], "foo");
528        let last_int = toks
529            .iter()
530            .find(|t| matches!(t.kind, TokenKind::Int(_)))
531            .expect("an int token");
532        assert_eq!(&src[last_int.span.start..last_int.span.end], "1");
533    }
534
535    #[test]
536    fn lexes_pipeline_and_rocket() {
537        assert_eq!(kinds("|> =>"), vec![TokenKind::Pipe, TokenKind::Rocket]);
538    }
539}