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}