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}