squonk_ast/dialect/lex_class.rs
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Moderately AI Inc.
3
4//! Byte-keyed lexer class data for the parser hot loop.
5
6/// ASCII whitespace byte (may *start* a whitespace run).
7pub const CLASS_WHITESPACE: u8 = 1 << 0;
8/// First byte of an unquoted identifier.
9pub const CLASS_IDENTIFIER_START: u8 = 1 << 1;
10/// Non-first byte of an unquoted identifier.
11pub const CLASS_IDENTIFIER_CONTINUE: u8 = 1 << 2;
12/// ASCII decimal digit.
13pub const CLASS_DIGIT: u8 = 1 << 3;
14/// Operator spelling byte.
15pub const CLASS_OPERATOR: u8 = 1 << 4;
16/// Whitespace-run *continuation* byte: it extends an already-open whitespace run
17/// but cannot start one. SQLite's vertical tab is the sole member — SQLite's
18/// tokenizer marks `0x0b` illegal as a token start (`aiClass[0x0b]` is not
19/// `CC_SPACE`, so a lone or token-leading `\v` is an "unrecognized token") yet
20/// `sqlite3Isspace(0x0b)` is true, so a `\v` is swallowed when it *follows* another
21/// space in a run (measured: `"\x20\x0b"` accepts, `"\x0b"` and `"SELECT\x0b\x20 1"`
22/// reject). The scanner reads this in the run-extension predicate only, never at
23/// run entry (see the tokenizer's `skip_trivia`).
24pub const CLASS_WHITESPACE_CONTINUE: u8 = 1 << 5;
25/// Structural punctuation byte.
26pub const CLASS_PUNCTUATION: u8 = 1 << 6;
27/// Statement-boundary "trim" whitespace: it folds as whitespace like
28/// [`CLASS_WHITESPACE`], but is legal *only* as leading or trailing trivia of a
29/// statement (adjacent to the input start, a `;` separator, or the input end) — a
30/// member wedged between two content items of one statement is a hard error; comments
31/// count as content items for this boundary rule.
32/// DuckDB's vertical tab is the sole member: DuckDB trims `0x0b` at each
33/// `;`-segment's edges but its statement parser rejects an interior `\v` (measured:
34/// `"\x0bSELECT 1"`, `"SELECT 1\x0b"`, `"SELECT 1;\x0b"` accept; `"SELECT\x0b1"`,
35/// `"SELECT 1\x0bSELECT 2"`, `"SELECT\x20\x0b1"` reject). The tokenizer's
36/// `skip_trivia` folds the byte, then rejects a boundary byte that a content token
37/// both precedes and follows.
38pub const CLASS_WHITESPACE_BOUNDARY: u8 = 1 << 7;
39
40/// Raw byte-class table storage.
41pub type ByteClassTable = [u8; 256];
42
43/// Const byte-class table shared by M1 dialects.
44pub const BYTE_CLASSES: ByteClassTable = build_byte_classes();
45
46/// Dialect-owned byte-class data for the tokenizer hot loop.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48pub struct ByteClasses {
49 table: ByteClassTable,
50 /// Cached: does the table mark any byte [`CLASS_WHITESPACE_BOUNDARY`]? Lets the
51 /// tokenizer skip the interior-boundary guard entirely for every dialect but
52 /// DuckDB, keeping the whitespace hot loop untouched where the guard cannot fire.
53 has_boundary: bool,
54}
55
56impl ByteClasses {
57 /// Standard M1 byte classes: the ANSI baseline shared by every preset except
58 /// PostgreSQL/MySQL (which layer the vertical tab as full whitespace) and
59 /// SQLite/DuckDB (which layer their measured position-dependent vertical-tab
60 /// rules — see [`SQLITE_BYTE_CLASSES`] / [`DUCKDB_BYTE_CLASSES`]).
61 pub const STANDARD: Self = Self {
62 table: BYTE_CLASSES,
63 has_boundary: false,
64 };
65
66 /// Return all classes for `byte`.
67 pub const fn byte_class(&self, byte: u8) -> u8 {
68 self.table[byte as usize]
69 }
70
71 /// Return true if `byte` has any class in `mask`.
72 pub const fn has_class(&self, byte: u8, mask: u8) -> bool {
73 self.byte_class(byte) & mask != 0
74 }
75
76 /// Whether any byte carries [`CLASS_WHITESPACE_BOUNDARY`] — the tokenizer's cheap
77 /// gate for the interior-boundary guard (true only for the DuckDB preset).
78 pub const fn has_boundary_whitespace(&self) -> bool {
79 self.has_boundary
80 }
81
82 /// Return a copy with `mask` added to `byte`.
83 pub const fn with_class(mut self, byte: u8, mask: u8) -> Self {
84 let index = byte as usize;
85 self.table[index] |= mask;
86 if mask & CLASS_WHITESPACE_BOUNDARY != 0 {
87 self.has_boundary = true;
88 }
89 self
90 }
91}
92
93/// Standard byte classes used by the builtin dialect presets.
94pub const STANDARD_BYTE_CLASSES: ByteClasses = ByteClasses::STANDARD;
95
96/// PostgreSQL byte classes: [`STANDARD_BYTE_CLASSES`] with the vertical tab
97/// (`0x0b`, `\v`) added to [`CLASS_WHITESPACE`].
98///
99/// PostgreSQL's flex scanner folds its full `space` set `[ \t\n\r\f\v]` as ignorable
100/// whitespace. The vertical tab is the one member the shared [`STANDARD_BYTE_CLASSES`]
101/// table omits: that table derives whitespace from Rust's [`u8::is_ascii_whitespace`],
102/// which covers `\t \n \x0c \r <space>` but not `0x0b`. Probing the engines directly
103/// shows the vertical tab is *dialect-specific* rather than a shared control byte:
104/// PostgreSQL folds it as ordinary whitespace everywhere (a lone `0x0b` parses as an
105/// empty statement, `SELECT\x0b1` as `SELECT 1`), whereas SQLite folds it only as a
106/// run continuation and DuckDB only as statement-trim (their own tables — see
107/// [`SQLITE_BYTE_CLASSES`] / [`DUCKDB_BYTE_CLASSES`]), and ANSI keeps it strict. So
108/// full whitespace-class membership rides only PostgreSQL's (and MySQL's) table.
109pub const POSTGRES_BYTE_CLASSES: ByteClasses =
110 STANDARD_BYTE_CLASSES.with_class(0x0b, CLASS_WHITESPACE);
111
112/// MySQL byte classes: [`STANDARD_BYTE_CLASSES`] with the vertical tab
113/// (`0x0b`, `\v`) added to [`CLASS_WHITESPACE`], exactly as [`POSTGRES_BYTE_CLASSES`].
114///
115/// MySQL's tokenizer folds the same flex-style `space` set `[ \t\n\r\f\v]` as ignorable
116/// whitespace, and the vertical tab is the one member the shared [`STANDARD_BYTE_CLASSES`]
117/// table omits (that table derives whitespace from Rust's [`u8::is_ascii_whitespace`],
118/// which covers `\t \n \x0c \r <space>` but not `0x0b`). Probing the live `mysql:8` oracle
119/// directly confirms it: a lone `0x0b` prepares as an empty statement and `SELECT\x0b1`
120/// prepares as `SELECT 1`, so MySQL folds it as ordinary whitespace like PostgreSQL —
121/// the second dialect to layer it fully onto the baseline. SQLite and DuckDB fold it only
122/// position-dependently (their own tables — see [`SQLITE_BYTE_CLASSES`] /
123/// [`DUCKDB_BYTE_CLASSES`]), and ANSI stays strict.
124pub const MYSQL_BYTE_CLASSES: ByteClasses =
125 STANDARD_BYTE_CLASSES.with_class(0x0b, CLASS_WHITESPACE);
126
127/// SQLite byte classes: [`STANDARD_BYTE_CLASSES`] with the vertical tab (`0x0b`,
128/// `\v`) marked [`CLASS_WHITESPACE_CONTINUE`] — a whitespace-run *continuation* that
129/// cannot *start* a run.
130///
131/// SQLite does not fold the vertical tab as ordinary whitespace the way PostgreSQL
132/// and MySQL do; its rule is position-dependent, measured against the bundled
133/// `rusqlite` (3.x) oracle. SQLite's tokenizer classes `0x0b` as illegal to *begin*
134/// a token (`aiClass[0x0b]` is not `CC_SPACE`, so a lone or token-leading `\v` is an
135/// "unrecognized token"), yet `sqlite3Isspace(0x0b)` is true, so the space-run loop
136/// swallows a `\v` that *follows* another whitespace byte. Net: `"\x20\x0b"` and
137/// `"SELECT\x20\x0b1"` accept (the `\v` rides an open run), while lone `"\x0b"`,
138/// `"\x0bSELECT 1"`, and `"SELECT\x0b1"` reject (the `\v` would have to start a run).
139/// The tokenizer models this by reading `CLASS_WHITESPACE_CONTINUE` only in the
140/// run-extension predicate, never at run entry, so no other dialect is affected.
141pub const SQLITE_BYTE_CLASSES: ByteClasses =
142 STANDARD_BYTE_CLASSES.with_class(0x0b, CLASS_WHITESPACE_CONTINUE);
143
144/// DuckDB byte classes: [`STANDARD_BYTE_CLASSES`] with the vertical tab (`0x0b`,
145/// `\v`) marked both [`CLASS_WHITESPACE`] and [`CLASS_WHITESPACE_BOUNDARY`] — it
146/// folds as whitespace but is legal only as statement-boundary trim.
147///
148/// DuckDB's rule, measured against the linked `libduckdb` (1.5.x) oracle, is also
149/// position-dependent but *different* from SQLite's: DuckDB trims `0x0b` at the
150/// leading and trailing edges of every `;`-delimited statement (so `"\x0b"`,
151/// `"\x0bSELECT 1"`, `"SELECT 1\x0b"`, `"SELECT 1;\x0b"`, and `"SELECT 1;\x0bSELECT 2"`
152/// all accept) while its statement parser rejects a `\v` interior to a statement's
153/// content (`"SELECT\x0b1"`, `"SELECT 1\x0bSELECT 2"`, and even `"SELECT\x20\x0b1"`
154/// reject — unlike SQLite, adjacency to a real space does not rescue an interior
155/// `\v`). The tokenizer folds the byte as whitespace, then the
156/// [`CLASS_WHITESPACE_BOUNDARY`] guard rejects a boundary byte that statement content
157/// (including comments) both precedes and follows.
158pub const DUCKDB_BYTE_CLASSES: ByteClasses =
159 STANDARD_BYTE_CLASSES.with_class(0x0b, CLASS_WHITESPACE | CLASS_WHITESPACE_BOUNDARY);
160
161/// Return all classes for `byte`.
162pub const fn byte_class(byte: u8) -> u8 {
163 STANDARD_BYTE_CLASSES.byte_class(byte)
164}
165
166/// Return true if `byte` has any class in `mask`.
167pub const fn has_class(byte: u8, mask: u8) -> bool {
168 STANDARD_BYTE_CLASSES.has_class(byte, mask)
169}
170
171const fn build_byte_classes() -> ByteClassTable {
172 let mut table = [0; 256];
173 let mut index = 0;
174
175 while index < 256 {
176 let byte = index as u8;
177 let mut class = 0;
178
179 if byte.is_ascii_whitespace() {
180 class |= CLASS_WHITESPACE;
181 }
182
183 if is_identifier_start(byte) {
184 class |= CLASS_IDENTIFIER_START;
185 }
186
187 if is_identifier_continue(byte) {
188 class |= CLASS_IDENTIFIER_CONTINUE;
189 }
190
191 if byte.is_ascii_digit() {
192 class |= CLASS_DIGIT;
193 }
194
195 if is_operator(byte) {
196 class |= CLASS_OPERATOR;
197 }
198
199 if is_punctuation(byte) {
200 class |= CLASS_PUNCTUATION;
201 }
202
203 table[index] = class;
204 index += 1;
205 }
206
207 table
208}
209
210const fn is_identifier_start(byte: u8) -> bool {
211 byte == b'_' || byte.is_ascii_alphabetic()
212}
213
214const fn is_identifier_continue(byte: u8) -> bool {
215 is_identifier_start(byte) || byte.is_ascii_digit() || byte >= 0x80
216}
217
218const fn is_operator(byte: u8) -> bool {
219 matches!(
220 byte,
221 b'+' | b'-' | b'*' | b'/' | b'%' | b'=' | b'<' | b'>' | b'!' | b'|' | b'&' | b'^' | b'~'
222 )
223}
224
225const fn is_punctuation(byte: u8) -> bool {
226 // `:` is structural punctuation: a lone `:` is the array-slice separator and
227 // `::` is the PostgreSQL typecast operator (the scanner munches `::` first).
228 matches!(
229 byte,
230 b'(' | b')' | b',' | b';' | b'.' | b'[' | b']' | b'{' | b'}' | b':'
231 )
232}