Skip to main content

cove_syntax/
lexer.rs

1//! The Cove lexer.
2//!
3//! Converts one source file into a flat token stream. Lexing never panics on
4//! malformed input: every lexical error is collected as a [`Diagnostic`] and
5//! the lexer recovers by skipping the offending text, so a single call to
6//! [`lex`] can report every problem in a file at once.
7
8use cove_diag::{Diagnostic, FileId, SourceMap, Span};
9
10use crate::token::{Keyword, StringPart, Token, TokenKind};
11
12/// One level of the nesting [`Lexer::skip_interpolation_body`] steps over: a
13/// `{` that opened an interpolation, or a `"` that opened a string literal.
14enum Unclosed {
15    Brace,
16    Quote,
17    Apostrophe,
18}
19
20/// Lexes `file` out of `sources` into a token stream.
21///
22/// The returned stream always ends with exactly one [`TokenKind::Eof`] whose
23/// span is the empty range at end of file. On any lexical error, every error
24/// found in the file is collected and returned; no tokens are produced in
25/// that case.
26pub fn lex(sources: &SourceMap, file: FileId) -> Result<Vec<Token>, Vec<Diagnostic>> {
27    let (tokens, diagnostics) = lex_recovered(sources, file);
28    if diagnostics.is_empty() {
29        Ok(tokens)
30    } else {
31        Err(diagnostics)
32    }
33}
34
35/// Lexes `file` and answers both halves of what the lexer found: the tokens it
36/// recovered *and* everything it complained about.
37///
38/// [`lex`] is this function with the tokens thrown away whenever there is a
39/// diagnostic, which is the right contract for a compiler — a parser handed a
40/// token stream with a hole in it reports a second, invented error at the
41/// hole. It is the wrong contract for a reader looking at the text. The
42/// recovery this exposes is not new: the lexer has always skipped past a
43/// lexical error and carried on so that one call reports every problem in a
44/// file, and these are the tokens that pass produced.
45///
46/// The caller is what makes the difference. `crates/cove-wasm`'s highlighter
47/// colours source that is being typed, which is a state that does not lex for
48/// most of the time a string literal is being written, and a highlighter that
49/// had nothing to say about a file with one open quote in it would have
50/// nothing to say most of the time.
51///
52/// The tokens are the same tokens [`lex`] would have answered had there been
53/// no diagnostic; nothing is invented to fill a gap. Text the lexer skipped is
54/// simply absent from the stream, so a caller that cares what is between two
55/// tokens must look at the source.
56pub fn lex_recovered(sources: &SourceMap, file: FileId) -> (Vec<Token>, Vec<Diagnostic>) {
57    let text = sources.get(file).text.as_str();
58    let mut lexer = Lexer {
59        text,
60        file,
61        pos: 0,
62        tokens: Vec::new(),
63        diagnostics: Vec::new(),
64        pending_newline: false,
65    };
66    lexer.run();
67    (lexer.tokens, lexer.diagnostics)
68}
69
70struct Lexer<'a> {
71    text: &'a str,
72    file: FileId,
73    pos: usize,
74    tokens: Vec<Token>,
75    diagnostics: Vec<Diagnostic>,
76    /// Set once a line break is seen in the trivia before the next token, and
77    /// cleared when that token is produced.
78    pending_newline: bool,
79}
80
81/// The character an escape sequence spells, or `None` when it spells none.
82///
83/// One table for both literal forms, because the rule is one: **a code-point
84/// literal takes the escapes a string takes, and `\'` besides.** `\'` is
85/// therefore legal in a string too, where the apostrophe needs no escaping —
86/// which is a widening, and a deliberate one: a second table would be a
87/// second rule to remember and a second place for the two to drift apart.
88///
89/// There is no `\u{...}`. Nothing in the corpus has needed one — every
90/// code point a program in this repository names is ASCII — and adding it to
91/// one literal form and not the other would be exactly the asymmetry the
92/// paragraph above avoids. When something needs it, it goes in here and
93/// both forms gain it at once.
94fn escaped_char(escaped: char) -> Option<char> {
95    Some(match escaped {
96        '\\' => '\\',
97        '"' => '"',
98        '\'' => '\'',
99        'n' => '\n',
100        't' => '\t',
101        'r' => '\r',
102        '0' => '\0',
103        '{' => '{',
104        '}' => '}',
105        _ => return None,
106    })
107}
108
109fn is_ident_start(c: char) -> bool {
110    c.is_ascii_alphabetic() || c == '_'
111}
112
113fn is_ident_continue(c: char) -> bool {
114    c.is_ascii_alphanumeric() || c == '_'
115}
116
117/// Nanoseconds per unit for a duration suffix, or `None` if `unit` is not one
118/// of `ns`, `us`, `ms`, `s`, `m`, `h`.
119fn duration_factor(unit: &str) -> Option<i64> {
120    Some(match unit {
121        "ns" => 1,
122        "us" => 1_000,
123        "ms" => 1_000_000,
124        "s" => 1_000_000_000,
125        "m" => 60_000_000_000,
126        "h" => 3_600_000_000_000,
127        _ => return None,
128    })
129}
130
131impl<'a> Lexer<'a> {
132    fn peek_char(&self) -> Option<char> {
133        self.text[self.pos..].chars().next()
134    }
135
136    fn peek_char_at(&self, n: usize) -> Option<char> {
137        self.text[self.pos..].chars().nth(n)
138    }
139
140    fn bump(&mut self) -> Option<char> {
141        let c = self.peek_char()?;
142        self.pos += c.len_utf8();
143        Some(c)
144    }
145
146    /// Produces one token, transferring any line break seen in the trivia
147    /// before it onto [`Token::preceded_by_newline`].
148    fn push_token(&mut self, kind: TokenKind, start: usize) {
149        let span = Span::new(self.file, start as u32, self.pos as u32);
150        let preceded_by_newline = std::mem::take(&mut self.pending_newline);
151        self.tokens.push(Token {
152            kind,
153            span,
154            preceded_by_newline,
155        });
156    }
157
158    fn run(&mut self) {
159        loop {
160            self.skip_whitespace();
161            let start = self.pos;
162            let Some(c) = self.peek_char() else { break };
163
164            if c == '/' {
165                if let Some(kind) = self.handle_slash() {
166                    self.push_token(kind, start);
167                }
168                continue;
169            }
170
171            if c == '"' {
172                self.bump();
173                if let Some(kind) = self.lex_string(start) {
174                    self.push_token(kind, start);
175                }
176                continue;
177            }
178
179            if c == '\'' {
180                self.bump();
181                if let Some(kind) = self.lex_code_point(start) {
182                    self.push_token(kind, start);
183                }
184                continue;
185            }
186
187            if c.is_ascii_digit() {
188                let kind = self.lex_number(start);
189                self.push_token(kind, start);
190                continue;
191            }
192
193            if is_ident_start(c) {
194                let kind = self.lex_ident(start);
195                self.push_token(kind, start);
196                continue;
197            }
198
199            if let Some(kind) = self.lex_operator() {
200                self.push_token(kind, start);
201                continue;
202            }
203
204            if c == '@' {
205                self.bump();
206                self.reserved_annotation(start);
207                continue;
208            }
209
210            self.bump();
211            self.unexpected_character(c, start);
212        }
213
214        let eof = self.pos as u32;
215        self.push_token(TokenKind::Eof, eof as usize);
216    }
217
218    fn skip_whitespace(&mut self) {
219        while let Some(c) = self.peek_char() {
220            match c {
221                '\n' => self.pending_newline = true,
222                ' ' | '\t' | '\r' => {}
223                _ => return,
224            }
225            self.bump();
226        }
227    }
228
229    fn unexpected_character(&mut self, c: char, start: usize) {
230        let span = Span::new(self.file, start as u32, self.pos as u32);
231        let mut diag = Diagnostic::error(
232            "cove::lex::unexpected_character",
233            format!("unexpected character `{c}`"),
234        )
235        .at(span);
236        if c == ';' {
237            diag = diag
238                .rule("Cove statements are not terminated by `;`.")
239                .help("Remove the `;`; the next token or a newline ends the statement.");
240        }
241        self.diagnostics.push(diag);
242    }
243
244    /// `@` is reserved surface, not merely an unknown character: the
245    /// Language Card reserves decorator syntax for behavior with specified
246    /// compiler or runtime semantics, and the MVP defines none. This is
247    /// reported distinctly from [`Lexer::unexpected_character`] so the
248    /// message states that rule instead of reading as a stray-character typo.
249    fn reserved_annotation(&mut self, start: usize) {
250        let span = Span::new(self.file, start as u32, self.pos as u32);
251        self.diagnostics.push(
252            Diagnostic::error(
253                "cove::parse::reserved_annotation",
254                "`@` is reserved decorator syntax",
255            )
256            .at(span)
257            .rule(
258                "Decorator syntax is reserved for behavior with specified compiler or runtime \
259                 semantics; the MVP defines no annotations, so an unknown annotation is an error.",
260            )
261            .help("remove the `@...`; there is no annotation the MVP recognizes yet"),
262        );
263    }
264
265    fn find_line_end(&self) -> usize {
266        match self.text[self.pos..].find('\n') {
267            Some(i) => self.pos + i,
268            None => self.text.len(),
269        }
270    }
271
272    /// Handles everything that can start with `/`: line comments, doc
273    /// comments, block comments, `/=`, and plain `/`.
274    ///
275    /// Returns `Some(kind)` when a token should be produced, or `None` when a
276    /// comment was discarded.
277    fn handle_slash(&mut self) -> Option<TokenKind> {
278        let rest = &self.text[self.pos..];
279
280        if rest.starts_with("///") && !rest.starts_with("////") {
281            self.pos += 3;
282            let content_start = self.pos;
283            let line_end = self.find_line_end();
284            let content = &self.text[content_start..line_end];
285            let content = content.strip_prefix(' ').unwrap_or(content);
286            let text = content.trim_end().to_string();
287            self.pos = line_end;
288            return Some(TokenKind::DocComment(text));
289        }
290
291        if rest.starts_with("//") {
292            self.pos = self.find_line_end();
293            return None;
294        }
295
296        if rest.starts_with("/*") {
297            let start = self.pos;
298            self.pos += 2;
299            self.skip_block_comment(start);
300            if self.text[start..self.pos].contains('\n') {
301                self.pending_newline = true;
302            }
303            return None;
304        }
305
306        if rest.starts_with("/=") {
307            self.pos += 2;
308            return Some(TokenKind::SlashEq);
309        }
310
311        self.pos += 1;
312        Some(TokenKind::Slash)
313    }
314
315    /// Skips a `/* ... */` comment, `start` being the offset of its opening
316    /// `/`. Block comments nest. `self.pos` must already be past the opening
317    /// `/*`.
318    fn skip_block_comment(&mut self, start: usize) {
319        let mut depth = 1u32;
320        loop {
321            if self.text[self.pos..].starts_with("*/") {
322                self.pos += 2;
323                depth -= 1;
324                if depth == 0 {
325                    return;
326                }
327                continue;
328            }
329            if self.text[self.pos..].starts_with("/*") {
330                self.pos += 2;
331                depth += 1;
332                continue;
333            }
334            if self.bump().is_none() {
335                let span = Span::new(self.file, start as u32, self.pos as u32);
336                self.diagnostics.push(
337                    Diagnostic::error(
338                        "cove::lex::unterminated_block_comment",
339                        "block comment is never closed",
340                    )
341                    .at(span)
342                    .help("Add a matching `*/`."),
343                );
344                return;
345            }
346        }
347    }
348
349    fn lex_ident(&mut self, start: usize) -> TokenKind {
350        while let Some(c) = self.peek_char() {
351            if is_ident_continue(c) {
352                self.bump();
353            } else {
354                break;
355            }
356        }
357        let text = &self.text[start..self.pos];
358        match text {
359            "_" => TokenKind::Underscore,
360            "true" => TokenKind::Bool(true),
361            "false" => TokenKind::Bool(false),
362            _ => match Keyword::from_text(text) {
363                Some(keyword) => TokenKind::Keyword(keyword),
364                None => TokenKind::Ident(text.to_string()),
365            },
366        }
367    }
368
369    fn lex_number(&mut self, start: usize) -> TokenKind {
370        if self.text[start..].starts_with("0x") {
371            let digits_start = start + 2;
372            if matches!(self.text[digits_start..].chars().next(), Some(c) if c.is_ascii_hexdigit())
373            {
374                self.pos = digits_start;
375                return self.lex_radix_integer(start, 16, |c| c.is_ascii_hexdigit());
376            }
377        } else if self.text[start..].starts_with("0b") {
378            let digits_start = start + 2;
379            if matches!(self.text[digits_start..].chars().next(), Some('0' | '1')) {
380                self.pos = digits_start;
381                return self.lex_radix_integer(start, 2, |c| c == '0' || c == '1');
382            }
383        }
384        self.lex_decimal_number(start)
385    }
386
387    fn lex_radix_integer(
388        &mut self,
389        start: usize,
390        radix: u32,
391        pred: impl Fn(char) -> bool,
392    ) -> TokenKind {
393        let digits_start = self.pos;
394        while let Some(c) = self.peek_char() {
395            if pred(c) || c == '_' {
396                self.bump();
397            } else {
398                break;
399            }
400        }
401        let raw = self.text[digits_start..self.pos].replace('_', "");
402        self.finish_integer_or_duration(start, &raw, radix)
403    }
404
405    fn lex_decimal_number(&mut self, start: usize) -> TokenKind {
406        while let Some(c) = self.peek_char() {
407            if c.is_ascii_digit() || c == '_' {
408                self.bump();
409            } else {
410                break;
411            }
412        }
413
414        let mut is_float = false;
415
416        if self.peek_char() == Some('.')
417            && matches!(self.peek_char_at(1), Some(d) if d.is_ascii_digit())
418        {
419            is_float = true;
420            self.bump();
421            while let Some(c) = self.peek_char() {
422                if c.is_ascii_digit() || c == '_' {
423                    self.bump();
424                } else {
425                    break;
426                }
427            }
428        }
429
430        if matches!(self.peek_char(), Some('e' | 'E')) {
431            let mark = self.pos;
432            self.bump();
433            if matches!(self.peek_char(), Some('+' | '-')) {
434                self.bump();
435            }
436            if matches!(self.peek_char(), Some(d) if d.is_ascii_digit()) {
437                is_float = true;
438                while let Some(c) = self.peek_char() {
439                    if c.is_ascii_digit() || c == '_' {
440                        self.bump();
441                    } else {
442                        break;
443                    }
444                }
445            } else {
446                self.pos = mark;
447            }
448        }
449
450        if is_float {
451            let text = self.text[start..self.pos].replace('_', "");
452            let value: f64 = text
453                .parse()
454                .expect("lexer produced a malformed float literal");
455            TokenKind::Float(value)
456        } else {
457            let raw = self.text[start..self.pos].replace('_', "");
458            self.finish_integer_or_duration(start, &raw, 10)
459        }
460    }
461
462    /// Given the digits of an integer literal already scanned (`raw_digits`,
463    /// in `radix`), scans an optional duration-unit suffix and produces
464    /// either an `Int` or a `Duration` token.
465    fn finish_integer_or_duration(
466        &mut self,
467        start: usize,
468        raw_digits: &str,
469        radix: u32,
470    ) -> TokenKind {
471        let suffix_start = self.pos;
472        while let Some(c) = self.peek_char() {
473            if is_ident_continue(c) {
474                self.bump();
475            } else {
476                break;
477            }
478        }
479        let suffix = self.text[suffix_start..self.pos].to_string();
480        let span = Span::new(self.file, start as u32, self.pos as u32);
481
482        if suffix.is_empty() {
483            return match i64::from_str_radix(raw_digits, radix) {
484                Ok(value) => TokenKind::Int(value),
485                Err(_) => {
486                    let message = format!(
487                        "integer literal `{}` does not fit in a 64-bit integer",
488                        &self.text[start..self.pos]
489                    );
490                    self.diagnostics.push(
491                        Diagnostic::error("cove::lex::integer_out_of_range", message)
492                            .at(span)
493                            .help("Use a smaller value, or split the computation across multiple steps."),
494                    );
495                    TokenKind::Int(0)
496                }
497            };
498        }
499
500        if let Some(factor) = duration_factor(&suffix) {
501            let ns = i64::from_str_radix(raw_digits, radix)
502                .ok()
503                .and_then(|value| value.checked_mul(factor));
504            match ns {
505                Some(ns) => TokenKind::Duration(ns),
506                None => {
507                    let message = format!(
508                        "duration literal `{}` overflows a 64-bit nanosecond count",
509                        &self.text[start..self.pos]
510                    );
511                    self.diagnostics.push(
512                        Diagnostic::error("cove::lex::duration_out_of_range", message)
513                            .at(span)
514                            .help("Use a smaller value or a coarser unit."),
515                    );
516                    TokenKind::Duration(0)
517                }
518            }
519        } else {
520            let message = format!("`{suffix}` is not a valid literal suffix");
521            self.diagnostics.push(
522                Diagnostic::error("cove::lex::invalid_number_suffix", message)
523                    .at(span)
524                    .help("Valid duration suffixes are `ns`, `us`, `ms`, `s`, `m`, and `h`."),
525            );
526            TokenKind::Int(0)
527        }
528    }
529
530    /// Lexes a string literal, having already consumed its opening `"` at
531    /// `quote_start`. Returns `None` (after recording a diagnostic) if the
532    /// string or one of its interpolations is never closed.
533    fn lex_string(&mut self, quote_start: usize) -> Option<TokenKind> {
534        let mut parts = Vec::new();
535        let mut current = String::new();
536
537        loop {
538            match self.peek_char() {
539                None => {
540                    self.unterminated_string(quote_start);
541                    return None;
542                }
543                Some('"') => {
544                    self.bump();
545                    if !current.is_empty() {
546                        parts.push(StringPart::Text(current));
547                    }
548                    return Some(TokenKind::Str(parts));
549                }
550                Some('\\') => {
551                    let esc_start = self.pos;
552                    self.bump();
553                    match self.peek_char() {
554                        None => {
555                            self.unterminated_string(quote_start);
556                            return None;
557                        }
558                        Some(escaped) => {
559                            self.bump();
560                            match escaped_char(escaped) {
561                                Some(character) => current.push(character),
562                                None => self.unknown_escape(escaped, esc_start),
563                            }
564                        }
565                    }
566                }
567                Some('{') => {
568                    let brace_start = self.pos;
569                    self.bump();
570                    if !current.is_empty() {
571                        parts.push(StringPart::Text(std::mem::take(&mut current)));
572                    }
573                    let interp_start = self.pos;
574                    match self.skip_interpolation_body() {
575                        Ok(()) => {
576                            let interp_end = self.pos - 1;
577                            let source = self.text[interp_start..interp_end].to_string();
578                            let span = Span::new(self.file, interp_start as u32, interp_end as u32);
579                            parts.push(StringPart::Interpolation { source, span });
580                        }
581                        Err(()) => {
582                            let span = Span::new(self.file, brace_start as u32, self.pos as u32);
583                            self.diagnostics.push(
584                                Diagnostic::error(
585                                    "cove::lex::unterminated_interpolation",
586                                    "string interpolation is never closed",
587                                )
588                                .at(span)
589                                .help("Add a matching `}`."),
590                            );
591                            return None;
592                        }
593                    }
594                }
595                Some(c) => {
596                    self.bump();
597                    current.push(c);
598                }
599            }
600        }
601    }
602
603    /// A code-point literal: `'a'`, whose value is a Unicode scalar value and
604    /// whose type is `Int`.
605    ///
606    /// There is no `Char` type and this does not add one —
607    /// [ADR 0046](../../../docs/adr/0046-a-byte-offset-is-a-value-a-string-hands-out.md)
608    /// decided a code point is an `Int`, and this is a literal for one rather
609    /// than a type for one. It answers [`TokenKind::Int`], so nothing past
610    /// this function knows the form exists, which is the whole of what it
611    /// costs the rest of the compiler.
612    ///
613    /// A literal holding no scalar or more than one is reported and then
614    /// answers something anyway, so that one bad literal does not cascade
615    /// into every expression that reads it.
616    fn lex_code_point(&mut self, quote_start: usize) -> Option<TokenKind> {
617        let mut scalars = String::new();
618        loop {
619            match self.peek_char() {
620                None => {
621                    let span = Span::new(self.file, quote_start as u32, self.pos as u32);
622                    self.diagnostics.push(
623                        Diagnostic::error(
624                            "cove::lex::unterminated_code_point",
625                            "code-point literal is never closed",
626                        )
627                        .at(span)
628                        .help("Add a closing `'`."),
629                    );
630                    return None;
631                }
632                Some('\'') => {
633                    self.bump();
634                    let span = Span::new(self.file, quote_start as u32, self.pos as u32);
635                    let mut characters = scalars.chars();
636                    let Some(first) = characters.next() else {
637                        self.diagnostics.push(
638                            Diagnostic::error(
639                                "cove::lex::empty_code_point",
640                                "code-point literal names no character",
641                            )
642                            .at(span)
643                            .rule("A code-point literal holds exactly one Unicode scalar value.")
644                            .help("Write the character between the quotes, as `'a'`. The empty string is `\"\"`."),
645                        );
646                        return Some(TokenKind::Int(0));
647                    };
648                    if characters.next().is_some() {
649                        let count = scalars.chars().count();
650                        self.diagnostics.push(
651                            Diagnostic::error(
652                                "cove::lex::code_point_is_not_one_scalar",
653                                format!("code-point literal holds {count} scalar values, not one"),
654                            )
655                            .at(span)
656                            .rule(
657                                "A code-point literal holds exactly one Unicode scalar value, so a \
658                                 combining pair or an emoji sequence is more than one and is not a \
659                                 code point.",
660                            )
661                            .help("Write one scalar, or use a `\"...\"` string for text of any length."),
662                        );
663                    }
664                    return Some(TokenKind::Int(first as i64));
665                }
666                Some('\\') => {
667                    let esc_start = self.pos;
668                    self.bump();
669                    match self.peek_char() {
670                        None => {
671                            let span = Span::new(self.file, quote_start as u32, self.pos as u32);
672                            self.diagnostics.push(
673                                Diagnostic::error(
674                                    "cove::lex::unterminated_code_point",
675                                    "code-point literal is never closed",
676                                )
677                                .at(span)
678                                .help("Add a closing `'`."),
679                            );
680                            return None;
681                        }
682                        Some(escaped) => {
683                            self.bump();
684                            match escaped_char(escaped) {
685                                Some(character) => scalars.push(character),
686                                None => self.unknown_escape(escaped, esc_start),
687                            }
688                        }
689                    }
690                }
691                Some(c) => {
692                    self.bump();
693                    scalars.push(c);
694                }
695            }
696        }
697    }
698
699    /// Reports an escape neither literal form spells anything with.
700    fn unknown_escape(&mut self, escaped: char, esc_start: usize) {
701        let span = Span::new(self.file, esc_start as u32, self.pos as u32);
702        let message = format!("unknown escape sequence `\\{escaped}`");
703        self.diagnostics.push(
704            Diagnostic::error("cove::lex::unknown_escape", message)
705                .at(span)
706                .help(
707                    "Use one of the supported escapes: \\\\, \\\", \\', \\n, \\t, \\r, \\0, \\{, \\}.",
708                ),
709        );
710    }
711
712    fn unterminated_string(&mut self, quote_start: usize) {
713        let span = Span::new(self.file, quote_start as u32, self.pos as u32);
714        self.diagnostics.push(
715            Diagnostic::error(
716                "cove::lex::unterminated_string",
717                "string literal is never closed",
718            )
719            .at(span)
720            .help("Add a closing `\"`."),
721        );
722    }
723
724    /// Consumes source text up to and including the `}` matching the `{`
725    /// that was just consumed by the caller, stepping over any nested `{ }`
726    /// blocks and any nested string literals, so that a `}` inside a nested
727    /// string does not end the interpolation early.
728    ///
729    /// A string may hold an interpolation, an interpolation may hold a
730    /// string, and either may hold more of itself, so what is stepped over is
731    /// a nesting. It is held in a `Vec` rather than in the call stack on
732    /// purpose. This runs before the parser, and therefore before
733    /// [`crate::parser`]'s nesting limit can refuse anything, so a recursive
734    /// scan here would let a string literal of nothing but `{` end the
735    /// process — which is the failure that limit exists to prevent. What this
736    /// spends instead is one byte of heap per unclosed level, bounded by the
737    /// length of the file.
738    fn skip_interpolation_body(&mut self) -> Result<(), ()> {
739        let mut unclosed = vec![Unclosed::Brace];
740        while let Some(innermost) = unclosed.last() {
741            // A `'...'` is stepped over whole, the way a `"..."` is, and for
742            // one reason more: inside it a brace is a *character*. Without
743            // that, `"{ head == '\{' }"` ends the interpolation at a brace
744            // the program meant as text. A `{` inside a `"..."` is not the
745            // same case — a nested string may itself interpolate — which is
746            // why only the apostrophe suppresses it.
747            let inside_scalar = matches!(innermost, Unclosed::Apostrophe);
748            let escaping = matches!(innermost, Unclosed::Quote | Unclosed::Apostrophe);
749            match self.peek_char() {
750                None => return Err(()),
751                Some('\\') if escaping => {
752                    self.bump();
753                    if self.peek_char().is_none() {
754                        return Err(());
755                    }
756                    self.bump();
757                }
758                Some('"') if !inside_scalar => {
759                    self.bump();
760                    if matches!(innermost, Unclosed::Quote) {
761                        unclosed.pop();
762                    } else {
763                        unclosed.push(Unclosed::Quote);
764                    }
765                }
766                // An apostrophe inside a string is ordinary text, so only a
767                // brace's body opens one and only its own closes it.
768                Some('\'') if !matches!(innermost, Unclosed::Quote) => {
769                    self.bump();
770                    if inside_scalar {
771                        unclosed.pop();
772                    } else {
773                        unclosed.push(Unclosed::Apostrophe);
774                    }
775                }
776                Some('{') if !inside_scalar => {
777                    self.bump();
778                    unclosed.push(Unclosed::Brace);
779                }
780                Some('}') if matches!(innermost, Unclosed::Brace) => {
781                    self.bump();
782                    unclosed.pop();
783                }
784                Some(_) => {
785                    self.bump();
786                }
787            }
788        }
789        Ok(())
790    }
791
792    /// Matches punctuation and operators, longest match first. `/` and
793    /// everything that can start with it (comments, `/=`) is handled
794    /// separately by [`Lexer::handle_slash`].
795    fn lex_operator(&mut self) -> Option<TokenKind> {
796        let rest = &self.text[self.pos..];
797
798        macro_rules! op {
799            ($lit:literal, $kind:expr) => {
800                if rest.starts_with($lit) {
801                    self.pos += $lit.len();
802                    return Some($kind);
803                }
804            };
805        }
806
807        op!("...", TokenKind::Ellipsis);
808        op!("..<", TokenKind::DotDotLt);
809        op!("..", TokenKind::DotDot);
810        op!(".", TokenKind::Dot);
811        op!("->", TokenKind::Arrow);
812        op!("=>", TokenKind::FatArrow);
813        op!("==", TokenKind::EqEq);
814        op!("!=", TokenKind::BangEq);
815        op!("<=", TokenKind::LtEq);
816        op!(">=", TokenKind::GtEq);
817        op!("+=", TokenKind::PlusEq);
818        op!("-=", TokenKind::MinusEq);
819        op!("*=", TokenKind::StarEq);
820        op!("%=", TokenKind::PercentEq);
821        op!("&&", TokenKind::AmpAmp);
822        op!("||", TokenKind::PipePipe);
823        op!("=", TokenKind::Eq);
824        op!("!", TokenKind::Bang);
825        op!("<", TokenKind::Lt);
826        op!(">", TokenKind::Gt);
827        op!("+", TokenKind::Plus);
828        op!("-", TokenKind::Minus);
829        op!("*", TokenKind::Star);
830        op!("%", TokenKind::Percent);
831        op!("?", TokenKind::Question);
832        op!(",", TokenKind::Comma);
833        op!(":", TokenKind::Colon);
834        op!("(", TokenKind::LParen);
835        op!(")", TokenKind::RParen);
836        op!("{", TokenKind::LBrace);
837        op!("}", TokenKind::RBrace);
838        op!("[", TokenKind::LBracket);
839        op!("]", TokenKind::RBracket);
840
841        None
842    }
843}
844
845#[cfg(test)]
846mod tests {
847    use super::*;
848
849    fn lex_ok(src: &str) -> Vec<Token> {
850        let mut sources = SourceMap::new();
851        let file = sources.add("test.cove", src);
852        lex(&sources, file).unwrap_or_else(|diags| {
853            panic!("expected `{src}` to lex successfully, got errors: {diags:?}")
854        })
855    }
856
857    fn lex_err(src: &str) -> Vec<Diagnostic> {
858        let mut sources = SourceMap::new();
859        let file = sources.add("test.cove", src);
860        match lex(&sources, file) {
861            Ok(tokens) => panic!("expected `{src}` to fail to lex, got tokens: {tokens:?}"),
862            Err(diags) => diags,
863        }
864    }
865
866    fn kinds(src: &str) -> Vec<TokenKind> {
867        let tokens = lex_ok(src);
868        assert!(
869            matches!(tokens.last().unwrap().kind, TokenKind::Eof),
870            "token stream must end with Eof, got {tokens:?}"
871        );
872        tokens[..tokens.len() - 1]
873            .iter()
874            .map(|t| t.kind.clone())
875            .collect()
876    }
877
878    #[test]
879    fn empty_file_is_just_eof() {
880        let tokens = lex_ok("");
881        assert_eq!(tokens.len(), 1);
882        assert_eq!(tokens[0].kind, TokenKind::Eof);
883        assert_eq!(tokens[0].span.start, 0);
884        assert_eq!(tokens[0].span.end, 0);
885    }
886
887    #[test]
888    fn whitespace_produces_no_tokens() {
889        assert_eq!(kinds(" \t\r\n foo"), vec![TokenKind::Ident("foo".into())]);
890    }
891
892    /// A line break is recorded on the token that follows it rather than
893    /// becoming a token of its own, and comments do not hide it.
894    #[test]
895    fn tokens_record_a_preceding_line_break() {
896        let tokens = lex_ok("a b\nc /* x\ny */ d // e\nf");
897        let flags: Vec<bool> = tokens.iter().map(|t| t.preceded_by_newline).collect();
898        // a, b, c, d, f, Eof
899        assert_eq!(flags, vec![false, false, true, true, true, false]);
900    }
901
902    #[test]
903    fn a_block_comment_on_one_line_is_not_a_line_break() {
904        let tokens = lex_ok("a /* x */ b");
905        assert!(!tokens[1].preceded_by_newline);
906    }
907
908    #[test]
909    fn keywords_vs_identifiers() {
910        assert_eq!(
911            kinds("fn foo let bar self"),
912            vec![
913                TokenKind::Keyword(Keyword::Fn),
914                TokenKind::Ident("foo".into()),
915                TokenKind::Keyword(Keyword::Let),
916                TokenKind::Ident("bar".into()),
917                TokenKind::Keyword(Keyword::SelfValue),
918            ]
919        );
920        // Maximal munch: `forever` is one identifier, not `for` + `ever`.
921        assert_eq!(kinds("forever"), vec![TokenKind::Ident("forever".into())]);
922        assert_eq!(
923            kinds("true false"),
924            vec![TokenKind::Bool(true), TokenKind::Bool(false)]
925        );
926    }
927
928    #[test]
929    fn is_is_a_keyword_and_maximal_munch_still_applies() {
930        assert_eq!(
931            kinds("a is b"),
932            vec![
933                TokenKind::Ident("a".into()),
934                TokenKind::Keyword(Keyword::Is),
935                TokenKind::Ident("b".into()),
936            ]
937        );
938        // `island` is one identifier, not `is` + `land`.
939        assert_eq!(kinds("island"), vec![TokenKind::Ident("island".into())]);
940    }
941
942    #[test]
943    fn underscore_is_its_own_token() {
944        assert_eq!(kinds("_"), vec![TokenKind::Underscore]);
945        assert_eq!(kinds("_foo"), vec![TokenKind::Ident("_foo".into())]);
946        assert_eq!(kinds("foo_"), vec![TokenKind::Ident("foo_".into())]);
947    }
948
949    #[test]
950    fn doc_comments_strip_one_leading_space_and_trailing_whitespace() {
951        assert_eq!(
952            kinds("/// hello\n/// world"),
953            vec![
954                TokenKind::DocComment("hello".into()),
955                TokenKind::DocComment("world".into()),
956            ]
957        );
958        assert_eq!(
959            kinds("///no-space"),
960            vec![TokenKind::DocComment("no-space".into())]
961        );
962        assert_eq!(
963            kinds("///  two spaces"),
964            vec![TokenKind::DocComment(" two spaces".into())]
965        );
966        assert_eq!(
967            kinds("///trailing   \nfn"),
968            vec![
969                TokenKind::DocComment("trailing".into()),
970                TokenKind::Keyword(Keyword::Fn)
971            ]
972        );
973    }
974
975    #[test]
976    fn four_slashes_is_a_plain_comment_not_a_doc_comment() {
977        assert_eq!(kinds("//// not a doc\n42"), vec![TokenKind::Int(42)]);
978    }
979
980    #[test]
981    fn line_comments_are_discarded() {
982        assert_eq!(kinds("// hello\n42"), vec![TokenKind::Int(42)]);
983    }
984
985    #[test]
986    fn nested_block_comments_are_discarded() {
987        assert_eq!(
988            kinds("/* outer /* inner */ still outer */ 42"),
989            vec![TokenKind::Int(42)]
990        );
991    }
992
993    #[test]
994    fn unterminated_block_comment_is_an_error() {
995        let diags = lex_err("/* never closed");
996        assert_eq!(diags.len(), 1);
997        assert_eq!(diags[0].code, "cove::lex::unterminated_block_comment");
998    }
999
1000    #[test]
1001    fn decimal_integers_and_underscores() {
1002        assert_eq!(kinds("123"), vec![TokenKind::Int(123)]);
1003        assert_eq!(kinds("1_000"), vec![TokenKind::Int(1000)]);
1004        assert_eq!(kinds("0"), vec![TokenKind::Int(0)]);
1005    }
1006
1007    #[test]
1008    fn hex_and_binary_integers() {
1009        assert_eq!(kinds("0xFF"), vec![TokenKind::Int(255)]);
1010        assert_eq!(kinds("0b1010"), vec![TokenKind::Int(10)]);
1011    }
1012
1013    #[test]
1014    fn floats() {
1015        assert_eq!(kinds("1.5"), vec![TokenKind::Float(1.5)]);
1016        assert_eq!(kinds("1.5e10"), vec![TokenKind::Float(1.5e10)]);
1017        assert_eq!(kinds("1e-3"), vec![TokenKind::Float(1e-3)]);
1018    }
1019
1020    #[test]
1021    fn range_dot_is_only_part_of_a_float_before_a_digit() {
1022        assert_eq!(
1023            kinds("0..<10"),
1024            vec![TokenKind::Int(0), TokenKind::DotDotLt, TokenKind::Int(10)]
1025        );
1026        assert_eq!(
1027            kinds("0..n"),
1028            vec![
1029                TokenKind::Int(0),
1030                TokenKind::DotDot,
1031                TokenKind::Ident("n".into())
1032            ]
1033        );
1034    }
1035
1036    #[test]
1037    fn every_duration_unit() {
1038        assert_eq!(kinds("1ns"), vec![TokenKind::Duration(1)]);
1039        assert_eq!(kinds("1us"), vec![TokenKind::Duration(1_000)]);
1040        assert_eq!(kinds("1ms"), vec![TokenKind::Duration(1_000_000)]);
1041        assert_eq!(kinds("1s"), vec![TokenKind::Duration(1_000_000_000)]);
1042        assert_eq!(kinds("1m"), vec![TokenKind::Duration(60_000_000_000)]);
1043        assert_eq!(kinds("1h"), vec![TokenKind::Duration(3_600_000_000_000)]);
1044        assert_eq!(kinds("500ms"), vec![TokenKind::Duration(500_000_000)]);
1045        assert_eq!(kinds("60s"), vec![TokenKind::Duration(60_000_000_000)]);
1046        assert_eq!(kinds("5s"), vec![TokenKind::Duration(5_000_000_000)]);
1047    }
1048
1049    #[test]
1050    fn integer_out_of_range_is_an_error() {
1051        let diags = lex_err("99999999999999999999");
1052        assert_eq!(diags.len(), 1);
1053        assert_eq!(diags[0].code, "cove::lex::integer_out_of_range");
1054    }
1055
1056    #[test]
1057    fn duration_out_of_range_is_an_error() {
1058        let diags = lex_err("9999999999h");
1059        assert_eq!(diags.len(), 1);
1060        assert_eq!(diags[0].code, "cove::lex::duration_out_of_range");
1061    }
1062
1063    #[test]
1064    fn invalid_number_suffix_is_an_error() {
1065        let diags = lex_err("5sx");
1066        assert_eq!(diags.len(), 1);
1067        assert_eq!(diags[0].code, "cove::lex::invalid_number_suffix");
1068    }
1069
1070    #[test]
1071    fn string_escapes() {
1072        let tokens = kinds(r#""\\ \" \n \t \r \0 \{ \}""#);
1073        assert_eq!(
1074            tokens,
1075            vec![TokenKind::Str(vec![StringPart::Text(
1076                "\\ \" \n \t \r \0 { }".into()
1077            )])]
1078        );
1079    }
1080
1081    /// A code-point literal is an `Int` token and nothing else.
1082    ///
1083    /// There is no `Char`, so what the lexer answers here is the same token
1084    /// `97` answers, and every test below is written against that fact
1085    /// rather than against a form the rest of the compiler would have to
1086    /// know about.
1087    #[test]
1088    fn code_point_literals_are_int_tokens() {
1089        assert_eq!(kinds("'a'"), vec![TokenKind::Int(97)]);
1090        assert_eq!(kinds("'0'"), vec![TokenKind::Int(48)]);
1091        assert_eq!(kinds("' '"), vec![TokenKind::Int(32)]);
1092        assert_eq!(kinds("'{'"), vec![TokenKind::Int(123)]);
1093        assert_eq!(kinds("'\u{e9}'"), vec![TokenKind::Int(233)]);
1094        assert_eq!(kinds("'\u{3042}'"), vec![TokenKind::Int(12354)]);
1095        assert_eq!(kinds("'\u{1F600}'"), vec![TokenKind::Int(128512)]);
1096    }
1097
1098    /// The escapes are the string's, and `\'` besides.
1099    #[test]
1100    fn code_point_literals_take_the_escapes_a_string_takes() {
1101        assert_eq!(kinds(r"'\n'"), vec![TokenKind::Int(10)]);
1102        assert_eq!(kinds(r"'\t'"), vec![TokenKind::Int(9)]);
1103        assert_eq!(kinds(r"'\r'"), vec![TokenKind::Int(13)]);
1104        assert_eq!(kinds(r"'\0'"), vec![TokenKind::Int(0)]);
1105        assert_eq!(kinds(r"'\\'"), vec![TokenKind::Int(92)]);
1106        assert_eq!(kinds(r"'\''"), vec![TokenKind::Int(39)]);
1107        assert_eq!(kinds(r#"'\"'"#), vec![TokenKind::Int(34)]);
1108        assert_eq!(kinds(r"'\{'"), vec![TokenKind::Int(123)]);
1109        assert_eq!(kinds(r"'\}'"), vec![TokenKind::Int(125)]);
1110    }
1111
1112    /// `\'` is legal in a string too, because there is one escape table and
1113    /// the apostrophe is in it. It spells the apostrophe it always did.
1114    #[test]
1115    fn a_string_may_escape_an_apostrophe() {
1116        assert_eq!(
1117            kinds(r#""\'""#),
1118            vec![TokenKind::Str(vec![StringPart::Text("'".to_string())])]
1119        );
1120    }
1121
1122    #[test]
1123    fn an_empty_code_point_literal_is_an_error() {
1124        let diags = lex_err("''");
1125        assert_eq!(diags.len(), 1);
1126        assert_eq!(diags[0].code, "cove::lex::empty_code_point");
1127    }
1128
1129    /// Two scalars is an error, and so is a grapheme cluster that is written
1130    /// as one character and is not one scalar — which is the case the rule
1131    /// exists for.
1132    #[test]
1133    fn a_code_point_literal_holding_more_than_one_scalar_is_an_error() {
1134        let diags = lex_err("'ab'");
1135        assert_eq!(diags.len(), 1);
1136        assert_eq!(diags[0].code, "cove::lex::code_point_is_not_one_scalar");
1137        assert_eq!(
1138            diags[0].message,
1139            "code-point literal holds 2 scalar values, not one"
1140        );
1141
1142        let diags = lex_err("'\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F466}'");
1143        assert_eq!(diags.len(), 1);
1144        assert_eq!(
1145            diags[0].message,
1146            "code-point literal holds 5 scalar values, not one"
1147        );
1148    }
1149
1150    #[test]
1151    fn an_unterminated_code_point_literal_is_an_error() {
1152        let diags = lex_err("'a");
1153        assert_eq!(diags.len(), 1);
1154        assert_eq!(diags[0].code, "cove::lex::unterminated_code_point");
1155    }
1156
1157    /// An interpolation is scanned for its closing brace before it is parsed,
1158    /// and a brace inside a code-point literal is a character rather than a
1159    /// nesting. Without that, this string ends at the wrong place.
1160    #[test]
1161    fn a_code_point_literal_inside_an_interpolation_may_hold_a_brace() {
1162        let tokens = kinds(r#""{ head == '{' }""#);
1163        assert_eq!(tokens.len(), 1);
1164        let TokenKind::Str(parts) = &tokens[0] else {
1165            panic!("expected a string token, got {tokens:?}");
1166        };
1167        assert_eq!(parts.len(), 1);
1168        let StringPart::Interpolation { source, .. } = &parts[0] else {
1169            panic!("expected one interpolation, got {parts:?}");
1170        };
1171        assert_eq!(source.trim(), "head == '{'");
1172    }
1173
1174    #[test]
1175    fn unknown_escape_is_an_error() {
1176        let diags = lex_err(r#""\q""#);
1177        assert_eq!(diags.len(), 1);
1178        assert_eq!(diags[0].code, "cove::lex::unknown_escape");
1179    }
1180
1181    #[test]
1182    fn unterminated_string_is_an_error() {
1183        let diags = lex_err("\"never closed");
1184        assert_eq!(diags.len(), 1);
1185        assert_eq!(diags[0].code, "cove::lex::unterminated_string");
1186    }
1187
1188    #[test]
1189    fn simple_interpolation() {
1190        let tokens = kinds(r#""a{b}c""#);
1191        assert_eq!(
1192            tokens,
1193            vec![TokenKind::Str(vec![
1194                StringPart::Text("a".into()),
1195                StringPart::Interpolation {
1196                    source: "b".into(),
1197                    span: Span::new(FileId(0), 3, 4),
1198                },
1199                StringPart::Text("c".into()),
1200            ])]
1201        );
1202    }
1203
1204    #[test]
1205    fn interpolation_with_nested_braces_and_nested_strings() {
1206        // `"{f("}")}"` must lex `f("}")` as the interpolation source: the
1207        // brace inside the nested string must not end the interpolation
1208        // early, and the nested string's own quotes must not be confused
1209        // with the outer string's.
1210        let tokens = kinds("\"{f(\"}\")}\"");
1211        assert_eq!(
1212            tokens,
1213            vec![TokenKind::Str(vec![StringPart::Interpolation {
1214                source: "f(\"}\")".into(),
1215                span: Span::new(FileId(0), 2, 8),
1216            }])]
1217        );
1218    }
1219
1220    #[test]
1221    fn multiple_interpolations_in_one_string() {
1222        let tokens = kinds(r#""{a}-{b}""#);
1223        assert_eq!(
1224            tokens,
1225            vec![TokenKind::Str(vec![
1226                StringPart::Interpolation {
1227                    source: "a".into(),
1228                    span: Span::new(FileId(0), 2, 3),
1229                },
1230                StringPart::Text("-".into()),
1231                StringPart::Interpolation {
1232                    source: "b".into(),
1233                    span: Span::new(FileId(0), 6, 7),
1234                },
1235            ])]
1236        );
1237    }
1238
1239    #[test]
1240    fn unterminated_interpolation_is_an_error() {
1241        let diags = lex_err(r#""{a"#);
1242        assert_eq!(diags.len(), 1);
1243        assert_eq!(diags[0].code, "cove::lex::unterminated_interpolation");
1244    }
1245
1246    #[test]
1247    fn longest_match_operators() {
1248        assert_eq!(
1249            kinds("... ..< .. ."),
1250            vec![
1251                TokenKind::Ellipsis,
1252                TokenKind::DotDotLt,
1253                TokenKind::DotDot,
1254                TokenKind::Dot,
1255            ]
1256        );
1257        assert_eq!(
1258            kinds("-> => == != <= >= += -= *= /= %= && ||"),
1259            vec![
1260                TokenKind::Arrow,
1261                TokenKind::FatArrow,
1262                TokenKind::EqEq,
1263                TokenKind::BangEq,
1264                TokenKind::LtEq,
1265                TokenKind::GtEq,
1266                TokenKind::PlusEq,
1267                TokenKind::MinusEq,
1268                TokenKind::StarEq,
1269                TokenKind::SlashEq,
1270                TokenKind::PercentEq,
1271                TokenKind::AmpAmp,
1272                TokenKind::PipePipe,
1273            ]
1274        );
1275        assert_eq!(
1276            kinds("= ! < > + - * / % ? , : ( ) { } [ ]"),
1277            vec![
1278                TokenKind::Eq,
1279                TokenKind::Bang,
1280                TokenKind::Lt,
1281                TokenKind::Gt,
1282                TokenKind::Plus,
1283                TokenKind::Minus,
1284                TokenKind::Star,
1285                TokenKind::Slash,
1286                TokenKind::Percent,
1287                TokenKind::Question,
1288                TokenKind::Comma,
1289                TokenKind::Colon,
1290                TokenKind::LParen,
1291                TokenKind::RParen,
1292                TokenKind::LBrace,
1293                TokenKind::RBrace,
1294                TokenKind::LBracket,
1295                TokenKind::RBracket,
1296            ]
1297        );
1298        // No space: greedy longest match still applies.
1299        assert_eq!(kinds("<=="), vec![TokenKind::LtEq, TokenKind::Eq]);
1300    }
1301
1302    #[test]
1303    fn semicolon_is_an_error_with_the_cove_rule() {
1304        let diags = lex_err(";");
1305        assert_eq!(diags.len(), 1);
1306        assert_eq!(diags[0].code, "cove::lex::unexpected_character");
1307        assert!(diags[0].rule.as_deref().unwrap().contains(';'));
1308    }
1309
1310    #[test]
1311    fn unexpected_character_is_an_error() {
1312        let diags = lex_err("`");
1313        assert_eq!(diags.len(), 1);
1314        assert_eq!(diags[0].code, "cove::lex::unexpected_character");
1315    }
1316
1317    #[test]
1318    fn at_sign_is_reserved_decorator_syntax() {
1319        let diags = lex_err("@decorate");
1320        assert_eq!(diags.len(), 1);
1321        assert_eq!(diags[0].code, "cove::parse::reserved_annotation");
1322        assert!(diags[0].rule.as_deref().unwrap().contains("Decorator"));
1323    }
1324
1325    #[test]
1326    fn all_errors_in_a_file_are_collected() {
1327        let diags = lex_err("` ~ #");
1328        assert_eq!(diags.len(), 3);
1329        for diag in &diags {
1330            assert_eq!(diag.code, "cove::lex::unexpected_character");
1331        }
1332    }
1333
1334    /// The half of the lexer's work that [`lex`] discards. A highlighter
1335    /// reads it, and the case it reads it for is this one: a string that is
1336    /// open because the reader has not typed the closing quote yet.
1337    #[test]
1338    fn recovery_answers_the_tokens_before_an_error_as_well_as_the_error() {
1339        let mut sources = SourceMap::new();
1340        let file = sources.add("test.cove", "let n = 1\nlet greeting = \"open");
1341        let (tokens, diagnostics) = lex_recovered(&sources, file);
1342
1343        assert_eq!(diagnostics.len(), 1);
1344        assert_eq!(diagnostics[0].code, "cove::lex::unterminated_string");
1345        assert!(
1346            lex(&sources, file).is_err(),
1347            "`lex` still refuses what it always refused"
1348        );
1349
1350        let kinds: Vec<TokenKind> = tokens.iter().map(|t| t.kind.clone()).collect();
1351        assert_eq!(
1352            kinds,
1353            vec![
1354                TokenKind::Keyword(Keyword::Let),
1355                TokenKind::Ident("n".into()),
1356                TokenKind::Eq,
1357                TokenKind::Int(1),
1358                TokenKind::Keyword(Keyword::Let),
1359                TokenKind::Ident("greeting".into()),
1360                TokenKind::Eq,
1361                TokenKind::Eof,
1362            ],
1363            "everything up to the open quote, and nothing invented for it"
1364        );
1365    }
1366
1367    /// What [`lex`] answers and what [`lex_recovered`] answers are the same
1368    /// tokens whenever there is nothing to complain about, which is what lets
1369    /// one be written in terms of the other.
1370    #[test]
1371    fn recovery_and_lex_agree_when_there_is_no_error() {
1372        let source = "export fn main() -> Int { 1 + 2 }";
1373        let mut sources = SourceMap::new();
1374        let file = sources.add("test.cove", source);
1375        let (tokens, diagnostics) = lex_recovered(&sources, file);
1376        assert!(diagnostics.is_empty());
1377        assert_eq!(tokens, lex(&sources, file).expect("it lexes"));
1378    }
1379
1380    #[test]
1381    fn duration_example_program_lexes() {
1382        let tokens =
1383            kinds("clock.timeout(500ms) { retry(3, attempts) }\nfor attempt in 0..<attempts {}");
1384        assert!(tokens.contains(&TokenKind::Duration(500_000_000)));
1385        assert!(tokens.contains(&TokenKind::DotDotLt));
1386        assert!(tokens.contains(&TokenKind::Keyword(Keyword::For)));
1387    }
1388}