Skip to main content

ronin_core/
lexer.rs

1//! The RON lexer: source bytes / `&str` → a flat stream of [`Token`]s.
2//!
3//! Two responsibilities:
4//!
5//! 1. **UTF-8 boundary (T012, TR-001/AD-008/INV-4).** [`validate_utf8`] accepts
6//!    `&[u8]` and returns a borrowed `&str` for valid UTF-8 or a clean
7//!    [`LexError`] for invalid UTF-8 — it never panics. A leading UTF-8 BOM is
8//!    *not* stripped here; it is preserved and later emitted as a [`SyntaxKind::Bom`]
9//!    trivia token so it round-trips (AD-008).
10//!
11//! 2. **Tokenization (T013, TR-002/TR-004/INV-1).** [`tokenize`] splits a `&str`
12//!    into tokens covering the **full** RON 0.12 surface verbatim, such that the
13//!    concatenation of every token's text equals the input exactly — every byte
14//!    lands in exactly one token (INV-1). Malformed bytes never panic; an
15//!    unrecognized run becomes a [`SyntaxKind::LexError`] token so coverage holds.
16
17use crate::syntax::SyntaxKind;
18
19/// A clean lexer error returned at the UTF-8 boundary (never a panic).
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub struct LexError {
22    /// Human-readable description of the failure.
23    pub message: String,
24    /// Byte offset at which the failure was detected, when known.
25    pub offset: Option<usize>,
26}
27
28impl std::fmt::Display for LexError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self.offset {
31            Some(o) => write!(f, "{} (at byte {o})", self.message),
32            None => f.write_str(&self.message),
33        }
34    }
35}
36
37impl std::error::Error for LexError {}
38
39/// A single lexed token: a kind plus the verbatim source slice it covers.
40///
41/// `text` is always an exact slice of the input; the sum of all `text` lengths
42/// equals the input length (INV-1).
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Token<'a> {
45    /// Token classification.
46    pub kind: SyntaxKind,
47    /// Verbatim source text for this token (never normalized).
48    pub text: &'a str,
49}
50
51/// The UTF-8 BOM as a `&str` (`\u{FEFF}`, 3 bytes: `EF BB BF`).
52const BOM_STR: &str = "\u{FEFF}";
53
54/// Validate that `bytes` is UTF-8 and return it as `&str`, or a clean error.
55///
56/// Never panics. A leading BOM is left intact for the tokenizer to preserve as
57/// trivia (AD-008/INV-4).
58///
59/// # Errors
60///
61/// Returns [`LexError`] if `bytes` is not valid UTF-8, with the byte offset of
62/// the first invalid sequence.
63pub fn validate_utf8(bytes: &[u8]) -> Result<&str, LexError> {
64    match std::str::from_utf8(bytes) {
65        Ok(s) => Ok(s),
66        Err(e) => Err(LexError {
67            message: "input is not valid UTF-8".to_string(),
68            offset: Some(e.valid_up_to()),
69        }),
70    }
71}
72
73/// Tokenize a `&str` into the full RON surface. Total over all input.
74///
75/// Guarantees (INV-1): the returned tokens, concatenated in order, reproduce
76/// `src` byte-for-byte. Never panics.
77#[must_use]
78pub fn tokenize(src: &str) -> Vec<Token<'_>> {
79    let mut lexer = Lexer::new(src);
80    let mut tokens = Vec::new();
81    while let Some(tok) = lexer.next_token() {
82        tokens.push(tok);
83    }
84    debug_assert_eq!(
85        tokens.iter().map(|t| t.text.len()).sum::<usize>(),
86        src.len(),
87        "lexer must cover every source byte exactly once (INV-1)"
88    );
89    tokens
90}
91
92struct Lexer<'a> {
93    src: &'a str,
94    /// Absolute byte offset of the next unconsumed character.
95    pos: usize,
96    /// `true` until the first token is produced (for leading-BOM detection).
97    at_start: bool,
98}
99
100impl<'a> Lexer<'a> {
101    fn new(src: &'a str) -> Self {
102        Self {
103            src,
104            pos: 0,
105            at_start: true,
106        }
107    }
108
109    /// Remaining unconsumed source.
110    #[inline]
111    fn rest(&self) -> &'a str {
112        &self.src[self.pos..]
113    }
114
115    /// Peek the next `char` without consuming.
116    #[inline]
117    fn peek(&self) -> Option<char> {
118        self.rest().chars().next()
119    }
120
121    /// Peek the char `n` chars ahead (0 == next), without consuming.
122    #[inline]
123    fn peek_nth(&self, n: usize) -> Option<char> {
124        self.rest().chars().nth(n)
125    }
126
127    /// Emit a token covering `[start, self.pos)` with `kind`.
128    #[inline]
129    fn emit(&self, kind: SyntaxKind, start: usize) -> Token<'a> {
130        Token {
131            kind,
132            text: &self.src[start..self.pos],
133        }
134    }
135
136    fn next_token(&mut self) -> Option<Token<'a>> {
137        if self.pos >= self.src.len() {
138            return None;
139        }
140        let start = self.pos;
141
142        // Leading BOM is its own trivia token (AD-008), only at the very start.
143        if self.at_start && self.rest().starts_with(BOM_STR) {
144            self.pos += BOM_STR.len();
145            self.at_start = false;
146            return Some(self.emit(SyntaxKind::Bom, start));
147        }
148        self.at_start = false;
149
150        let c = self.peek().expect("non-empty rest has a char");
151
152        let token = match c {
153            c if is_whitespace(c) => self.lex_whitespace(start),
154            '/' if self.peek_nth(1) == Some('/') => self.lex_line_comment(start),
155            '/' if self.peek_nth(1) == Some('*') => self.lex_block_comment(start),
156            '"' => self.lex_string(start),
157            'r' if matches!(self.peek_nth(1), Some('"') | Some('#')) => self.lex_raw_string(start),
158            '\'' => self.lex_char(start),
159            '0'..='9' => self.lex_number(start, false),
160            '+' | '-' if matches!(self.peek_nth(1), Some('0'..='9') | Some('.')) => {
161                self.lex_number(start, true)
162            }
163            '.' if matches!(self.peek_nth(1), Some('0'..='9')) => self.lex_number(start, false),
164            c if is_ident_start(c) => self.lex_ident_or_keyword(start),
165            '(' => self.bump_punct(SyntaxKind::LParen, start),
166            ')' => self.bump_punct(SyntaxKind::RParen, start),
167            '[' => self.bump_punct(SyntaxKind::LBracket, start),
168            ']' => self.bump_punct(SyntaxKind::RBracket, start),
169            '{' => self.bump_punct(SyntaxKind::LBrace, start),
170            '}' => self.bump_punct(SyntaxKind::RBrace, start),
171            ':' => self.bump_punct(SyntaxKind::Colon, start),
172            ',' => self.bump_punct(SyntaxKind::Comma, start),
173            '#' => self.bump_punct(SyntaxKind::Hash, start),
174            '!' => self.bump_punct(SyntaxKind::Bang, start),
175            // Unknown byte run: consume one char, classify as a lex error.
176            _ => {
177                self.pos += c.len_utf8();
178                self.emit(SyntaxKind::LexError, start)
179            }
180        };
181        Some(token)
182    }
183
184    /// Consume the single char `c` already peeked, emit `kind`.
185    #[inline]
186    fn bump_punct(&mut self, kind: SyntaxKind, start: usize) -> Token<'a> {
187        // All punctuation handled here is single-byte ASCII.
188        self.pos += 1;
189        self.emit(kind, start)
190    }
191
192    fn lex_whitespace(&mut self, start: usize) -> Token<'a> {
193        while let Some(c) = self.peek() {
194            if is_whitespace(c) {
195                self.pos += c.len_utf8();
196            } else {
197                break;
198            }
199        }
200        self.emit(SyntaxKind::Whitespace, start)
201    }
202
203    fn lex_line_comment(&mut self, start: usize) -> Token<'a> {
204        // Consume `//` then everything up to (not including) the line break.
205        self.pos += 2;
206        while let Some(c) = self.peek() {
207            if c == '\n' {
208                break;
209            }
210            self.pos += c.len_utf8();
211        }
212        self.emit(SyntaxKind::LineComment, start)
213    }
214
215    fn lex_block_comment(&mut self, start: usize) -> Token<'a> {
216        // Consume `/*`, then balance nested `/* ... */`. Unterminated comments
217        // run to EOF (still a single token — losslessness holds).
218        self.pos += 2;
219        let mut depth = 1usize;
220        while depth > 0 {
221            let Some(c) = self.peek() else { break };
222            if c == '/' && self.peek_nth(1) == Some('*') {
223                self.pos += 2;
224                depth += 1;
225            } else if c == '*' && self.peek_nth(1) == Some('/') {
226                self.pos += 2;
227                depth -= 1;
228            } else {
229                self.pos += c.len_utf8();
230            }
231        }
232        self.emit(SyntaxKind::BlockComment, start)
233    }
234
235    fn lex_string(&mut self, start: usize) -> Token<'a> {
236        // Opening quote.
237        self.pos += 1;
238        while let Some(c) = self.peek() {
239            match c {
240                '\\' => {
241                    // Escape: consume the backslash and the escaped char (if any)
242                    // verbatim. Validation is the parser's concern; the lexer only
243                    // needs to keep the bytes and not terminate on an escaped quote.
244                    self.pos += 1;
245                    if let Some(esc) = self.peek() {
246                        self.pos += esc.len_utf8();
247                    }
248                }
249                '"' => {
250                    self.pos += 1;
251                    break;
252                }
253                _ => self.pos += c.len_utf8(),
254            }
255        }
256        self.emit(SyntaxKind::String, start)
257    }
258
259    fn lex_raw_string(&mut self, start: usize) -> Token<'a> {
260        // Form: r#*"..."#*  — `r`, then N hashes, then `"`, then body, then `"`
261        // followed by the same N hashes. Consume `r`.
262        self.pos += 1;
263        // Count opening hashes.
264        let mut hashes = 0usize;
265        while self.peek() == Some('#') {
266            self.pos += 1;
267            hashes += 1;
268        }
269        // Expect an opening quote; if absent this is a malformed raw string —
270        // keep what we consumed as a single token (round-trip still holds).
271        if self.peek() != Some('"') {
272            return self.emit(SyntaxKind::RawString, start);
273        }
274        self.pos += 1; // opening quote
275                       // Scan body until a `"` followed by exactly `hashes` hashes.
276                       // unterminated → run to EOF
277        while let Some(c) = self.peek() {
278            if c == '"' {
279                // Tentatively consume the quote and check the closing hashes.
280                let after_quote = self.pos + 1;
281                let mut matched = 0usize;
282                let mut probe = after_quote;
283                while matched < hashes && self.src[probe..].starts_with('#') {
284                    probe += 1;
285                    matched += 1;
286                }
287                if matched == hashes {
288                    self.pos = probe;
289                    break;
290                }
291                // Not the real terminator: consume the quote and continue.
292                self.pos += 1;
293            } else {
294                self.pos += c.len_utf8();
295            }
296        }
297        self.emit(SyntaxKind::RawString, start)
298    }
299
300    fn lex_char(&mut self, start: usize) -> Token<'a> {
301        // Opening `'`.
302        self.pos += 1;
303        while let Some(c) = self.peek() {
304            match c {
305                '\\' => {
306                    self.pos += 1;
307                    if let Some(esc) = self.peek() {
308                        self.pos += esc.len_utf8();
309                    }
310                }
311                '\'' => {
312                    self.pos += 1;
313                    break;
314                }
315                _ => self.pos += c.len_utf8(),
316            }
317        }
318        self.emit(SyntaxKind::Char, start)
319    }
320
321    /// Lex an integer or float. `signed` indicates a leading `+`/`-` was seen.
322    fn lex_number(&mut self, start: usize, signed: bool) -> Token<'a> {
323        if signed {
324            self.pos += 1; // sign char (ASCII)
325        }
326
327        // Hex / binary / octal integer prefixes.
328        if self.peek() == Some('0') {
329            if let Some(radix) = self.peek_nth(1) {
330                let base = match radix {
331                    'x' | 'X' => Some(16u32),
332                    'b' | 'B' => Some(2),
333                    'o' | 'O' => Some(8),
334                    _ => None,
335                };
336                if let Some(base) = base {
337                    self.pos += 2; // `0x` / `0b` / `0o`
338                    self.consume_digits(base);
339                    self.consume_type_suffix();
340                    return self.emit(SyntaxKind::Integer, start);
341                }
342            }
343        }
344
345        // Decimal integer part.
346        self.consume_digits(10);
347
348        let mut is_float = false;
349
350        // Fractional part: a `.` followed by a digit, or `.` not followed by `.`
351        // (RON allows `1.` and `.5`). We only treat `.` as a decimal point when
352        // it is not the start of a range/field access — RON has no such tokens at
353        // value position, so a `.` here is always the float point.
354        if self.peek() == Some('.') && self.peek_nth(1) != Some('.') {
355            is_float = true;
356            self.pos += 1;
357            self.consume_digits(10);
358        }
359
360        // Exponent.
361        if matches!(self.peek(), Some('e') | Some('E')) {
362            // Only an exponent if followed by digits or a sign+digits.
363            let next = self.peek_nth(1);
364            let exp = matches!(next, Some('0'..='9'))
365                || (matches!(next, Some('+') | Some('-'))
366                    && matches!(self.peek_nth(2), Some('0'..='9')));
367            if exp {
368                is_float = true;
369                self.pos += 1; // e/E
370                if matches!(self.peek(), Some('+') | Some('-')) {
371                    self.pos += 1;
372                }
373                self.consume_digits(10);
374            }
375        }
376
377        self.consume_type_suffix();
378
379        self.emit(
380            if is_float {
381                SyntaxKind::Float
382            } else {
383                SyntaxKind::Integer
384            },
385            start,
386        )
387    }
388
389    /// Consume a run of digits valid for `base`, allowing `_` separators.
390    fn consume_digits(&mut self, base: u32) {
391        while let Some(c) = self.peek() {
392            if c == '_' || c.is_digit(base) {
393                self.pos += 1; // digits and `_` are ASCII (single byte)
394            } else {
395                break;
396            }
397        }
398    }
399
400    /// Consume an optional numeric type suffix (e.g. `i32`, `u8`, `f64`, `usize`).
401    ///
402    /// A suffix is an ident-continue run immediately following the numeric body.
403    /// Floats keep `f32`/`f64`; ints keep `i*`/`u*`. We accept any ident run as
404    /// the suffix verbatim — the parser/validator (later epics) judge validity;
405    /// the lexer only preserves bytes.
406    fn consume_type_suffix(&mut self) {
407        // Only consume if the next char could begin a suffix (a letter).
408        if let Some(c) = self.peek() {
409            if c.is_ascii_alphabetic() {
410                while let Some(c) = self.peek() {
411                    if is_ident_continue(c) {
412                        self.pos += c.len_utf8();
413                    } else {
414                        break;
415                    }
416                }
417            }
418        }
419    }
420
421    fn lex_ident_or_keyword(&mut self, start: usize) -> Token<'a> {
422        while let Some(c) = self.peek() {
423            if is_ident_continue(c) {
424                self.pos += c.len_utf8();
425            } else {
426                break;
427            }
428        }
429        let text = &self.src[start..self.pos];
430        let kind = match text {
431            "true" => SyntaxKind::TrueKw,
432            "false" => SyntaxKind::FalseKw,
433            "enable" => SyntaxKind::EnableKw,
434            _ => SyntaxKind::Ident,
435        };
436        Token { kind, text }
437    }
438}
439
440/// Whitespace per RON: ASCII whitespace plus the BOM is handled separately.
441#[inline]
442fn is_whitespace(c: char) -> bool {
443    // Note: a non-leading BOM is *not* whitespace; it falls through to LexError,
444    // which still preserves the byte. A leading BOM is handled before this.
445    c.is_whitespace()
446}
447
448/// Identifier start: Unicode XID-start-ish plus `_`. RON idents follow Rust's.
449#[inline]
450fn is_ident_start(c: char) -> bool {
451    c == '_' || c.is_alphabetic()
452}
453
454/// Identifier continue: alphanumeric or `_`.
455#[inline]
456fn is_ident_continue(c: char) -> bool {
457    c == '_' || c.is_alphanumeric()
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    fn concat(tokens: &[Token<'_>]) -> String {
465        tokens.iter().map(|t| t.text).collect()
466    }
467
468    fn kinds(tokens: &[Token<'_>]) -> Vec<SyntaxKind> {
469        tokens.iter().map(|t| t.kind).collect()
470    }
471
472    #[test]
473    fn validate_utf8_accepts_valid() {
474        assert_eq!(validate_utf8(b"hello").unwrap(), "hello");
475    }
476
477    #[test]
478    fn validate_utf8_rejects_invalid_without_panic() {
479        let bad = [0xFF, 0xFE, 0x00];
480        let err = validate_utf8(&bad).unwrap_err();
481        assert_eq!(err.offset, Some(0));
482    }
483
484    #[test]
485    fn covers_every_byte() {
486        let inputs = [
487            "",
488            "   ",
489            "// only a comment",
490            "/* nested /* block */ comment */",
491            "Foo(x: 1, y: 2.5)",
492            "[1, 2, 3,]",
493            "{ \"k\": 'c', 4: true }",
494            "r#\"raw \"quote\" string\"#",
495            "Some(())",
496            "#![enable(implicit_some)]\n42",
497            "0xFF_u8 0b1010 0o17 1_000.5e-3f64 -1 +2.0",
498        ];
499        for input in inputs {
500            let toks = tokenize(input);
501            assert_eq!(concat(&toks), input, "round-trip for {input:?}");
502        }
503    }
504
505    #[test]
506    fn leading_bom_is_trivia() {
507        let src = "\u{FEFF}1";
508        let toks = tokenize(src);
509        assert_eq!(toks[0].kind, SyntaxKind::Bom);
510        assert_eq!(toks[0].text, "\u{FEFF}");
511        assert_eq!(concat(&toks), src);
512    }
513
514    #[test]
515    fn raw_string_with_hashes() {
516        let src = "r##\"has \"# inside\"##";
517        let toks = tokenize(src);
518        assert_eq!(kinds(&toks), vec![SyntaxKind::RawString]);
519        assert_eq!(toks[0].text, src);
520    }
521
522    #[test]
523    fn numbers_classified() {
524        assert_eq!(tokenize("42")[0].kind, SyntaxKind::Integer);
525        assert_eq!(tokenize("0xFF")[0].kind, SyntaxKind::Integer);
526        assert_eq!(tokenize("3.14")[0].kind, SyntaxKind::Float);
527        assert_eq!(tokenize("1e10")[0].kind, SyntaxKind::Float);
528        assert_eq!(tokenize("1_000i64")[0].kind, SyntaxKind::Integer);
529    }
530
531    #[test]
532    fn keywords_and_idents() {
533        assert_eq!(tokenize("true")[0].kind, SyntaxKind::TrueKw);
534        assert_eq!(tokenize("false")[0].kind, SyntaxKind::FalseKw);
535        assert_eq!(tokenize("enable")[0].kind, SyntaxKind::EnableKw);
536        assert_eq!(tokenize("Foo")[0].kind, SyntaxKind::Ident);
537    }
538
539    #[test]
540    fn unterminated_string_runs_to_eof_without_panic() {
541        let src = "\"no end";
542        let toks = tokenize(src);
543        assert_eq!(toks[0].kind, SyntaxKind::String);
544        assert_eq!(concat(&toks), src);
545    }
546
547    #[test]
548    fn crlf_preserved() {
549        let src = "1\r\n2";
550        let toks = tokenize(src);
551        assert_eq!(concat(&toks), src);
552    }
553}