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