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