Skip to main content

nova_lexer/
lib.rs

1//! Lexer for the Nova programming language.
2//!
3//! Converts Nova source text into a `Vec<Spanned<Token>>`. Uses `logos` for
4//! the core token recognition and a hand-written state machine wrapper for
5//! string interpolation (`"Hello, ${name}!"`).
6//!
7//! An interpolation hole is lexed *inline* — its tokens go straight into the
8//! one stream, bracketed by `InterpOpen`/`InterpClose` — rather than being
9//! captured as a span and re-lexed. The hole therefore ends at the `}` that
10//! matches its `${`, which the lexer tracks with a per-hole brace counter so
11//! that `"${f(R { v: 1 })}"` works.
12//!
13//! # Usage
14//!
15//! ```
16//! use nova_diagnostics::{FileDb, FileId};
17//! use nova_lexer::lex;
18//!
19//! let mut db = FileDb::new();
20//! let file = db.add("<test>", r#"fn main() { println("hi") }"#);
21//! let (tokens, errors) = lex(r#"fn main() { println("hi") }"#, file);
22//! assert!(errors.is_empty());
23//! ```
24
25mod error;
26mod token;
27
28pub use error::LexError;
29pub use token::Token;
30
31/// Every keyword, in the order the lexer declares its tokens: the words
32/// completion offers, and the extension's grammar colours (spec
33/// `docs/superpowers/specs/2026-10-08-phase-3-2-lsp-core-design.md` §4.3,
34/// §7.4). `tests/keywords.rs` keeps it equal to the lexer's alphabetic
35/// tokens.
36pub const KEYWORDS: [&str; 32] = [
37    "let", "mut", "const", "fn", "return", "if", "else", "while", "for", "in", "break", "continue",
38    "match", "type", "record", "trait", "impl", "import", "module", "pub", "async", "await",
39    "extern", "unsafe", "true", "false", "as", "is", "where", "with", "self", "Self",
40];
41
42use nova_diagnostics::{FileId, Span, Spanned};
43
44/// Lex `source` and return all tokens and any errors encountered.
45///
46/// Errors are non-fatal: the lexer continues after a bad character and collects
47/// all errors in the returned `Vec`.
48pub fn lex(source: &str, file: FileId) -> (Vec<Spanned<Token>>, Vec<LexError>) {
49    let mut lexer = Lexer::new(source, file);
50    lexer.tokenize()
51}
52
53/// [`lex`], plus every comment it skipped, in source order: what the
54/// formatter needs to keep them (spec
55/// `docs/superpowers/specs/2026-10-07-phase-3-1-formatter-design.md` §3.1).
56/// The tokens and errors are exactly `lex`'s.
57pub fn lex_with_comments(
58    source: &str,
59    file: FileId,
60) -> (Vec<Spanned<Token>>, Vec<Comment>, Vec<LexError>) {
61    let mut lexer = Lexer::new(source, file);
62    let (tokens, errors) = lexer.tokenize();
63    (tokens, lexer.comments, errors)
64}
65
66/// A comment the lexer skipped.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub struct Comment {
69    pub kind: CommentKind,
70    /// From `//` to the end of its line, without the line break or a `\r`
71    /// before it; or from `/*` through `*/`.
72    pub span: Span,
73}
74
75/// Which kind of comment.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub enum CommentKind {
78    /// `// …`, including `//// …`.
79    Line,
80    /// `/* … */`, which does not nest.
81    Block,
82}
83
84/// One open `${…}` interpolation hole.
85struct InterpFrame {
86    /// Byte offset of the `$` in the `${` that opened this hole, for the
87    /// unterminated-hole diagnostic.
88    open: usize,
89    /// How many `{` opened *inside* this hole are still unmatched. A `}` only
90    /// ends the hole once this reaches zero.
91    braces: usize,
92}
93
94/// Stateful lexer that wraps the `logos`-generated token recognizer and adds
95/// string-interpolation tracking.
96pub struct Lexer<'src> {
97    source: &'src str,
98    file: FileId,
99    // Current byte position in source.
100    pos: usize,
101    // Open `${...}` holes, innermost last. Each frame counts its own unmatched
102    // `{` so a hole ends at the `}` matching its `${`, not at the first `}`.
103    interp: Vec<InterpFrame>,
104    // Whether we are currently inside a string literal (between delimiters).
105    in_string: bool,
106    // Every comment skipped so far, in source order.
107    comments: Vec<Comment>,
108}
109
110impl<'src> Lexer<'src> {
111    pub fn new(source: &'src str, file: FileId) -> Self {
112        Self {
113            source,
114            file,
115            pos: 0,
116            interp: Vec::new(),
117            in_string: false,
118            comments: Vec::new(),
119        }
120    }
121
122    pub fn tokenize(&mut self) -> (Vec<Spanned<Token>>, Vec<LexError>) {
123        let mut tokens = Vec::new();
124        let mut errors = Vec::new();
125
126        while self.pos < self.source.len() {
127            match self.next_token() {
128                Ok(Some(tok)) => tokens.push(tok),
129                Ok(None) => {}
130                Err(e) => errors.push(e),
131            }
132        }
133
134        // A hole still open at EOF never found its `}`. Report the innermost
135        // one — the first the author has to close — rather than letting the
136        // parser guess at a stream that just stops mid-expression.
137        if let Some(frame) = self.interp.last() {
138            let end = (frame.open + 2).min(self.source.len());
139            errors.push(LexError::UnterminatedInterpolation(
140                self.span(frame.open, end),
141            ));
142        }
143
144        tokens.push(Spanned::new(
145            Token::Eof,
146            Span::point(self.source.len() as u32, self.file),
147        ));
148
149        (tokens, errors)
150    }
151
152    fn span(&self, start: usize, end: usize) -> Span {
153        Span::new(start as u32, end as u32, self.file)
154    }
155
156    /// Advance past whitespace, `//` line comments, and `/* */` block
157    /// comments, recording each comment. A `///` doc comment, exactly three
158    /// slashes, is *not* trivia: it is left for the tokenizer to emit as a
159    /// `DocComment`. Four or more slashes make a plain comment, as in Rust. A
160    /// `/*` with no `*/` before the end of the file is an error (10-LEXER.md
161    /// §6).
162    fn skip_trivia(&mut self) -> Result<(), LexError> {
163        let b = self.source.as_bytes();
164        loop {
165            while self.pos < b.len() {
166                match b[self.pos] {
167                    b' ' | b'\t' | b'\n' | b'\r' => self.pos += 1,
168                    _ => break,
169                }
170            }
171            if self.pos + 1 < b.len() && b[self.pos] == b'/' && b[self.pos + 1] == b'/' {
172                let doc = b.get(self.pos + 2) == Some(&b'/') && b.get(self.pos + 3) != Some(&b'/');
173                if doc {
174                    break;
175                }
176                let start = self.pos;
177                while self.pos < b.len() && b[self.pos] != b'\n' {
178                    self.pos += 1;
179                }
180                let end = if self.pos > start && b[self.pos - 1] == b'\r' {
181                    self.pos - 1
182                } else {
183                    self.pos
184                };
185                self.comments.push(Comment {
186                    kind: CommentKind::Line,
187                    span: self.span(start, end),
188                });
189                continue;
190            }
191            if self.pos + 1 < b.len() && b[self.pos] == b'/' && b[self.pos + 1] == b'*' {
192                let start = self.pos;
193                self.pos += 2;
194                while self.pos + 1 < b.len() && !(b[self.pos] == b'*' && b[self.pos + 1] == b'/') {
195                    self.pos += 1;
196                }
197                if self.pos + 1 >= b.len() {
198                    self.pos = b.len();
199                    return Err(LexError::UnterminatedBlockComment(
200                        self.span(start, b.len()),
201                    ));
202                }
203                self.pos += 2;
204                self.comments.push(Comment {
205                    kind: CommentKind::Block,
206                    span: self.span(start, self.pos),
207                });
208                continue;
209            }
210            break;
211        }
212        Ok(())
213    }
214
215    fn next_token(&mut self) -> Result<Option<Spanned<Token>>, LexError> {
216        // Inside a string (including resuming after `${expr}`) whitespace is
217        // significant — dispatch to string-content lexing before any skipping.
218        if self.in_string {
219            if self.pos >= self.source.len() {
220                return Ok(None);
221            }
222            return self.lex_string_content();
223        }
224
225        // Skip whitespace and comments together. Comments must be stripped
226        // here (not only inside `lex_logos`) because string and raw-string
227        // literals are detected before `lex_logos`; a comment sitting right
228        // before a `"`/`r"` would otherwise misdirect the dispatch.
229        self.skip_trivia()?;
230
231        if self.pos >= self.source.len() {
232            return Ok(None);
233        }
234
235        // String literal handling
236        if self.source.as_bytes()[self.pos] == b'"' {
237            return self.lex_string_start();
238        }
239
240        // Check for raw strings
241        if self.pos + 1 < self.source.len()
242            && self.source.as_bytes()[self.pos] == b'r'
243            && (self.source.as_bytes()[self.pos + 1] == b'"'
244                || self.source.as_bytes()[self.pos + 1] == b'#')
245        {
246            return self.lex_raw_string();
247        }
248
249        // Use the logos-generated lexer for everything else
250        self.lex_logos()
251    }
252
253    fn lex_string_start(&mut self) -> Result<Option<Spanned<Token>>, LexError> {
254        let start = self.pos;
255        self.pos += 1; // skip opening `"`
256        self.in_string = true;
257        // Emit StrStart, then lex content on next call
258        Ok(Some(Spanned::new(
259            Token::StrStart,
260            self.span(start, self.pos),
261        )))
262    }
263
264    fn lex_string_content(&mut self) -> Result<Option<Spanned<Token>>, LexError> {
265        let src = self.source.as_bytes();
266        let start = self.pos;
267
268        // Check for end of string
269        if src[self.pos] == b'"' {
270            self.pos += 1;
271            self.in_string = false;
272            return Ok(Some(Spanned::new(
273                Token::StrEnd,
274                self.span(start, self.pos),
275            )));
276        }
277
278        // Check for interpolation open `${`
279        if self.pos + 1 < src.len() && src[self.pos] == b'$' && src[self.pos + 1] == b'{' {
280            self.pos += 2;
281            self.in_string = false; // switch to expression mode
282            self.interp.push(InterpFrame {
283                open: start,
284                braces: 0,
285            });
286            return Ok(Some(Spanned::new(
287                Token::InterpOpen,
288                self.span(start, self.pos),
289            )));
290        }
291
292        // Collect a string segment
293        let mut buf = String::new();
294        while self.pos < src.len() && src[self.pos] != b'"' {
295            // Check for interpolation
296            if self.pos + 1 < src.len() && src[self.pos] == b'$' && src[self.pos + 1] == b'{' {
297                break;
298            }
299            // Handle escape sequences
300            if src[self.pos] == b'\\' {
301                self.pos += 1;
302                if self.pos >= src.len() {
303                    return Err(LexError::UnterminatedString(self.span(start, self.pos)));
304                }
305                match src[self.pos] {
306                    b'n' => {
307                        buf.push('\n');
308                        self.pos += 1;
309                    }
310                    b't' => {
311                        buf.push('\t');
312                        self.pos += 1;
313                    }
314                    b'r' => {
315                        buf.push('\r');
316                        self.pos += 1;
317                    }
318                    b'"' => {
319                        buf.push('"');
320                        self.pos += 1;
321                    }
322                    b'\\' => {
323                        buf.push('\\');
324                        self.pos += 1;
325                    }
326                    b'0' => {
327                        buf.push('\0');
328                        self.pos += 1;
329                    }
330                    b'u' => {
331                        self.pos += 1;
332                        if self.pos < src.len() && src[self.pos] == b'{' {
333                            self.pos += 1;
334                            let hex_start = self.pos;
335                            while self.pos < src.len() && src[self.pos] != b'}' {
336                                self.pos += 1;
337                            }
338                            let hex = &self.source[hex_start..self.pos];
339                            if self.pos < src.len() {
340                                self.pos += 1;
341                            } // skip `}`
342                            match u32::from_str_radix(hex, 16).ok().and_then(char::from_u32) {
343                                Some(c) => buf.push(c),
344                                None => {
345                                    return Err(LexError::InvalidUnicodeEscape(
346                                        self.span(start, self.pos),
347                                    ))
348                                }
349                            }
350                        } else {
351                            return Err(LexError::InvalidEscapeSequence(
352                                'u',
353                                self.span(start, self.pos),
354                            ));
355                        }
356                    }
357                    c => {
358                        return Err(LexError::InvalidEscapeSequence(
359                            c as char,
360                            self.span(start, self.pos),
361                        ));
362                    }
363                }
364            } else {
365                // Safety: valid UTF-8 since source is &str
366                let ch_len = utf8_char_width(src[self.pos]);
367                let slice = &self.source[self.pos..self.pos + ch_len];
368                buf.push_str(slice);
369                self.pos += ch_len;
370            }
371        }
372
373        if self.pos >= src.len() && !buf.is_empty() {
374            return Err(LexError::UnterminatedString(self.span(start, self.pos)));
375        }
376
377        let end = self.pos;
378        Ok(Some(Spanned::new(
379            Token::StrPart(buf),
380            self.span(start, end),
381        )))
382    }
383
384    fn lex_raw_string(&mut self) -> Result<Option<Spanned<Token>>, LexError> {
385        let start = self.pos;
386        self.pos += 1; // skip `r`
387        let mut hash_count = 0usize;
388        let src = self.source.as_bytes();
389
390        while self.pos < src.len() && src[self.pos] == b'#' {
391            hash_count += 1;
392            self.pos += 1;
393        }
394
395        if self.pos >= src.len() || src[self.pos] != b'"' {
396            return Err(LexError::InvalidNumberLiteral(self.span(start, self.pos)));
397        }
398        self.pos += 1; // skip `"`
399
400        let mut content = String::new();
401        loop {
402            if self.pos >= src.len() {
403                return Err(LexError::UnterminatedString(self.span(start, self.pos)));
404            }
405            if src[self.pos] == b'"' {
406                // Check for matching `"###...`
407                let mut end_hashes = 0;
408                let mut look = self.pos + 1;
409                while look < src.len() && src[look] == b'#' && end_hashes < hash_count {
410                    end_hashes += 1;
411                    look += 1;
412                }
413                if end_hashes == hash_count {
414                    self.pos = look;
415                    break;
416                }
417            }
418            let ch_len = utf8_char_width(src[self.pos]);
419            content.push_str(&self.source[self.pos..self.pos + ch_len]);
420            self.pos += ch_len;
421        }
422
423        Ok(Some(Spanned::new(
424            Token::RawStr(content),
425            self.span(start, self.pos),
426        )))
427    }
428
429    /// Decide what a `}` means. It closes the innermost `${…}` hole only when
430    /// that hole has no unmatched `{` of its own; otherwise it is the ordinary
431    /// `RBrace` closing one of those (a record literal, a block, …).
432    ///
433    /// `}` inside a nested string/raw string/char literal never reaches here:
434    /// those are recognised before `lex_logos` runs, so their braces are text.
435    fn maybe_close_interp(&mut self, start: usize) -> Spanned<Token> {
436        let span = self.span(start, self.pos);
437        match self.interp.last_mut() {
438            Some(frame) if frame.braces > 0 => {
439                frame.braces -= 1;
440                Spanned::new(Token::RBrace, span)
441            }
442            Some(_) => {
443                self.interp.pop();
444                self.in_string = true;
445                Spanned::new(Token::InterpClose, span)
446            }
447            None => Spanned::new(Token::RBrace, span),
448        }
449    }
450
451    fn lex_logos(&mut self) -> Result<Option<Spanned<Token>>, LexError> {
452        use logos::Logos;
453        // We run logos on the remaining slice.
454        let remaining = &self.source[self.pos..];
455        let mut lex = RawToken::lexer(remaining);
456
457        match lex.next() {
458            None => {
459                self.pos = self.source.len();
460                Ok(None)
461            }
462            Some(Ok(raw)) => {
463                // `lex.span()` is relative to `remaining` and excludes any
464                // trivia logos skipped (comments, whitespace), so the token
465                // may not start at `self.pos`. Account for the skipped
466                // prefix, otherwise `pos` drifts into the comment text.
467                let tok_span = lex.span();
468                let tok_start = self.pos + tok_span.start;
469                let tok_end = self.pos + tok_span.end;
470                self.pos = tok_end;
471                let span = self.span(tok_start, tok_end);
472
473                // Braces are the one pair that interacts with interpolation:
474                // a `{` opened inside a hole has to be matched before that
475                // hole's own `}` is reached.
476                match raw {
477                    RawToken::LBrace => {
478                        if let Some(frame) = self.interp.last_mut() {
479                            frame.braces += 1;
480                        }
481                    }
482                    RawToken::RBrace => return Ok(Some(self.maybe_close_interp(tok_start))),
483                    _ => {}
484                }
485
486                let tok = raw_to_token(raw, lex.slice());
487                Ok(Some(Spanned::new(tok, span)))
488            }
489            Some(Err(())) => {
490                // Skip any trivia before the offending character so the
491                // error span points at the character itself.
492                let err_offset = lex.span().start;
493                let ch = remaining[err_offset..].chars().next().unwrap_or('?');
494                let err_start = self.pos + err_offset;
495                self.pos = err_start + ch.len_utf8();
496                Err(LexError::UnexpectedCharacter(
497                    ch,
498                    self.span(err_start, self.pos),
499                ))
500            }
501        }
502    }
503}
504
505fn utf8_char_width(b: u8) -> usize {
506    match b {
507        0x00..=0x7F => 1,
508        0xC0..=0xDF => 2,
509        0xE0..=0xEF => 3,
510        0xF0..=0xFF => 4,
511        _ => 1,
512    }
513}
514
515/// Convert a `RawToken` (logos output) into a `Token`.
516fn raw_to_token(raw: RawToken, slice: &str) -> Token {
517    match raw {
518        RawToken::Let => Token::Let,
519        RawToken::Mut => Token::Mut,
520        RawToken::Const => Token::Const,
521        RawToken::Fn => Token::Fn,
522        RawToken::Return => Token::Return,
523        RawToken::If => Token::If,
524        RawToken::Else => Token::Else,
525        RawToken::While => Token::While,
526        RawToken::For => Token::For,
527        RawToken::In => Token::In,
528        RawToken::Break => Token::Break,
529        RawToken::Continue => Token::Continue,
530        RawToken::Match => Token::Match,
531        RawToken::Type => Token::Type,
532        RawToken::Record => Token::Record,
533        RawToken::Trait => Token::Trait,
534        RawToken::Impl => Token::Impl,
535        RawToken::Import => Token::Import,
536        RawToken::Module => Token::Module,
537        RawToken::Pub => Token::Pub,
538        RawToken::Async => Token::Async,
539        RawToken::Await => Token::Await,
540        RawToken::Extern => Token::Extern,
541        RawToken::Unsafe => Token::Unsafe,
542        RawToken::True => Token::True,
543        RawToken::False => Token::False,
544        RawToken::As => Token::As,
545        RawToken::Is => Token::Is,
546        RawToken::Where => Token::Where,
547        RawToken::With => Token::With,
548        RawToken::SelfLower => Token::SelfLower,
549        RawToken::SelfUpper => Token::SelfUpper,
550        RawToken::LParen => Token::LParen,
551        RawToken::RParen => Token::RParen,
552        RawToken::LBracket => Token::LBracket,
553        RawToken::RBracket => Token::RBracket,
554        RawToken::LBrace => Token::LBrace,
555        RawToken::RBrace => Token::RBrace, // handled before this function for interp
556        RawToken::Comma => Token::Comma,
557        RawToken::Semicolon => Token::Semicolon,
558        RawToken::Colon => Token::Colon,
559        RawToken::ColonColon => Token::ColonColon,
560        RawToken::Arrow => Token::Arrow,
561        RawToken::FatArrow => Token::FatArrow,
562        RawToken::Dot => Token::Dot,
563        RawToken::DotDot => Token::DotDot,
564        RawToken::DotDotEq => Token::DotDotEq,
565        RawToken::At => Token::At,
566        RawToken::Question => Token::Question,
567        RawToken::Eq => Token::Eq,
568        RawToken::EqEq => Token::EqEq,
569        RawToken::BangEq => Token::BangEq,
570        RawToken::Lt => Token::Lt,
571        RawToken::LtEq => Token::LtEq,
572        RawToken::Gt => Token::Gt,
573        RawToken::GtEq => Token::GtEq,
574        RawToken::AmpAmp => Token::AmpAmp,
575        RawToken::PipePipe => Token::PipePipe,
576        RawToken::Bang => Token::Bang,
577        RawToken::Plus => Token::Plus,
578        RawToken::Minus => Token::Minus,
579        RawToken::Star => Token::Star,
580        RawToken::Slash => Token::Slash,
581        RawToken::Percent => Token::Percent,
582        RawToken::PlusEq => Token::PlusEq,
583        RawToken::MinusEq => Token::MinusEq,
584        RawToken::StarEq => Token::StarEq,
585        RawToken::SlashEq => Token::SlashEq,
586        RawToken::PercentEq => Token::PercentEq,
587        RawToken::Amp => Token::Amp,
588        RawToken::Pipe => Token::Pipe,
589        RawToken::Caret => Token::Caret,
590        RawToken::Tilde => Token::Tilde,
591        RawToken::LtLt => Token::LtLt,
592        RawToken::GtGt => Token::GtGt,
593        RawToken::AmpEq => Token::AmpEq,
594        RawToken::PipeEq => Token::PipeEq,
595        RawToken::CaretEq => Token::CaretEq,
596        RawToken::LtLtEq => Token::LtLtEq,
597        RawToken::GtGtEq => Token::GtGtEq,
598        RawToken::Float(f) => Token::Float(f),
599        RawToken::Int(n) => Token::Int(n),
600        RawToken::Char(c) => Token::Char(c),
601        // Leading whitespace kept, so indentation inside a doc's Markdown
602        // survives; trailing whitespace, `\r` included, dropped (spec §3.2).
603        RawToken::DocComment => Token::DocComment(slice[3..].trim_end().to_owned()),
604        RawToken::Ident => Token::Ident(slice.to_owned()),
605    }
606}
607
608// ---------------------------------------------------------------------------
609// logos token enum (internal)
610// ---------------------------------------------------------------------------
611
612#[derive(logos::Logos, Debug, Clone, PartialEq)]
613#[logos(skip r"[ \t\n\r]+")]
614#[logos(skip r"//[^\n]*")] // line comment
615#[logos(skip r"/\*([^*]|\*[^/])*\*/")] // block comment (non-recursive)
616enum RawToken {
617    // Keywords (must come before Ident)
618    #[token("let")]
619    Let,
620    #[token("mut")]
621    Mut,
622    #[token("const")]
623    Const,
624    #[token("fn")]
625    Fn,
626    #[token("return")]
627    Return,
628    #[token("if")]
629    If,
630    #[token("else")]
631    Else,
632    #[token("while")]
633    While,
634    #[token("for")]
635    For,
636    #[token("in")]
637    In,
638    #[token("break")]
639    Break,
640    #[token("continue")]
641    Continue,
642    #[token("match")]
643    Match,
644    #[token("type")]
645    Type,
646    #[token("record")]
647    Record,
648    #[token("trait")]
649    Trait,
650    #[token("impl")]
651    Impl,
652    #[token("import")]
653    Import,
654    #[token("module")]
655    Module,
656    #[token("pub")]
657    Pub,
658    #[token("async")]
659    Async,
660    #[token("await")]
661    Await,
662    #[token("extern")]
663    Extern,
664    #[token("unsafe")]
665    Unsafe,
666    #[token("true")]
667    True,
668    #[token("false")]
669    False,
670    #[token("as")]
671    As,
672    #[token("is")]
673    Is,
674    #[token("where")]
675    Where,
676    #[token("with")]
677    With,
678    #[token("self")]
679    SelfLower,
680    #[token("Self")]
681    SelfUpper,
682
683    // Multi-char punctuation (longest match first)
684    #[token("::")]
685    ColonColon,
686    #[token("->")]
687    Arrow,
688    #[token("=>")]
689    FatArrow,
690    #[token("..")]
691    DotDot,
692    #[token("..=")]
693    DotDotEq,
694    #[token("==")]
695    EqEq,
696    #[token("!=")]
697    BangEq,
698    #[token("<=")]
699    LtEq,
700    #[token(">=")]
701    GtEq,
702    #[token("&&")]
703    AmpAmp,
704    #[token("||")]
705    PipePipe,
706    #[token("+=")]
707    PlusEq,
708    #[token("-=")]
709    MinusEq,
710    #[token("*=")]
711    StarEq,
712    #[token("/=")]
713    SlashEq,
714    #[token("%=")]
715    PercentEq,
716    #[token("&=")]
717    AmpEq,
718    #[token("|=")]
719    PipeEq,
720    #[token("^=")]
721    CaretEq,
722    #[token("<<=")]
723    LtLtEq,
724    #[token(">>=")]
725    GtGtEq,
726    #[token("<<")]
727    LtLt,
728    #[token(">>")]
729    GtGt,
730
731    // Single-char punctuation
732    #[token("(")]
733    LParen,
734    #[token(")")]
735    RParen,
736    #[token("[")]
737    LBracket,
738    #[token("]")]
739    RBracket,
740    #[token("{")]
741    LBrace,
742    #[token("}")]
743    RBrace,
744    #[token(",")]
745    Comma,
746    #[token(";")]
747    Semicolon,
748    #[token(":")]
749    Colon,
750    #[token(".")]
751    Dot,
752    #[token("@")]
753    At,
754    #[token("?")]
755    Question,
756    #[token("=")]
757    Eq,
758    #[token("<")]
759    Lt,
760    #[token(">")]
761    Gt,
762    #[token("!")]
763    Bang,
764    #[token("+")]
765    Plus,
766    #[token("-")]
767    Minus,
768    #[token("*")]
769    Star,
770    #[token("/")]
771    Slash,
772    #[token("%")]
773    Percent,
774    #[token("&")]
775    Amp,
776    #[token("|")]
777    Pipe,
778    #[token("^")]
779    Caret,
780    #[token("~")]
781    Tilde,
782
783    // Float literals — must be before Int to get longest match priority
784    #[regex(r"[0-9][0-9_]*\.[0-9][0-9_]*([eE][+-]?[0-9]+)?", lex_float)]
785    #[regex(r"[0-9][0-9_]*[eE][+-]?[0-9]+", lex_float)]
786    Float(f64),
787
788    // Integer literals
789    #[regex(r"0x[0-9a-fA-F][0-9a-fA-F_]*", lex_hex_int)]
790    #[regex(r"0b[01][01_]*", lex_bin_int)]
791    #[regex(r"0o[0-7][0-7_]*", lex_oct_int)]
792    #[regex(r"[0-9][0-9_]*", lex_dec_int)]
793    Int(i64),
794
795    // Char literals
796    #[regex(r"'([^'\\]|\\.)'", lex_char)]
797    Char(char),
798
799    // Doc comments (/// ...)
800    #[regex(r"///[^\n]*")]
801    DocComment,
802
803    // Identifiers (after keywords)
804    #[regex(r"[a-zA-Z_][a-zA-Z0-9_]*")]
805    Ident,
806}
807
808fn lex_hex_int(lex: &mut logos::Lexer<RawToken>) -> Option<i64> {
809    let s = lex.slice()[2..].replace('_', "");
810    i64::from_str_radix(&s, 16).ok()
811}
812
813fn lex_bin_int(lex: &mut logos::Lexer<RawToken>) -> Option<i64> {
814    let s = lex.slice()[2..].replace('_', "");
815    i64::from_str_radix(&s, 2).ok()
816}
817
818fn lex_oct_int(lex: &mut logos::Lexer<RawToken>) -> Option<i64> {
819    let s = lex.slice()[2..].replace('_', "");
820    i64::from_str_radix(&s, 8).ok()
821}
822
823fn lex_dec_int(lex: &mut logos::Lexer<RawToken>) -> Option<i64> {
824    lex.slice().replace('_', "").parse().ok()
825}
826
827fn lex_float(lex: &mut logos::Lexer<RawToken>) -> Option<f64> {
828    lex.slice().replace('_', "").parse().ok()
829}
830
831fn lex_char(lex: &mut logos::Lexer<RawToken>) -> Option<char> {
832    let s = lex.slice();
833    // strip surrounding `'`
834    let inner = &s[1..s.len() - 1];
835    if inner.starts_with('\\') {
836        match inner.as_bytes().get(1)? {
837            b'n' => Some('\n'),
838            b't' => Some('\t'),
839            b'r' => Some('\r'),
840            b'\\' => Some('\\'),
841            b'\'' => Some('\''),
842            b'0' => Some('\0'),
843            _ => None,
844        }
845    } else {
846        inner.chars().next()
847    }
848}