Skip to main content

brink_syntax_native/lexer/
mod.rs

1mod ident;
2mod punctuation;
3#[cfg(test)]
4mod tests;
5
6use crate::SyntaxKind;
7pub use ident::classify_keyword;
8pub use punctuation::lex_punctuation;
9
10/// Lex `source` into a sequence of `(SyntaxKind, slice)` pairs.
11///
12/// Every byte of `source` appears in exactly one token — this is the
13/// lossless-roundtrip invariant the CST and its proptests depend on. The
14/// only mutable state is a `string_depth: u32` tracking nested string /
15/// interpolation regions, mirroring `brink-syntax`'s lexer: a `"` toggles
16/// string-scanning mode regardless of whether the surrounding context turns
17/// out to be an expression string literal or literal dialogue-quote prose
18/// text — the *parser*, not the lexer, decides which node shape a quoted
19/// run gets, from its structural position (same one-token-stream,
20/// position-decides-shape pattern `brace_scan` uses in the ink parser).
21pub fn lex(source: &str) -> Vec<(SyntaxKind, &str)> {
22    Lexer::new(source).run()
23}
24
25struct Lexer<'src> {
26    source: &'src str,
27    bytes: &'src [u8],
28    pos: usize,
29    /// Nesting depth of string interpolations. 0 = outside any string.
30    /// Odd = in string mode, even = in code mode with pending string
31    /// closings to track (a `{` inside a string increments depth and
32    /// re-enters code mode for the interpolation; the matching `}`
33    /// decrements depth and re-enters string mode).
34    string_depth: u32,
35    tokens: Vec<(SyntaxKind, &'src str)>,
36}
37
38impl<'src> Lexer<'src> {
39    fn new(source: &'src str) -> Self {
40        Self {
41            source,
42            bytes: source.as_bytes(),
43            pos: 0,
44            string_depth: 0,
45            tokens: Vec::new(),
46        }
47    }
48
49    fn in_string(&self) -> bool {
50        self.string_depth % 2 == 1
51    }
52
53    fn run(mut self) -> Vec<(SyntaxKind, &'src str)> {
54        while self.pos < self.bytes.len() {
55            if self.in_string() {
56                self.lex_string_token();
57            } else {
58                self.lex_code_token();
59            }
60        }
61        self.tokens
62    }
63
64    fn emit(&mut self, kind: SyntaxKind, start: usize) {
65        self.tokens.push((kind, &self.source[start..self.pos]));
66    }
67
68    // ── String-mode lexing ──────────────────────────────────────
69
70    fn lex_string_token(&mut self) {
71        let start = self.pos;
72        let b = self.bytes[self.pos];
73
74        // Closing quote — pop one level of string nesting.
75        if b == b'"' {
76            self.pos += 1;
77            self.string_depth -= 1;
78            self.emit(SyntaxKind::QUOTE, start);
79            return;
80        }
81
82        // Escape sequence.
83        if b == b'\\' && self.pos + 1 < self.bytes.len() {
84            let next = self.bytes[self.pos + 1];
85            if matches!(next, b'n' | b't' | b'\\' | b'"') {
86                self.pos += 2;
87                self.emit(SyntaxKind::STRING_ESCAPE, start);
88                return;
89            }
90        }
91
92        // Opening brace — enter interpolation (push depth). Lets
93        // `{expr}` interpolation appear inside dialogue-quoted prose text.
94        if b == b'{' {
95            self.pos += 1;
96            self.string_depth += 1;
97            self.emit(SyntaxKind::L_BRACE, start);
98            return;
99        }
100
101        // Brackets — emit as L_BRACKET/R_BRACKET even in string mode so
102        // the parser can find choice-bracket boundaries (charter §5's
103        // `[]` display-split) regardless of context, mirroring ink.
104        if b == b'[' {
105            self.pos += 1;
106            self.emit(SyntaxKind::L_BRACKET, start);
107            return;
108        }
109        if b == b']' {
110            self.pos += 1;
111            self.emit(SyntaxKind::R_BRACKET, start);
112            return;
113        }
114
115        // Glue `<>` — breaks out of STRING_TEXT so the parser sees it even
116        // inside dialogue-quoted text (charter §11: glue kept).
117        if b == b'<' && self.pos + 1 < self.bytes.len() && self.bytes[self.pos + 1] == b'>' {
118            self.pos += 2;
119            self.emit(SyntaxKind::GLUE, start);
120            return;
121        }
122
123        // Newline terminates an unterminated string — never let a
124        // malformed quote swallow the rest of the file.
125        if b == b'\n' || b == b'\r' {
126            self.pos += 1;
127            if b == b'\r' && self.pos < self.bytes.len() && self.bytes[self.pos] == b'\n' {
128                self.pos += 1;
129            }
130            self.string_depth -= 1;
131            self.emit(SyntaxKind::NEWLINE, start);
132            return;
133        }
134
135        // `STRING_TEXT`: run of non-special chars (byte-stepped; codepoint
136        // boundaries never split because every multi-byte UTF-8 continuation
137        // byte is >= 0x80 and none of the ASCII break bytes below collide
138        // with continuation bytes).
139        self.pos += 1;
140        while self.pos < self.bytes.len() {
141            match self.bytes[self.pos] {
142                b'"' | b'\\' | b'{' | b'\n' | b'\r' | b'[' | b']' => break,
143                b'<' if self.pos + 1 < self.bytes.len() && self.bytes[self.pos + 1] == b'>' => {
144                    break;
145                }
146                _ => self.pos += 1,
147            }
148        }
149        self.emit(SyntaxKind::STRING_TEXT, start);
150    }
151
152    // ── Code-mode lexing ────────────────────────────────────────
153
154    fn lex_code_token(&mut self) {
155        let start = self.pos;
156        let b = self.bytes[self.pos];
157
158        // Newlines.
159        if b == b'\n' {
160            self.pos += 1;
161            self.emit(SyntaxKind::NEWLINE, start);
162            return;
163        }
164        if b == b'\r' {
165            self.pos += 1;
166            if self.pos < self.bytes.len() && self.bytes[self.pos] == b'\n' {
167                self.pos += 1;
168            }
169            self.emit(SyntaxKind::NEWLINE, start);
170            return;
171        }
172
173        // UTF-8 BOM (U+FEFF) — treat as whitespace trivia for lossless
174        // roundtrip (adversarial-input requirement).
175        if b == 0xEF
176            && self.pos + 2 < self.bytes.len()
177            && self.bytes[self.pos + 1] == 0xBB
178            && self.bytes[self.pos + 2] == 0xBF
179        {
180            self.pos += 3;
181            self.emit(SyntaxKind::WHITESPACE, start);
182            return;
183        }
184
185        // Whitespace (spaces + tabs only).
186        if b == b' ' || b == b'\t' {
187            self.pos += 1;
188            while self.pos < self.bytes.len()
189                && (self.bytes[self.pos] == b' ' || self.bytes[self.pos] == b'\t')
190            {
191                self.pos += 1;
192            }
193            self.emit(SyntaxKind::WHITESPACE, start);
194            return;
195        }
196
197        // Comments (checked before punctuation, since `/` is also SLASH).
198        if b == b'/'
199            && let Some(kind) = self.try_lex_comment()
200        {
201            self.emit(kind, start);
202            return;
203        }
204
205        // Closing brace — if `string_depth > 0`, re-enter string mode.
206        if b == b'}' && self.string_depth > 0 {
207            self.pos += 1;
208            self.string_depth -= 1;
209            self.emit(SyntaxKind::R_BRACE, start);
210            return;
211        }
212
213        // Multi-char punctuation (greedy, longest-first).
214        if let Some((kind, advance)) = lex_punctuation(self.bytes, self.pos) {
215            self.pos += advance;
216            if kind == SyntaxKind::QUOTE {
217                self.string_depth += 1;
218            }
219            self.emit(kind, start);
220            return;
221        }
222
223        // Digits — could be INTEGER, FLOAT, or digit-start IDENT.
224        if b.is_ascii_digit() {
225            self.lex_number_or_ident();
226            return;
227        }
228
229        // Identifiers (and keywords) — ASCII-only (Finding #2).
230        if ident::is_ident_start_byte(b) {
231            let end = ident::scan_ident(self.bytes, self.pos + 1);
232            let text = &self.source[start..end];
233            let kind = classify_keyword(text);
234            self.pos = end;
235            self.tokens.push((kind, text));
236            return;
237        }
238
239        // Anything else is an error token (one codepoint at a time, never
240        // splitting a multi-byte UTF-8 sequence — required for lossless
241        // roundtrip on arbitrary prose/unicode input).
242        self.pos += char_len_utf8(self.bytes, self.pos);
243        self.emit(SyntaxKind::ERROR_TOKEN, start);
244    }
245
246    /// Try to lex a comment starting at current position (which is `/`).
247    /// Returns `Some(kind)` and advances `self.pos` if successful, `None`
248    /// otherwise.
249    fn try_lex_comment(&mut self) -> Option<SyntaxKind> {
250        if self.pos + 1 >= self.bytes.len() {
251            return None;
252        }
253        match self.bytes[self.pos + 1] {
254            b'/' => {
255                // B0.6b: classify by the third/fourth byte before consuming
256                // to end-of-line — `///` (exactly three slashes) is
257                // DOC_COMMENT_OUTER, `//!` is DOC_COMMENT_INNER, everything
258                // else (`//`, `////+`) stays a plain LINE_COMMENT (Rust
259                // precedent for both rulings).
260                let kind = match self.bytes.get(self.pos + 2) {
261                    Some(b'/') if self.bytes.get(self.pos + 3) != Some(&b'/') => {
262                        SyntaxKind::DOC_COMMENT_OUTER
263                    }
264                    Some(b'!') => SyntaxKind::DOC_COMMENT_INNER,
265                    _ => SyntaxKind::LINE_COMMENT,
266                };
267                self.pos += 2;
268                while self.pos < self.bytes.len()
269                    && self.bytes[self.pos] != b'\n'
270                    && self.bytes[self.pos] != b'\r'
271                {
272                    self.pos += 1;
273                }
274                Some(kind)
275            }
276            b'*' => {
277                self.pos += 2;
278                loop {
279                    if self.pos + 1 < self.bytes.len()
280                        && self.bytes[self.pos] == b'*'
281                        && self.bytes[self.pos + 1] == b'/'
282                    {
283                        self.pos += 2;
284                        break;
285                    }
286                    if self.pos >= self.bytes.len() {
287                        break; // unterminated — runs to EOF, still lossless
288                    }
289                    self.pos += 1;
290                }
291                Some(SyntaxKind::BLOCK_COMMENT)
292            }
293            _ => None,
294        }
295    }
296
297    /// Lex a sequence starting with a digit. Could be:
298    /// - `INTEGER` (digits, NOT followed by an identifier character)
299    /// - `FLOAT` (digits.digits, NOT followed by an identifier character)
300    /// - digit-start `IDENT` (digits followed by an identifier character)
301    fn lex_number_or_ident(&mut self) {
302        let start = self.pos;
303
304        while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
305            self.pos += 1;
306        }
307
308        if self.pos < self.bytes.len() && ident::is_ident_continue_byte(self.bytes[self.pos]) {
309            self.pos = ident::scan_ident(self.bytes, self.pos);
310            self.emit(SyntaxKind::IDENT, start);
311            return;
312        }
313
314        // Float: digits.digits (NOT followed by an identifier character;
315        // and NOT `..`/`.method()`-shaped — a lone `.` not followed by a
316        // digit stays a separate DOT token, so `1.method()`-style postfix
317        // stays parseable later without the lexer pre-deciding it).
318        if self.pos < self.bytes.len()
319            && self.bytes[self.pos] == b'.'
320            && self.pos + 1 < self.bytes.len()
321            && self.bytes[self.pos + 1].is_ascii_digit()
322        {
323            self.pos += 1;
324            while self.pos < self.bytes.len() && self.bytes[self.pos].is_ascii_digit() {
325                self.pos += 1;
326            }
327            if self.pos < self.bytes.len() && ident::is_ident_continue_byte(self.bytes[self.pos]) {
328                self.pos = ident::scan_ident(self.bytes, self.pos);
329                self.emit(SyntaxKind::IDENT, start);
330                return;
331            }
332            self.emit(SyntaxKind::FLOAT, start);
333            return;
334        }
335
336        self.emit(SyntaxKind::INTEGER, start);
337    }
338}
339
340/// Length of the UTF-8 character starting at `pos` (1-4 bytes). Used only
341/// for stepping over unrecognized bytes one codepoint at a time.
342pub(crate) fn char_len_utf8(bytes: &[u8], pos: usize) -> usize {
343    let b = bytes[pos];
344    if b < 0x80 {
345        1
346    } else if b < 0xE0 {
347        2
348    } else if b < 0xF0 {
349        3
350    } else {
351        4
352    }
353}