Skip to main content

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}