Skip to main content

sql_dialect_fmt_syntax/
keyword.rs

1//! Case-insensitive recognition of keyword text, and its **dialect-aware reservation**.
2//!
3//! A single table — [`KEYWORDS`] — is the one source of truth: every reserved keyword appears
4//! exactly once as `(lowercase text, SyntaxKind, KeywordDialect)`. From it we derive both the
5//! text→kind lookup ([`keyword_kind`]) and the dialect-aware variant ([`keyword_kind_for`]), and a
6//! completeness test proves the table covers the whole `__KW_START..__KW_END` block so the two can
7//! never drift.
8//!
9//! ## Why a dialect dimension
10//! Whether a word is *reserved* (forced into the grammar instead of being a plain identifier)
11//! differs by dialect. Snowflake reserves words like `TASK`, `WAREHOUSE`, `FLATTEN`, `QUALIFY` that
12//! Databricks treats as ordinary identifiers (they are Snowflake-specific DDL/feature words and do
13//! not appear in the Spark SQL keyword table). [`KeywordDialect`] records, per keyword, *which*
14//! dialects reserve it; the parser consults this so `SELECT task, flatten FROM t` parses clean
15//! under Databricks while Snowflake's reservation is unchanged.
16
17use crate::{Dialect, SyntaxKind};
18
19/// Which dialect(s) reserve a given keyword. The grammar treats a word as a keyword only when the
20/// active [`Dialect`] reserves it; otherwise the word is an ordinary identifier.
21#[derive(Clone, Copy, Debug, PartialEq, Eq)]
22pub enum KeywordDialect {
23    /// Reserved in every dialect (standard SQL words: `SELECT`, `FROM`, `JOIN`, …).
24    Shared,
25    /// Reserved in Snowflake only; a plain identifier under Databricks. These are Snowflake-specific
26    /// DDL/feature/scripting words that are absent from the Spark SQL keyword table.
27    SnowflakeOnly,
28    /// Reserved in Databricks only; a plain identifier under Snowflake. (None today — the variant
29    /// exists so a Databricks-specific reserved word can be added without reshaping the model.)
30    #[allow(dead_code)]
31    DatabricksOnly,
32}
33
34impl KeywordDialect {
35    /// Does `dialect` reserve a keyword carrying this classification?
36    #[inline]
37    #[must_use]
38    pub fn reserved_in(self, dialect: Dialect) -> bool {
39        match self {
40            KeywordDialect::Shared => true,
41            KeywordDialect::SnowflakeOnly => matches!(dialect, Dialect::Snowflake),
42            KeywordDialect::DatabricksOnly => matches!(dialect, Dialect::Databricks),
43        }
44    }
45}
46
47/// The single source of truth for keyword recognition: `(lowercase text, kind, dialect class)`.
48///
49/// Each entry's text must be ASCII lowercase (lookups lowercase the input before matching). The
50/// `every_keyword_variant_is_mapped` test asserts this list covers exactly the `SyntaxKind` keyword
51/// block, so a keyword cannot be added to the enum without a matching entry (and dialect class)
52/// here. Entries must remain sorted by text because lookup uses binary search.
53const KEYWORDS: &[(&str, SyntaxKind, KeywordDialect)] = {
54    use KeywordDialect::{Shared, SnowflakeOnly};
55    use SyntaxKind::*;
56    &[
57        ("after", AFTER_KW, Shared),
58        ("all", ALL_KW, Shared),
59        ("alter", ALTER_KW, Shared),
60        ("and", AND_KW, Shared),
61        ("any", ANY_KW, Shared),
62        ("as", AS_KW, Shared),
63        ("asc", ASC_KW, Shared),
64        ("begin", BEGIN_KW, Shared),
65        ("between", BETWEEN_KW, Shared),
66        ("by", BY_KW, Shared),
67        ("call", CALL_KW, Shared),
68        ("called", CALLED_KW, SnowflakeOnly),
69        ("caller", CALLER_KW, SnowflakeOnly),
70        ("case", CASE_KW, Shared),
71        ("cast", CAST_KW, Shared),
72        ("commit", COMMIT_KW, Shared),
73        // CONNECT/PRIOR: Snowflake hierarchical `CONNECT BY`; absent from the Spark keyword table.
74        ("connect", CONNECT_KW, SnowflakeOnly),
75        ("copy", COPY_KW, SnowflakeOnly),
76        ("create", CREATE_KW, Shared),
77        ("cross", CROSS_KW, Shared),
78        ("current", CURRENT_KW, Shared),
79        ("cursor", CURSOR_KW, SnowflakeOnly),
80        ("declare", DECLARE_KW, Shared),
81        ("delete", DELETE_KW, Shared),
82        ("desc", DESC_KW, Shared),
83        ("describe", DESCRIBE_KW, Shared),
84        ("distinct", DISTINCT_KW, Shared),
85        ("do", DO_KW, Shared),
86        ("drop", DROP_KW, Shared),
87        ("else", ELSE_KW, Shared),
88        ("elseif", ELSEIF_KW, SnowflakeOnly),
89        ("end", END_KW, Shared),
90        ("except", EXCEPT_KW, Shared),
91        ("exception", EXCEPTION_KW, SnowflakeOnly),
92        ("execute", EXECUTE_KW, Shared),
93        ("exists", EXISTS_KW, Shared),
94        ("false", FALSE_KW, Shared),
95        ("fetch", FETCH_KW, Shared),
96        ("first", FIRST_KW, Shared),
97        // FLATTEN: Snowflake table function; not a Spark keyword.
98        ("flatten", FLATTEN_KW, SnowflakeOnly),
99        ("following", FOLLOWING_KW, Shared),
100        ("for", FOR_KW, Shared),
101        ("from", FROM_KW, Shared),
102        ("full", FULL_KW, Shared),
103        ("function", FUNCTION_KW, Shared),
104        ("grant", GRANT_KW, Shared),
105        ("grants", GRANTS_KW, SnowflakeOnly),
106        ("group", GROUP_KW, Shared),
107        ("handler", HANDLER_KW, SnowflakeOnly),
108        ("having", HAVING_KW, Shared),
109        ("if", IF_KW, Shared),
110        // ILIKE/RLIKE/REGEXP: Snowflake operators; non-reserved in Spark, so identifiers there.
111        ("ilike", ILIKE_KW, SnowflakeOnly),
112        ("immediate", IMMEDIATE_KW, SnowflakeOnly),
113        ("imports", IMPORTS_KW, SnowflakeOnly),
114        ("in", IN_KW, Shared),
115        ("inner", INNER_KW, Shared),
116        ("input", INPUT_KW, Shared),
117        ("insert", INSERT_KW, Shared),
118        ("intersect", INTERSECT_KW, Shared),
119        ("into", INTO_KW, Shared),
120        ("is", IS_KW, Shared),
121        ("java", JAVA_KW, Shared),
122        // JAVASCRIPT/SCALA: Snowflake `LANGUAGE` values, not Spark keywords. (JAVA/PYTHON/SQL also
123        // serve as type/language words common to both, so they stay shared.)
124        ("javascript", JAVASCRIPT_KW, SnowflakeOnly),
125        ("join", JOIN_KW, Shared),
126        ("language", LANGUAGE_KW, Shared),
127        ("last", LAST_KW, Shared),
128        ("lateral", LATERAL_KW, Shared),
129        ("left", LEFT_KW, Shared),
130        ("let", LET_KW, Shared),
131        ("like", LIKE_KW, Shared),
132        ("limit", LIMIT_KW, Shared),
133        ("loop", LOOP_KW, Shared),
134        ("matched", MATCHED_KW, Shared),
135        ("merge", MERGE_KW, Shared),
136        ("minus", MINUS_KW, Shared),
137        ("natural", NATURAL_KW, Shared),
138        ("not", NOT_KW, Shared),
139        ("null", NULL_KW, Shared),
140        ("nulls", NULLS_KW, Shared),
141        ("offset", OFFSET_KW, Shared),
142        ("on", ON_KW, Shared),
143        ("or", OR_KW, Shared),
144        ("order", ORDER_KW, Shared),
145        ("out", OUT_KW, Shared),
146        ("outer", OUTER_KW, Shared),
147        ("output", OUTPUT_KW, Shared),
148        ("over", OVER_KW, Shared),
149        ("overwrite", OVERWRITE_KW, Shared),
150        ("owner", OWNER_KW, SnowflakeOnly),
151        ("packages", PACKAGES_KW, SnowflakeOnly),
152        ("partition", PARTITION_KW, Shared),
153        ("pivot", PIVOT_KW, Shared),
154        ("preceding", PRECEDING_KW, Shared),
155        ("prior", PRIOR_KW, SnowflakeOnly),
156        ("procedure", PROCEDURE_KW, Shared),
157        ("python", PYTHON_KW, Shared),
158        // QUALIFY: a window-filter clause in BOTH dialects. Databricks SQL supports `SELECT ...
159        // QUALIFY <predicate>` (Databricks Runtime 10.4 LTS+), so it must stay reserved under
160        // Databricks too — otherwise the parser treats it as a plain identifier and mis-splits the
161        // query. Reserving it in both dialects leaves Snowflake byte-identical (it was reserved
162        // there already).
163        ("qualify", QUALIFY_KW, Shared),
164        ("range", RANGE_KW, Shared),
165        ("recursive", RECURSIVE_KW, Shared),
166        ("regexp", REGEXP_KW, SnowflakeOnly),
167        ("repeat", REPEAT_KW, Shared),
168        ("replace", REPLACE_KW, Shared),
169        ("resultset", RESULTSET_KW, SnowflakeOnly),
170        ("return", RETURN_KW, Shared),
171        ("returns", RETURNS_KW, Shared),
172        ("revoke", REVOKE_KW, Shared),
173        ("right", RIGHT_KW, Shared),
174        ("rlike", RLIKE_KW, SnowflakeOnly),
175        ("rollback", ROLLBACK_KW, Shared),
176        ("row", ROW_KW, Shared),
177        ("rows", ROWS_KW, Shared),
178        ("runtime_version", RUNTIME_VERSION_KW, SnowflakeOnly),
179        // SAMPLE: Snowflake spelling; absent from the Spark keyword table (`TABLESAMPLE` is shared).
180        ("sample", SAMPLE_KW, SnowflakeOnly),
181        ("scala", SCALA_KW, SnowflakeOnly),
182        ("schedule", SCHEDULE_KW, SnowflakeOnly),
183        // Snowflake table-property words; not Spark keywords.
184        ("secure", SECURE_KW, SnowflakeOnly),
185        ("select", SELECT_KW, Shared),
186        ("set", SET_KW, Shared),
187        ("show", SHOW_KW, Shared),
188        ("sql", SQL_KW, Shared),
189        ("start", START_KW, Shared),
190        ("strict", STRICT_KW, SnowflakeOnly),
191        ("table", TABLE_KW, Shared),
192        ("tablesample", TABLESAMPLE_KW, Shared),
193        // Snowflake object DDL / scripting words absent from the Spark keyword table.
194        ("task", TASK_KW, SnowflakeOnly),
195        ("temp", TEMP_KW, Shared),
196        ("temporary", TEMPORARY_KW, Shared),
197        ("then", THEN_KW, Shared),
198        // Snowflake's row-limiting `TOP n`; not a Spark keyword.
199        ("top", TOP_KW, SnowflakeOnly),
200        ("transient", TRANSIENT_KW, SnowflakeOnly),
201        ("true", TRUE_KW, Shared),
202        ("truncate", TRUNCATE_KW, Shared),
203        // TRY_CAST is the function `try_cast(...)` in Spark, not a structural keyword.
204        ("try_cast", TRY_CAST_KW, SnowflakeOnly),
205        ("unbounded", UNBOUNDED_KW, Shared),
206        // Snowflake scripting / object words absent from the Spark keyword table.
207        ("undrop", UNDROP_KW, SnowflakeOnly),
208        ("union", UNION_KW, Shared),
209        ("unpivot", UNPIVOT_KW, Shared),
210        ("until", UNTIL_KW, Shared),
211        ("update", UPDATE_KW, Shared),
212        ("use", USE_KW, Shared),
213        ("using", USING_KW, Shared),
214        ("values", VALUES_KW, Shared),
215        ("view", VIEW_KW, Shared),
216        ("volatile", VOLATILE_KW, SnowflakeOnly),
217        ("warehouse", WAREHOUSE_KW, SnowflakeOnly),
218        ("when", WHEN_KW, Shared),
219        ("where", WHERE_KW, Shared),
220        ("while", WHILE_KW, Shared),
221        ("window", WINDOW_KW, Shared),
222        ("with", WITH_KW, Shared),
223        ("within", WITHIN_KW, Shared),
224    ]
225};
226
227const MAX_KEYWORD_LEN: usize = 16;
228
229/// Lowercase `ident` into a stack buffer, returning the byte length — or `None` if it cannot be a
230/// keyword (empty, or longer than the longest keyword). Allocation-free: keeps the lexer's hot path
231/// off the heap. ASCII-lowercasing a valid `&str` byte-by-byte yields valid UTF-8, so the caller's
232/// `from_utf8` never errors.
233#[inline]
234fn lower_for_lookup(ident: &str, buf: &mut [u8; MAX_KEYWORD_LEN]) -> Option<usize> {
235    let bytes = ident.as_bytes();
236    if bytes.is_empty() || bytes.len() > MAX_KEYWORD_LEN {
237        return None;
238    }
239    for (slot, &b) in buf.iter_mut().zip(bytes) {
240        *slot = b.to_ascii_lowercase();
241    }
242    Some(bytes.len())
243}
244
245/// Look up a keyword and its dialect classification from `ident`, case-insensitively.
246#[inline]
247fn lookup(ident: &str) -> Option<(SyntaxKind, KeywordDialect)> {
248    let mut buf = [0u8; MAX_KEYWORD_LEN];
249    let len = lower_for_lookup(ident, &mut buf)?;
250    let lower = std::str::from_utf8(&buf[..len]).ok()?;
251    KEYWORDS
252        .binary_search_by(|(text, _, _)| text.cmp(&lower))
253        .ok()
254        .map(|index| {
255            let (_, kind, dialect) = KEYWORDS[index];
256            (kind, dialect)
257        })
258}
259
260/// Map an identifier's text to its keyword kind, case-insensitively, using **Snowflake** semantics.
261///
262/// Snowflake folds unquoted identifiers and matches keywords without regard to case. Returns `None`
263/// for plain identifiers — the lexer emits [`SyntaxKind::IDENT`] for every word and the parser uses
264/// this to reclassify keywords contextually. Kept for backward compatibility; prefer
265/// [`keyword_kind_for`] when a [`Dialect`] is in hand.
266#[must_use]
267pub fn keyword_kind(ident: &str) -> Option<SyntaxKind> {
268    keyword_kind_for(ident, Dialect::Snowflake)
269}
270
271/// Like [`keyword_kind`], but a word counts as a keyword only when `dialect` reserves it.
272///
273/// A Snowflake-only word (e.g. `TASK`, `FLATTEN`) returns its keyword kind under
274/// [`Dialect::Snowflake`] but `None` under [`Dialect::Databricks`], where it is an ordinary
275/// identifier. Shared keywords behave identically in every dialect, so under
276/// [`Dialect::Snowflake`] this is byte-for-byte equivalent to [`keyword_kind`].
277#[must_use]
278pub fn keyword_kind_for(ident: &str, dialect: Dialect) -> Option<SyntaxKind> {
279    let (kind, kw_dialect) = lookup(ident)?;
280    kw_dialect.reserved_in(dialect).then_some(kind)
281}
282
283/// Canonical lowercase keyword spellings, in the same sorted order as the lookup table.
284///
285/// This lets editor integrations and completion providers share the parser's keyword source
286/// instead of carrying independent reserved-word lists.
287pub fn keyword_texts() -> impl ExactSizeIterator<Item = &'static str> {
288    KEYWORDS.iter().map(|(text, _, _)| *text)
289}
290
291#[cfg(test)]
292mod tests {
293    use super::{keyword_kind, keyword_kind_for, keyword_texts, KeywordDialect, KEYWORDS};
294    use crate::{Dialect, SyntaxKind};
295
296    #[test]
297    fn keyword_lookup_is_case_insensitive() {
298        assert_eq!(keyword_kind("select"), Some(SyntaxKind::SELECT_KW));
299        assert_eq!(keyword_kind("SeLeCt"), Some(SyntaxKind::SELECT_KW));
300        assert_eq!(keyword_kind("QUALIFY"), Some(SyntaxKind::QUALIFY_KW));
301        assert_eq!(keyword_kind("javascript"), Some(SyntaxKind::JAVASCRIPT_KW));
302        assert_eq!(keyword_kind("try_cast"), Some(SyntaxKind::TRY_CAST_KW));
303        assert_eq!(keyword_kind("TASK"), Some(SyntaxKind::TASK_KW));
304        assert_eq!(
305            keyword_kind("runtime_version"),
306            Some(SyntaxKind::RUNTIME_VERSION_KW)
307        );
308        assert_eq!(keyword_kind("definitely_not_a_keyword"), None);
309    }
310
311    #[test]
312    fn every_keyword_variant_is_mapped() {
313        // Completeness: the table must have exactly one entry per keyword enum variant.
314        let range_count = SyntaxKind::__KW_END as u16 - SyntaxKind::__KW_START as u16 - 1;
315        assert_eq!(
316            KEYWORDS.len() as u16,
317            range_count,
318            "KEYWORDS table is out of sync with the SyntaxKind keyword block"
319        );
320        let mut seen = std::collections::HashSet::new();
321        for (text, kind, _) in KEYWORDS {
322            assert_eq!(
323                keyword_kind(text),
324                Some(*kind),
325                "keyword_kind({text:?}) is wrong"
326            );
327            assert_eq!(
328                keyword_kind(&text.to_uppercase()),
329                Some(*kind),
330                "keyword_kind is not case-insensitive for {text:?}"
331            );
332            assert!(
333                kind.is_keyword(),
334                "{kind:?} should be inside the keyword range"
335            );
336            assert!(seen.insert(*kind), "duplicate keyword kind for {text:?}");
337            assert!(
338                text.bytes().all(|b| !b.is_ascii_uppercase()),
339                "KEYWORDS text must be lowercase: {text:?}"
340            );
341        }
342    }
343
344    #[test]
345    fn keywords_are_sorted_for_binary_search() {
346        for window in KEYWORDS.windows(2) {
347            let (left, _, _) = window[0];
348            let (right, _, _) = window[1];
349            assert!(
350                left < right,
351                "KEYWORDS must be sorted: {left:?} >= {right:?}"
352            );
353        }
354    }
355
356    #[test]
357    fn keyword_texts_exposes_the_lookup_table_order() {
358        let texts: Vec<_> = keyword_texts().collect();
359        assert_eq!(texts.len(), KEYWORDS.len());
360        assert_eq!(texts.first(), Some(&"after"));
361        assert_eq!(texts.last(), Some(&"within"));
362        for (text, (table_text, _, _)) in texts.iter().zip(KEYWORDS) {
363            assert_eq!(text, table_text);
364        }
365    }
366
367    #[test]
368    fn every_keyword_has_a_dialect_classification() {
369        // Completeness, dialect dimension: every keyword in the table carries one of the three
370        // classifications, so the reservation set cannot silently drift as keywords are added.
371        for (text, kind, dialect) in KEYWORDS {
372            assert!(
373                matches!(
374                    dialect,
375                    KeywordDialect::Shared
376                        | KeywordDialect::SnowflakeOnly
377                        | KeywordDialect::DatabricksOnly
378                ),
379                "{text:?} ({kind:?}) has no dialect classification"
380            );
381        }
382    }
383
384    #[test]
385    fn snowflake_classification_is_byte_identical_to_legacy_keyword_kind() {
386        // Under Snowflake, the dialect-aware lookup must agree with the plain `keyword_kind` for
387        // every keyword — the regression guard that Snowflake reservation is unchanged.
388        for (text, kind, _) in KEYWORDS {
389            assert_eq!(
390                keyword_kind_for(text, Dialect::Snowflake),
391                Some(*kind),
392                "Snowflake reservation changed for {text:?}"
393            );
394            assert_eq!(
395                keyword_kind(text),
396                keyword_kind_for(text, Dialect::Snowflake)
397            );
398        }
399    }
400
401    #[test]
402    fn shared_keywords_are_reserved_in_every_dialect() {
403        // Standard SQL words stay reserved under Databricks.
404        for word in ["select", "from", "where", "join", "group", "order", "case"] {
405            assert!(
406                keyword_kind_for(word, Dialect::Databricks).is_some(),
407                "{word}"
408            );
409            assert!(
410                keyword_kind_for(word, Dialect::Snowflake).is_some(),
411                "{word}"
412            );
413        }
414    }
415
416    #[test]
417    fn snowflake_only_keywords_are_identifiers_under_databricks() {
418        // The Snowflake-only DDL/feature words must drop their reservation under Databricks.
419        for word in [
420            "task",
421            "flatten",
422            "warehouse",
423            "schedule",
424            "transient",
425            "volatile",
426            "secure",
427            "undrop",
428            "elseif",
429            "cursor",
430            "resultset",
431            "connect",
432            "prior",
433            "top",
434            "copy",
435            "owner",
436        ] {
437            assert!(
438                keyword_kind_for(word, Dialect::Snowflake).is_some(),
439                "{word} should be reserved in Snowflake"
440            );
441            assert_eq!(
442                keyword_kind_for(word, Dialect::Databricks),
443                None,
444                "{word} must be a plain identifier under Databricks"
445            );
446        }
447    }
448}