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.
53const KEYWORDS: &[(&str, SyntaxKind, KeywordDialect)] = {
54    use KeywordDialect::{Shared, SnowflakeOnly};
55    use SyntaxKind::*;
56    &[
57        ("select", SELECT_KW, Shared),
58        ("from", FROM_KW, Shared),
59        ("where", WHERE_KW, Shared),
60        ("group", GROUP_KW, Shared),
61        ("by", BY_KW, Shared),
62        ("having", HAVING_KW, Shared),
63        ("order", ORDER_KW, Shared),
64        ("limit", LIMIT_KW, Shared),
65        ("offset", OFFSET_KW, Shared),
66        ("fetch", FETCH_KW, Shared),
67        // Snowflake's row-limiting `TOP n`; not a Spark keyword.
68        ("top", TOP_KW, SnowflakeOnly),
69        ("as", AS_KW, Shared),
70        ("and", AND_KW, Shared),
71        ("or", OR_KW, Shared),
72        ("not", NOT_KW, Shared),
73        ("null", NULL_KW, Shared),
74        ("is", IS_KW, Shared),
75        ("in", IN_KW, Shared),
76        ("like", LIKE_KW, Shared),
77        // ILIKE/RLIKE/REGEXP: Snowflake operators; non-reserved in Spark, so identifiers there.
78        ("ilike", ILIKE_KW, SnowflakeOnly),
79        ("rlike", RLIKE_KW, SnowflakeOnly),
80        ("regexp", REGEXP_KW, SnowflakeOnly),
81        ("between", BETWEEN_KW, Shared),
82        ("case", CASE_KW, Shared),
83        ("when", WHEN_KW, Shared),
84        ("then", THEN_KW, Shared),
85        ("else", ELSE_KW, Shared),
86        ("end", END_KW, Shared),
87        ("join", JOIN_KW, Shared),
88        ("inner", INNER_KW, Shared),
89        ("left", LEFT_KW, Shared),
90        ("right", RIGHT_KW, Shared),
91        ("full", FULL_KW, Shared),
92        ("outer", OUTER_KW, Shared),
93        ("cross", CROSS_KW, Shared),
94        ("lateral", LATERAL_KW, Shared),
95        ("natural", NATURAL_KW, Shared),
96        ("on", ON_KW, Shared),
97        ("using", USING_KW, Shared),
98        ("with", WITH_KW, Shared),
99        ("recursive", RECURSIVE_KW, Shared),
100        ("union", UNION_KW, Shared),
101        ("all", ALL_KW, Shared),
102        ("any", ANY_KW, Shared),
103        ("except", EXCEPT_KW, Shared),
104        ("intersect", INTERSECT_KW, Shared),
105        ("minus", MINUS_KW, Shared),
106        ("distinct", DISTINCT_KW, Shared),
107        // QUALIFY: a window-filter clause in BOTH dialects. Databricks SQL supports `SELECT ...
108        // QUALIFY <predicate>` (Databricks Runtime 10.4 LTS+), so it must stay reserved under
109        // Databricks too — otherwise the parser treats it as a plain identifier and mis-splits the
110        // query. Reserving it in both dialects leaves Snowflake byte-identical (it was reserved
111        // there already).
112        ("qualify", QUALIFY_KW, Shared),
113        ("over", OVER_KW, Shared),
114        ("partition", PARTITION_KW, Shared),
115        ("window", WINDOW_KW, Shared),
116        ("rows", ROWS_KW, Shared),
117        ("range", RANGE_KW, Shared),
118        ("unbounded", UNBOUNDED_KW, Shared),
119        ("preceding", PRECEDING_KW, Shared),
120        ("following", FOLLOWING_KW, Shared),
121        ("current", CURRENT_KW, Shared),
122        ("row", ROW_KW, Shared),
123        ("asc", ASC_KW, Shared),
124        ("desc", DESC_KW, Shared),
125        ("nulls", NULLS_KW, Shared),
126        ("first", FIRST_KW, Shared),
127        ("last", LAST_KW, Shared),
128        ("true", TRUE_KW, Shared),
129        ("false", FALSE_KW, Shared),
130        ("cast", CAST_KW, Shared),
131        // TRY_CAST is the function `try_cast(...)` in Spark, not a structural keyword.
132        ("try_cast", TRY_CAST_KW, SnowflakeOnly),
133        ("exists", EXISTS_KW, Shared),
134        ("values", VALUES_KW, Shared),
135        ("pivot", PIVOT_KW, Shared),
136        ("unpivot", UNPIVOT_KW, Shared),
137        // SAMPLE: Snowflake spelling; absent from the Spark keyword table (`TABLESAMPLE` is shared).
138        ("sample", SAMPLE_KW, SnowflakeOnly),
139        ("tablesample", TABLESAMPLE_KW, Shared),
140        ("create", CREATE_KW, Shared),
141        ("replace", REPLACE_KW, Shared),
142        ("if", IF_KW, Shared),
143        ("table", TABLE_KW, Shared),
144        ("view", VIEW_KW, Shared),
145        ("temporary", TEMPORARY_KW, Shared),
146        ("temp", TEMP_KW, Shared),
147        // Snowflake table-property words; not Spark keywords.
148        ("transient", TRANSIENT_KW, SnowflakeOnly),
149        ("volatile", VOLATILE_KW, SnowflakeOnly),
150        ("secure", SECURE_KW, SnowflakeOnly),
151        ("insert", INSERT_KW, Shared),
152        ("into", INTO_KW, Shared),
153        ("update", UPDATE_KW, Shared),
154        ("delete", DELETE_KW, Shared),
155        ("merge", MERGE_KW, Shared),
156        ("set", SET_KW, Shared),
157        // FLATTEN: Snowflake table function; not a Spark keyword.
158        ("flatten", FLATTEN_KW, SnowflakeOnly),
159        // CONNECT/PRIOR: Snowflake hierarchical `CONNECT BY`; absent from the Spark keyword table.
160        ("connect", CONNECT_KW, SnowflakeOnly),
161        ("start", START_KW, Shared),
162        ("prior", PRIOR_KW, SnowflakeOnly),
163        ("language", LANGUAGE_KW, Shared),
164        // JAVASCRIPT/SCALA: Snowflake `LANGUAGE` values, not Spark keywords. (JAVA/PYTHON/SQL also
165        // serve as type/language words common to both, so they stay shared.)
166        ("javascript", JAVASCRIPT_KW, SnowflakeOnly),
167        ("python", PYTHON_KW, Shared),
168        ("java", JAVA_KW, Shared),
169        ("scala", SCALA_KW, SnowflakeOnly),
170        ("sql", SQL_KW, Shared),
171        ("begin", BEGIN_KW, Shared),
172        ("declare", DECLARE_KW, Shared),
173        ("let", LET_KW, Shared),
174        ("return", RETURN_KW, Shared),
175        ("call", CALL_KW, Shared),
176        ("procedure", PROCEDURE_KW, Shared),
177        ("function", FUNCTION_KW, Shared),
178        ("returns", RETURNS_KW, Shared),
179        // Snowflake object DDL / scripting words absent from the Spark keyword table.
180        ("task", TASK_KW, SnowflakeOnly),
181        ("warehouse", WAREHOUSE_KW, SnowflakeOnly),
182        ("schedule", SCHEDULE_KW, SnowflakeOnly),
183        ("after", AFTER_KW, Shared),
184        ("copy", COPY_KW, SnowflakeOnly),
185        ("grants", GRANTS_KW, SnowflakeOnly),
186        ("handler", HANDLER_KW, SnowflakeOnly),
187        ("packages", PACKAGES_KW, SnowflakeOnly),
188        ("imports", IMPORTS_KW, SnowflakeOnly),
189        ("runtime_version", RUNTIME_VERSION_KW, SnowflakeOnly),
190        ("execute", EXECUTE_KW, Shared),
191        ("owner", OWNER_KW, SnowflakeOnly),
192        ("caller", CALLER_KW, SnowflakeOnly),
193        ("strict", STRICT_KW, SnowflakeOnly),
194        ("called", CALLED_KW, SnowflakeOnly),
195        ("input", INPUT_KW, Shared),
196        ("output", OUTPUT_KW, Shared),
197        ("out", OUT_KW, Shared),
198        ("matched", MATCHED_KW, Shared),
199        ("drop", DROP_KW, Shared),
200        ("alter", ALTER_KW, Shared),
201        ("within", WITHIN_KW, Shared),
202        ("for", FOR_KW, Shared),
203        ("immediate", IMMEDIATE_KW, SnowflakeOnly),
204        ("overwrite", OVERWRITE_KW, Shared),
205        ("grant", GRANT_KW, Shared),
206        ("revoke", REVOKE_KW, Shared),
207        ("use", USE_KW, Shared),
208        ("show", SHOW_KW, Shared),
209        ("describe", DESCRIBE_KW, Shared),
210        ("truncate", TRUNCATE_KW, Shared),
211        ("commit", COMMIT_KW, Shared),
212        ("rollback", ROLLBACK_KW, Shared),
213        // Snowflake scripting / object words absent from the Spark keyword table.
214        ("undrop", UNDROP_KW, SnowflakeOnly),
215        ("elseif", ELSEIF_KW, SnowflakeOnly),
216        ("while", WHILE_KW, Shared),
217        ("loop", LOOP_KW, Shared),
218        ("repeat", REPEAT_KW, Shared),
219        ("until", UNTIL_KW, Shared),
220        ("do", DO_KW, Shared),
221        ("exception", EXCEPTION_KW, SnowflakeOnly),
222        ("cursor", CURSOR_KW, SnowflakeOnly),
223        ("resultset", RESULTSET_KW, SnowflakeOnly),
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        .iter()
253        .find(|(text, _, _)| *text == lower)
254        .map(|(_, kind, dialect)| (*kind, *dialect))
255}
256
257/// Map an identifier's text to its keyword kind, case-insensitively, using **Snowflake** semantics.
258///
259/// Snowflake folds unquoted identifiers and matches keywords without regard to case. Returns `None`
260/// for plain identifiers — the lexer emits [`SyntaxKind::IDENT`] for every word and the parser uses
261/// this to reclassify keywords contextually. Kept for backward compatibility; prefer
262/// [`keyword_kind_for`] when a [`Dialect`] is in hand.
263#[must_use]
264pub fn keyword_kind(ident: &str) -> Option<SyntaxKind> {
265    keyword_kind_for(ident, Dialect::Snowflake)
266}
267
268/// Like [`keyword_kind`], but a word counts as a keyword only when `dialect` reserves it.
269///
270/// A Snowflake-only word (e.g. `TASK`, `FLATTEN`) returns its keyword kind under
271/// [`Dialect::Snowflake`] but `None` under [`Dialect::Databricks`], where it is an ordinary
272/// identifier. Shared keywords behave identically in every dialect, so under
273/// [`Dialect::Snowflake`] this is byte-for-byte equivalent to [`keyword_kind`].
274#[must_use]
275pub fn keyword_kind_for(ident: &str, dialect: Dialect) -> Option<SyntaxKind> {
276    let (kind, kw_dialect) = lookup(ident)?;
277    kw_dialect.reserved_in(dialect).then_some(kind)
278}
279
280#[cfg(test)]
281mod tests {
282    use super::{keyword_kind, keyword_kind_for, KeywordDialect, KEYWORDS};
283    use crate::{Dialect, SyntaxKind};
284
285    #[test]
286    fn keyword_lookup_is_case_insensitive() {
287        assert_eq!(keyword_kind("select"), Some(SyntaxKind::SELECT_KW));
288        assert_eq!(keyword_kind("SeLeCt"), Some(SyntaxKind::SELECT_KW));
289        assert_eq!(keyword_kind("QUALIFY"), Some(SyntaxKind::QUALIFY_KW));
290        assert_eq!(keyword_kind("javascript"), Some(SyntaxKind::JAVASCRIPT_KW));
291        assert_eq!(keyword_kind("try_cast"), Some(SyntaxKind::TRY_CAST_KW));
292        assert_eq!(keyword_kind("TASK"), Some(SyntaxKind::TASK_KW));
293        assert_eq!(
294            keyword_kind("runtime_version"),
295            Some(SyntaxKind::RUNTIME_VERSION_KW)
296        );
297        assert_eq!(keyword_kind("definitely_not_a_keyword"), None);
298    }
299
300    #[test]
301    fn every_keyword_variant_is_mapped() {
302        // Completeness: the table must have exactly one entry per keyword enum variant.
303        let range_count = SyntaxKind::__KW_END as u16 - SyntaxKind::__KW_START as u16 - 1;
304        assert_eq!(
305            KEYWORDS.len() as u16,
306            range_count,
307            "KEYWORDS table is out of sync with the SyntaxKind keyword block"
308        );
309        let mut seen = std::collections::HashSet::new();
310        for (text, kind, _) in KEYWORDS {
311            assert_eq!(
312                keyword_kind(text),
313                Some(*kind),
314                "keyword_kind({text:?}) is wrong"
315            );
316            assert_eq!(
317                keyword_kind(&text.to_uppercase()),
318                Some(*kind),
319                "keyword_kind is not case-insensitive for {text:?}"
320            );
321            assert!(
322                kind.is_keyword(),
323                "{kind:?} should be inside the keyword range"
324            );
325            assert!(seen.insert(*kind), "duplicate keyword kind for {text:?}");
326            assert!(
327                text.bytes().all(|b| !b.is_ascii_uppercase()),
328                "KEYWORDS text must be lowercase: {text:?}"
329            );
330        }
331    }
332
333    #[test]
334    fn every_keyword_has_a_dialect_classification() {
335        // Completeness, dialect dimension: every keyword in the table carries one of the three
336        // classifications, so the reservation set cannot silently drift as keywords are added.
337        for (text, kind, dialect) in KEYWORDS {
338            assert!(
339                matches!(
340                    dialect,
341                    KeywordDialect::Shared
342                        | KeywordDialect::SnowflakeOnly
343                        | KeywordDialect::DatabricksOnly
344                ),
345                "{text:?} ({kind:?}) has no dialect classification"
346            );
347        }
348    }
349
350    #[test]
351    fn snowflake_classification_is_byte_identical_to_legacy_keyword_kind() {
352        // Under Snowflake, the dialect-aware lookup must agree with the plain `keyword_kind` for
353        // every keyword — the regression guard that Snowflake reservation is unchanged.
354        for (text, kind, _) in KEYWORDS {
355            assert_eq!(
356                keyword_kind_for(text, Dialect::Snowflake),
357                Some(*kind),
358                "Snowflake reservation changed for {text:?}"
359            );
360            assert_eq!(
361                keyword_kind(text),
362                keyword_kind_for(text, Dialect::Snowflake)
363            );
364        }
365    }
366
367    #[test]
368    fn shared_keywords_are_reserved_in_every_dialect() {
369        // Standard SQL words stay reserved under Databricks.
370        for word in ["select", "from", "where", "join", "group", "order", "case"] {
371            assert!(
372                keyword_kind_for(word, Dialect::Databricks).is_some(),
373                "{word}"
374            );
375            assert!(
376                keyword_kind_for(word, Dialect::Snowflake).is_some(),
377                "{word}"
378            );
379        }
380    }
381
382    #[test]
383    fn snowflake_only_keywords_are_identifiers_under_databricks() {
384        // The Snowflake-only DDL/feature words must drop their reservation under Databricks.
385        for word in [
386            "task",
387            "flatten",
388            "warehouse",
389            "schedule",
390            "transient",
391            "volatile",
392            "secure",
393            "undrop",
394            "elseif",
395            "cursor",
396            "resultset",
397            "connect",
398            "prior",
399            "top",
400            "copy",
401            "owner",
402        ] {
403            assert!(
404                keyword_kind_for(word, Dialect::Snowflake).is_some(),
405                "{word} should be reserved in Snowflake"
406            );
407            assert_eq!(
408                keyword_kind_for(word, Dialect::Databricks),
409                None,
410                "{word} must be a plain identifier under Databricks"
411            );
412        }
413    }
414}