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