squonk_ast/dialect/keyword.rs
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 Moderately AI Inc.
3
4//! Shared SQL keyword table and per-position reservation bitsets.
5//!
6//! The [`Keyword`] inventory, its `ALL`/`as_str` tables, the allocation-free
7//! [`lookup_keyword`], and the per-category reservation bitsets are *generated*
8//! from the objective keyword data (`keyword_data/*.csv`; PostgreSQL `kwlist.h`
9//! and SQL:2016 Part 2) into a `generated` submodule. This hand-written
10//! parent owns the [`KeywordSet`] representation, the keyword-symbol bridge, and
11//! the *composition* of the generated categories into the per-position reject sets
12//! the parser's identifier gates consult.
13
14use crate::vocab::Symbol;
15
16mod generated;
17
18pub use generated::{
19 Keyword, MYSQL_FUNCTION_ONLY_KEYWORDS, MYSQL_RESERVED_KEYWORDS, MYSQL_TYPE_FUNC_NAME_KEYWORDS,
20 POSTGRES_AS_LABEL_KEYWORDS, POSTGRES_COL_NAME_KEYWORDS, POSTGRES_RESERVED_KEYWORDS,
21 POSTGRES_TYPE_FUNC_NAME_KEYWORDS, lookup_keyword,
22};
23
24impl Keyword {
25 /// The fixed low symbol occupied by this keyword's canonical spelling.
26 pub fn symbol(self) -> Symbol {
27 Symbol::new(self as u32 + 1).expect("keyword symbols are one-based")
28 }
29}
30
31// Fail the build loudly if the discriminant-order invariant the keyword
32// machinery relies on is ever broken: `symbol()` (`self as u32 + 1`), the
33// resolver's reverse lookup (`Keyword::ALL[sym - 1]`), and the [`KeywordSet`]
34// bitset all index by discriminant, so the generated `ALL` must list every
35// variant in discriminant order or symbols silently mis-resolve. The bitset is
36// `[u64; KEYWORD_WORDS]`, which widens with the inventory, and `#[repr(u16)]`
37// admits up to 65_535 keywords.
38const _: () = {
39 let mut index = 0;
40 while index < Keyword::ALL.len() {
41 assert!(
42 Keyword::ALL[index] as usize == index,
43 "Keyword::ALL must list every variant in discriminant order; symbol() and \
44 the resolver's reverse lookup index by `self as u32`",
45 );
46 index += 1;
47 }
48};
49
50/// Number of 64-bit words the keyword bitset needs to give every [`Keyword`] its
51/// own slot, derived from the keyword count so it widens automatically with the
52/// inventory.
53const KEYWORD_WORDS: usize = Keyword::ALL.len().div_ceil(u64::BITS as usize);
54
55/// A const bitset giving every [`Keyword`] discriminant its own bit.
56///
57/// Backed by `[u64; KEYWORD_WORDS]` rather than a single `u64`, so reservation
58/// stays a one-bit-test per-position lookup while scaling past 64
59/// keywords with no representation change. Membership and [`union`](Self::union)
60/// are `O(1)` and `const`, so the per-position sets fold at compile time.
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62pub struct KeywordSet([u64; KEYWORD_WORDS]);
63
64impl KeywordSet {
65 /// The empty keyword set.
66 pub const EMPTY: Self = Self([0; KEYWORD_WORDS]);
67
68 /// Build a set from keywords.
69 pub const fn from_keywords(keywords: &[Keyword]) -> Self {
70 let mut words = [0; KEYWORD_WORDS];
71 let mut index = 0;
72 while index < keywords.len() {
73 let (word, bit) = keyword_slot(keywords[index] as usize);
74 words[word] |= bit;
75 index += 1;
76 }
77 Self(words)
78 }
79
80 /// The union of two sets — the per-position reject sets are built by unioning
81 /// the generated `kwlist.h` category bitsets (e.g. a ColId rejects
82 /// `type_func_name ∪ reserved`), so this must be `const`.
83 pub const fn union(self, other: Self) -> Self {
84 let mut words = self.0;
85 let mut index = 0;
86 while index < KEYWORD_WORDS {
87 words[index] |= other.0[index];
88 index += 1;
89 }
90 Self(words)
91 }
92
93 /// The set difference `self \ other` — every keyword in `self` that is not in
94 /// `other`. Carves a small allowlist out of a broad reserved set (MySQL admits its
95 /// reserved window-function names — `ROW_NUMBER`, `RANK`, … — as call heads via a
96 /// dedicated grammar, so its function-name reject set is the reserved set minus those),
97 /// so it must be `const` to fold at compile time alongside [`union`](Self::union).
98 pub const fn difference(self, other: Self) -> Self {
99 let mut words = self.0;
100 let mut index = 0;
101 while index < KEYWORD_WORDS {
102 words[index] &= !other.0[index];
103 index += 1;
104 }
105 Self(words)
106 }
107
108 /// True if `keyword` is present.
109 pub const fn contains(self, keyword: Keyword) -> bool {
110 let (word, bit) = keyword_slot(keyword as usize);
111 self.0[word] & bit != 0
112 }
113}
114
115/// The `(word index, bit mask)` addressing a keyword `discriminant` in the bitset.
116///
117/// `discriminant / 64` selects the word and `discriminant % 64` the bit, so a
118/// keyword's slot stays fixed as the bitset widens. The discriminant-order
119/// invariant asserted above keeps these in lockstep with `symbol()`.
120const fn keyword_slot(discriminant: usize) -> (usize, u64) {
121 let bits = u64::BITS as usize;
122 (discriminant / bits, 1_u64 << (discriminant % bits))
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn lookup_keyword_is_case_insensitive_and_alloc_free() {
131 assert_eq!(lookup_keyword("select"), Some(Keyword::Select));
132 assert_eq!(lookup_keyword("SeLeCt"), Some(Keyword::Select));
133 assert_eq!(lookup_keyword("selector"), None);
134 assert_eq!(lookup_keyword("café"), None);
135 }
136
137 #[test]
138 fn lookup_table_covers_every_keyword() {
139 for keyword in Keyword::ALL {
140 assert_eq!(lookup_keyword(keyword.as_str()), Some(keyword));
141 }
142 }
143
144 /// The obvious-correct keyword lookup: ASCII-lower-case the word and linear-scan
145 /// the inventory. The shipped `lookup_keyword` is a length-bucketed search that
146 /// compares the lower-cased bytes as a packed integer (`u64`/`u128`); this is the
147 /// independent oracle it must match, so a packing, endianness, bucket-dispatch, or
148 /// table-sort bug surfaces as a disagreement.
149 fn reference_lookup(word: &str) -> Option<Keyword> {
150 let lowered = word.to_ascii_lowercase();
151 Keyword::ALL
152 .into_iter()
153 .find(|&keyword| keyword.as_str() == lowered)
154 }
155
156 /// Upper/lower alternating copy of an ASCII spelling (`select` -> `SeLeCt`), so
157 /// the agreement test exercises mixed case across the whole inventory.
158 fn alternating_case(spelling: &str) -> String {
159 spelling
160 .bytes()
161 .enumerate()
162 .map(|(index, byte)| {
163 if index % 2 == 0 {
164 byte.to_ascii_uppercase() as char
165 } else {
166 byte.to_ascii_lowercase() as char
167 }
168 })
169 .collect()
170 }
171
172 #[test]
173 fn packed_lookup_agrees_with_reference_over_keywords_and_non_keywords() {
174 // Non-keywords that must all MISS — snake_case identifiers (the real hot-path
175 // input is overwhelmingly identifiers, not keywords) plus adversarial edges:
176 // empty, lone `_`, keyword-with-affix, near-misses, and non-ASCII words.
177 const NON_KEYWORDS: &[&str] = &[
178 "user_id",
179 "created_at",
180 "customer_email",
181 "order_total",
182 "line_item",
183 "txn_ref",
184 "qty_on_hand",
185 "unit_price_usd",
186 "is_active",
187 "uuid_pk",
188 "foo",
189 "bar",
190 "baz",
191 "xyzzy",
192 "col_1",
193 "",
194 "_",
195 "select_",
196 "_select",
197 "selectt",
198 "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz",
199 "café",
200 "naïve",
201 "señor",
202 ];
203
204 let mut probes: Vec<String> = Vec::new();
205 for keyword in Keyword::ALL {
206 let spelling = keyword.as_str();
207 // Canonical, all-upper, and alternating case across the whole inventory.
208 probes.push(spelling.to_string());
209 probes.push(spelling.to_ascii_uppercase());
210 probes.push(alternating_case(spelling));
211 // Near-misses probe the length-bucket boundaries and the packing: one byte
212 // longer, one byte shorter, a prefixed copy. Spellings are ASCII, so the
213 // byte slice is a char boundary. Comparing against the reference (not a flat
214 // `None`) keeps it correct when a truncation IS a shorter keyword (`asc` ->
215 // `as`).
216 probes.push(format!("{spelling}z"));
217 probes.push(format!("z{spelling}"));
218 if spelling.len() > 1 {
219 probes.push(spelling[..spelling.len() - 1].to_string());
220 }
221 }
222 probes.extend(NON_KEYWORDS.iter().map(|word| (*word).to_string()));
223
224 for probe in &probes {
225 assert_eq!(
226 lookup_keyword(probe),
227 reference_lookup(probe),
228 "packed lookup disagrees with the reference scan on {probe:?}",
229 );
230 }
231 }
232
233 #[test]
234 fn keyword_symbols_are_fixed_low_slots() {
235 // The inventory is alphabetical, so `A` is the first variant; symbols are
236 // one-based discriminants, and the last keyword fills the `ALL.len()` slot.
237 assert_eq!(Keyword::A.symbol().as_u32(), 1);
238 assert_eq!(Keyword::A as usize, 0);
239 let last = *Keyword::ALL.last().expect("non-empty inventory");
240 assert_eq!(last.symbol().as_u32(), Keyword::ALL.len() as u32);
241 // The symbol is exactly the discriminant plus one, for every keyword.
242 for keyword in Keyword::ALL {
243 assert_eq!(keyword.symbol().as_u32(), keyword as u32 + 1);
244 }
245 }
246
247 #[test]
248 fn keyword_set_round_trips_every_keyword() {
249 let all = KeywordSet::from_keywords(&Keyword::ALL);
250 for keyword in Keyword::ALL {
251 assert!(all.contains(keyword), "{keyword:?} should be present");
252 assert!(
253 !KeywordSet::EMPTY.contains(keyword),
254 "{keyword:?} should be absent from the empty set",
255 );
256 }
257 }
258
259 #[test]
260 fn keyword_set_union_is_membership_union() {
261 let left = KeywordSet::from_keywords(&[Keyword::Select, Keyword::From]);
262 let right = KeywordSet::from_keywords(&[Keyword::From, Keyword::Where]);
263 let union = left.union(right);
264 for keyword in [Keyword::Select, Keyword::From, Keyword::Where] {
265 assert!(
266 union.contains(keyword),
267 "{keyword:?} should be in the union"
268 );
269 }
270 assert!(
271 !union.contains(Keyword::Join),
272 "Join was in neither operand"
273 );
274 }
275
276 #[test]
277 fn keyword_bitset_addresses_slots_across_word_boundaries() {
278 // The bitset packs discriminants into 64-bit words; prove the addressing
279 // is correct across word boundaries so the representation scales with the
280 // full inventory (which already spans many words).
281 assert_eq!(keyword_slot(0), (0, 1));
282 assert_eq!(keyword_slot(63), (0, 1 << 63));
283 assert_eq!(keyword_slot(64), (1, 1));
284 assert_eq!(keyword_slot(127), (1, 1 << 63));
285 assert_eq!(keyword_slot(128), (2, 1));
286
287 // Round-trip set/get over a synthetic 130-slot space spanning three words.
288 let mut words = [0_u64; 3];
289 let present = [0_usize, 1, 63, 64, 65, 127, 129];
290 for &discriminant in &present {
291 let (word, bit) = keyword_slot(discriminant);
292 words[word] |= bit;
293 }
294 for discriminant in 0..130_usize {
295 let (word, bit) = keyword_slot(discriminant);
296 assert_eq!(
297 words[word] & bit != 0,
298 present.contains(&discriminant),
299 "slot {discriminant}",
300 );
301 }
302 }
303}