Skip to main content

sql_dialect_fmt_lexer/
lexer.rs

1//! The single-pass tokenizer.
2
3use crate::token::{LexError, Lexed, Token};
4use crate::{BodyDelimiter, LexOptions};
5use sql_dialect_fmt_syntax::{Dialect, SyntaxKind};
6use sql_dialect_fmt_text::LineIndex;
7
8/// Tokenize Snowflake SQL into a lossless token stream.
9///
10/// The concatenation of `tokens[i].text` always equals `input`.
11pub fn tokenize(input: &str) -> Lexed<'_> {
12    tokenize_with_options(input, LexOptions::default())
13}
14
15pub fn tokenize_with_options<'a>(input: &'a str, options: LexOptions<'_>) -> Lexed<'a> {
16    Lexer::new(input, options).run()
17}
18
19/// Tokenize `input` for `dialect`, using that dialect's default lexing rules.
20///
21/// The dialect drives quoting and special-token behavior (`$$`/`$n` dollar handling, `@stage`
22/// references); see [`Dialect`].
23pub fn tokenize_for_dialect(input: &str, dialect: Dialect) -> Lexed<'_> {
24    tokenize_with_options(input, LexOptions::default().with_dialect(dialect))
25}
26
27struct Lexer<'a, 'cfg> {
28    input: &'a str,
29    bytes: &'a [u8],
30    line_index: LineIndex<'a>,
31    options: LexOptions<'cfg>,
32    pos: usize,
33    tokens: Vec<Token<'a>>,
34    errors: Vec<LexError>,
35}
36
37impl<'a, 'cfg> Lexer<'a, 'cfg> {
38    fn new(input: &'a str, options: LexOptions<'cfg>) -> Self {
39        Lexer {
40            input,
41            bytes: input.as_bytes(),
42            line_index: LineIndex::new(input),
43            options,
44            pos: 0,
45            tokens: Vec::new(),
46            errors: Vec::new(),
47        }
48    }
49
50    #[inline]
51    fn at_end(&self) -> bool {
52        self.pos >= self.bytes.len()
53    }
54
55    /// Current byte, or `0` at end of input (`0` never appears in valid SQL we care about).
56    #[inline]
57    fn peek(&self) -> u8 {
58        if self.pos < self.bytes.len() {
59            self.bytes[self.pos]
60        } else {
61            0
62        }
63    }
64
65    #[inline]
66    fn peek_at(&self, n: usize) -> u8 {
67        let i = self.pos + n;
68        if i < self.bytes.len() {
69            self.bytes[i]
70        } else {
71            0
72        }
73    }
74
75    /// Advance one byte and return it. Callers must ensure `!at_end()`.
76    #[inline]
77    fn bump(&mut self) -> u8 {
78        let c = self.bytes[self.pos];
79        self.pos += 1;
80        c
81    }
82
83    #[inline]
84    fn eat_while(&mut self, mut pred: impl FnMut(u8) -> bool) {
85        while !self.at_end() && pred(self.peek()) {
86            self.pos += 1;
87        }
88    }
89
90    #[inline]
91    fn starts_with(&self, text: &str) -> bool {
92        self.bytes[self.pos..].starts_with(text.as_bytes())
93    }
94
95    #[inline]
96    fn push(&mut self, kind: SyntaxKind, start: usize) {
97        // `self.input` is a `&'a str`; reborrowing through it yields a `&'a str` that does not
98        // borrow `self`, so this composes fine with the `&mut self` push.
99        let text = &self.input[start..self.pos];
100        self.tokens.push(Token { kind, text });
101    }
102
103    /// Record a lexical diagnostic spanning `offset..self.pos` (the offending token text consumed
104    /// so far). Called once the lexer has advanced past the start of the bad token, so the current
105    /// position marks the end of the span being reported.
106    #[inline]
107    fn error(&mut self, message: impl Into<String>, offset: usize) {
108        self.errors.push(LexError {
109            message: message.into(),
110            offset,
111            len: self.pos.saturating_sub(offset),
112            line_column: Some(self.line_index.line_column(offset)),
113        });
114    }
115
116    fn run(mut self) -> Lexed<'a> {
117        // Dialects that support dollar quoting (`$$ … $$`) treat such a run as one body token; the
118        // rest (none yet) lex the `$` characters as ordinary operators.
119        let dollar_quoting = self.options.dialect.supports_dollar_quoting();
120        while !self.at_end() {
121            let start = self.pos;
122            if dollar_quoting {
123                if let Some(delimiter) = self.body_delimiter_at() {
124                    self.delimited_body(start, delimiter);
125                    self.push(SyntaxKind::DOLLAR_STRING, start);
126                    continue;
127                }
128            }
129            match self.peek() {
130                b' ' | b'\t' => {
131                    self.eat_while(|c| c == b' ' || c == b'\t');
132                    self.push(SyntaxKind::WHITESPACE, start);
133                }
134                b'\n' => {
135                    self.pos += 1;
136                    self.push(SyntaxKind::NEWLINE, start);
137                }
138                b'\r' => {
139                    self.pos += 1;
140                    if self.peek() == b'\n' {
141                        self.pos += 1;
142                    }
143                    self.push(SyntaxKind::NEWLINE, start);
144                }
145                // Line comments: -- ...  and  // ...
146                b'-' if self.peek_at(1) == b'-' => {
147                    self.line_comment();
148                    self.push(SyntaxKind::COMMENT, start);
149                }
150                b'/' if self.options.dialect.supports_double_slash_comments()
151                    && self.peek_at(1) == b'/' =>
152                {
153                    self.line_comment();
154                    self.push(SyntaxKind::COMMENT, start);
155                }
156                // Block comment: /* ... */  (Snowflake block comments do not nest)
157                b'/' if self.peek_at(1) == b'*' => {
158                    self.block_comment(start);
159                    self.push(SyntaxKind::BLOCK_COMMENT, start);
160                }
161                b'\'' => {
162                    self.string_body(start, true);
163                    self.push(SyntaxKind::STRING, start);
164                }
165                b'r' | b'R'
166                    if self.options.dialect.supports_prefixed_strings()
167                        && self.peek_at(1) == b'\'' =>
168                {
169                    self.pos += 1; // raw-string prefix
170                    self.string_body(start, false);
171                    self.push(SyntaxKind::STRING, start);
172                }
173                b'x' | b'X'
174                    if self.options.dialect.supports_prefixed_strings()
175                        && self.peek_at(1) == b'\'' =>
176                {
177                    self.pos += 1; // hex-string prefix
178                    self.string_body(start, false);
179                    self.push(SyntaxKind::STRING, start);
180                }
181                b'"' => {
182                    self.quoted_ident_body(start);
183                    self.push(SyntaxKind::QUOTED_IDENT, start);
184                }
185                b'`' if self.options.dialect.supports_backtick_identifiers() => {
186                    self.backtick_ident_body(start);
187                    self.push(SyntaxKind::QUOTED_IDENT, start);
188                }
189                // `${ ... }` template-substitution placeholder. A `$` immediately followed by `{`
190                // opens a placeholder whose body is a balanced brace run: JS template-literal
191                // interpolation (`${cfg.table}`), Databricks / Spark / dbt variable substitution
192                // (`${env:VAR}`), and similar. Recognized in every dialect (SQL is commonly embedded
193                // in a host language), and lexed as one atomic token so the surrounding statement
194                // still parses, formats, and highlights instead of derailing on the braces.
195                b'$' if self.peek_at(1) == b'{' => {
196                    self.placeholder(start);
197                    self.push(SyntaxKind::PLACEHOLDER, start);
198                }
199                // $1 / $name variables (but not body delimiters, handled above). Gated on dollar
200                // quoting so non-Snowflake dialects lex a bare `$` as the DOLLAR operator instead.
201                b'$' if dollar_quoting
202                    && (is_ident_start(self.peek_at(1)) || self.peek_at(1).is_ascii_digit()) =>
203                {
204                    self.pos += 1; // $
205                    self.eat_while(is_ident_continue);
206                    self.push(SyntaxKind::VARIABLE, start);
207                }
208                c if c.is_ascii_digit() => self.number(start),
209                // Leading-dot float like `.5` (a bare `.` is the DOT operator, handled below).
210                b'.' if self.peek_at(1).is_ascii_digit() => self.number(start),
211                c if is_ident_start(c)
212                    && self.options.dialect.supports_stage_refs()
213                    && self.at_file_uri_start() =>
214                {
215                    self.file_uri();
216                    self.push(SyntaxKind::FILE_URI, start);
217                }
218                c if is_ident_start(c) => {
219                    self.eat_while(is_ident_continue);
220                    self.push(SyntaxKind::IDENT, start);
221                }
222                _ => self.operator(start),
223            }
224        }
225        Lexed {
226            tokens: self.tokens,
227            errors: self.errors,
228        }
229    }
230
231    /// Whether the current position begins an unquoted Snowflake client-file URI. Recognizing the
232    /// entire `file://...` run before identifier/comment dispatch prevents the URI's `//` from
233    /// being mistaken for a line comment.
234    fn at_file_uri_start(&self) -> bool {
235        self.bytes
236            .get(self.pos..self.pos.saturating_add(b"file://".len()))
237            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"file://"))
238    }
239
240    /// Consume an unquoted `file://...` URI through the next SQL separator. Paths containing
241    /// spaces or other separators must be single-quoted per Snowflake syntax and are therefore
242    /// handled by [`Self::string_body`] instead.
243    fn file_uri(&mut self) {
244        self.eat_while(|c| !c.is_ascii_whitespace() && c != b';');
245    }
246
247    /// Consume `--`/`//` and the rest of the physical line (not the newline itself).
248    fn line_comment(&mut self) {
249        self.pos += 2;
250        while !self.at_end() {
251            let c = self.peek();
252            if c == b'\n' || c == b'\r' {
253                break;
254            }
255            self.pos += 1;
256        }
257    }
258
259    /// Consume `/* ... */`. Records an error if unterminated. Non-nesting (Snowflake semantics).
260    fn block_comment(&mut self, start: usize) {
261        self.pos += 2; // /*
262        loop {
263            if self.at_end() {
264                self.error("unterminated block comment", start);
265                break;
266            }
267            if self.peek() == b'*' && self.peek_at(1) == b'/' {
268                self.pos += 2;
269                break;
270            }
271            self.pos += 1;
272        }
273    }
274
275    /// Consume a single-quoted string. Handles `''` (doubled quote) and `\` escapes
276    /// (Snowflake interprets backslash escape sequences in string literals by default).
277    fn string_body(&mut self, start: usize, backslash_escapes: bool) {
278        self.pos += 1; // opening '
279        loop {
280            if self.at_end() {
281                self.error("unterminated string literal", start);
282                break;
283            }
284            match self.bump() {
285                b'\\' if backslash_escapes => {
286                    // Escape: consume the next byte if present (e.g. \' or \\).
287                    if !self.at_end() {
288                        self.pos += 1;
289                    }
290                }
291                b'\'' => {
292                    if self.peek() == b'\'' {
293                        self.pos += 1; // doubled quote → escaped quote, keep going
294                    } else {
295                        break; // closing quote
296                    }
297                }
298                _ => {}
299            }
300        }
301    }
302
303    /// Consume a `"quoted identifier"`. Handles `""` (doubled quote). No backslash escapes.
304    fn quoted_ident_body(&mut self, start: usize) {
305        self.pos += 1; // opening "
306        loop {
307            if self.at_end() {
308                self.error("unterminated quoted identifier", start);
309                break;
310            }
311            if self.bump() == b'"' {
312                if self.peek() == b'"' {
313                    self.pos += 1; // doubled quote → escaped, keep going
314                } else {
315                    break; // closing quote
316                }
317            }
318        }
319    }
320
321    /// Consume a Databricks/Spark `` `quoted identifier` ``. Handles doubled backticks.
322    fn backtick_ident_body(&mut self, start: usize) {
323        self.pos += 1; // opening `
324        loop {
325            if self.at_end() {
326                self.error("unterminated backtick identifier", start);
327                break;
328            }
329            if self.bump() == b'`' {
330                if self.peek() == b'`' {
331                    self.pos += 1; // doubled backtick, keep going
332                } else {
333                    break; // closing backtick
334                }
335            }
336        }
337    }
338
339    fn body_delimiter_at(&self) -> Option<BodyDelimiter> {
340        self.options
341            .body_delimiters
342            .iter()
343            .copied()
344            .filter(|delimiter| {
345                !delimiter.opener.is_empty()
346                    && !delimiter.closer.is_empty()
347                    && self.starts_with(delimiter.opener)
348            })
349            .max_by_key(|delimiter| delimiter.opener.len())
350    }
351
352    /// Consume a delimited embedded body. The current Snowflake default is `$$...$$`.
353    fn delimited_body(&mut self, start: usize, delimiter: BodyDelimiter) {
354        self.pos += delimiter.opener.len();
355        loop {
356            if self.at_end() {
357                self.error(format!("unterminated {}", delimiter.name), start);
358                break;
359            }
360            if self.starts_with(delimiter.closer) {
361                self.pos += delimiter.closer.len();
362                break;
363            }
364            self.pos += 1;
365        }
366    }
367
368    /// Consume a `${ ... }` template-substitution placeholder, entered with the cursor on the
369    /// opening `$` (and `{` immediately after). Advances past the matching close brace.
370    ///
371    /// The body is scanned with an explicit context stack rather than a naive search for the next
372    /// `}`, so nested structure is handled exactly:
373    /// - nested braces balance (`${ fn({a: 1, b: [2]}) }` stays one token),
374    /// - a `}` inside a `'...'` / `"..."` string does not close the placeholder,
375    /// - a back-tick template literal switches to string context, where only `` ` `` closes it and
376    ///   an inner `${ ... }` opens a fresh nested placeholder scope (JS template nesting).
377    ///
378    /// The stack lives on the heap and grows at most one frame per opening brace/back-tick, so even
379    /// pathological input cannot overflow the call stack. An unterminated placeholder records an
380    /// error but still consumes to end of input, keeping the token lossless.
381    fn placeholder(&mut self, start: usize) {
382        self.pos += 2; // opening `${`
383        let mut stack = vec![PlaceholderCtx::Brace];
384        while let Some(ctx) = stack.last().copied() {
385            if self.at_end() {
386                self.error("unterminated placeholder", start);
387                return;
388            }
389            match ctx {
390                // JS-expression context: braces are structural, strings are skipped wholesale.
391                PlaceholderCtx::Brace => match self.peek() {
392                    b'{' => {
393                        self.pos += 1;
394                        stack.push(PlaceholderCtx::Brace);
395                    }
396                    b'}' => {
397                        self.pos += 1;
398                        stack.pop();
399                    }
400                    b'\'' => self.skip_placeholder_string(b'\''),
401                    b'"' => self.skip_placeholder_string(b'"'),
402                    b'`' => {
403                        self.pos += 1;
404                        stack.push(PlaceholderCtx::Template);
405                    }
406                    _ => self.pos += 1,
407                },
408                // Template-literal context: bare braces are text; only `` ` `` closes, and `${`
409                // opens a nested interpolation scope.
410                PlaceholderCtx::Template => match self.peek() {
411                    b'\\' => {
412                        self.pos += 1;
413                        if !self.at_end() {
414                            self.pos += 1; // escaped character
415                        }
416                    }
417                    b'`' => {
418                        self.pos += 1;
419                        stack.pop();
420                    }
421                    b'$' if self.peek_at(1) == b'{' => {
422                        self.pos += 2;
423                        stack.push(PlaceholderCtx::Brace);
424                    }
425                    _ => self.pos += 1,
426                },
427            }
428        }
429    }
430
431    /// Skip a `'...'` / `"..."` string inside a placeholder body so a `}` within it does not close
432    /// the placeholder. Honors backslash escapes (JS interpolation semantics). Entered with the
433    /// cursor on the opening quote; returns with the cursor past the closing quote, or at end of
434    /// input if the string is unterminated (the caller then reports the placeholder as unterminated).
435    fn skip_placeholder_string(&mut self, quote: u8) {
436        self.pos += 1; // opening quote
437        while !self.at_end() {
438            match self.bump() {
439                b'\\' => {
440                    if !self.at_end() {
441                        self.pos += 1; // escaped character
442                    }
443                }
444                c if c == quote => return,
445                _ => {}
446            }
447        }
448    }
449
450    /// Lex a numeric literal. Entered on a digit or on `.` immediately followed by a digit.
451    fn number(&mut self, start: usize) {
452        let mut is_float = false;
453        if self.peek() == b'.' {
454            // Leading-dot float: `.5`
455            is_float = true;
456            self.pos += 1;
457            self.eat_while(|c| c.is_ascii_digit());
458        } else {
459            self.eat_while(|c| c.is_ascii_digit());
460            if self.peek() == b'.' {
461                // Fractional part (or a trailing dot, e.g. `100.`).
462                is_float = true;
463                self.pos += 1;
464                self.eat_while(|c| c.is_ascii_digit());
465            }
466        }
467        // Optional exponent: e / E [ + | - ] digits. Backtrack if no digits follow.
468        if self.peek() == b'e' || self.peek() == b'E' {
469            let save = self.pos;
470            self.pos += 1;
471            if self.peek() == b'+' || self.peek() == b'-' {
472                self.pos += 1;
473            }
474            if self.peek().is_ascii_digit() {
475                is_float = true;
476                self.eat_while(|c| c.is_ascii_digit());
477            } else {
478                self.pos = save; // not actually an exponent
479            }
480        }
481        self.push(
482            if is_float {
483                SyntaxKind::FLOAT_NUMBER
484            } else {
485                SyntaxKind::INT_NUMBER
486            },
487            start,
488        );
489    }
490
491    /// Lex punctuation / operators (everything not handled by the dispatch in `run`).
492    fn operator(&mut self, start: usize) {
493        let c = self.peek();
494        // A stray multi-byte (non-ASCII) char outside any literal: consume the whole char so
495        // we never slice the &str off a UTF-8 boundary, and report it. `c >= 0x80` guarantees a
496        // char starts here, but fall back to a single byte rather than panic if that ever changes.
497        if c >= 0x80 {
498            if let Some(ch) = self.input[self.pos..].chars().next() {
499                self.pos += ch.len_utf8();
500                self.error(format!("unexpected character {ch:?}"), start);
501            } else {
502                self.pos += 1;
503                self.error("unexpected byte", start);
504            }
505            self.push(SyntaxKind::ERROR, start);
506            return;
507        }
508
509        self.pos += 1; // consume `c`
510        let kind = match c {
511            b'(' => SyntaxKind::L_PAREN,
512            b')' => SyntaxKind::R_PAREN,
513            b'[' => SyntaxKind::L_BRACKET,
514            b']' => SyntaxKind::R_BRACKET,
515            b'{' => SyntaxKind::L_BRACE,
516            b'}' => SyntaxKind::R_BRACE,
517            b',' => SyntaxKind::COMMA,
518            b';' => SyntaxKind::SEMICOLON,
519            b'+' => SyntaxKind::PLUS,
520            b'*' => SyntaxKind::STAR,
521            b'/' => SyntaxKind::SLASH,
522            b'%' => SyntaxKind::PERCENT,
523            b'&' => SyntaxKind::AMP,
524            b'^' => SyntaxKind::CARET,
525            b'~' => SyntaxKind::TILDE,
526            // `@` introduces stage references (`@stage`, `@~`, `@%table`). Dialects without stage
527            // refs (none yet) treat it as an unexpected character rather than the AT operator.
528            b'@' if self.options.dialect.supports_stage_refs() => SyntaxKind::AT,
529            b'?' => SyntaxKind::QUESTION,
530            b'.' => SyntaxKind::DOT,
531            b'$' => SyntaxKind::DOLLAR,
532            b'=' => {
533                if self.peek() == b'>' {
534                    self.pos += 1;
535                    SyntaxKind::FAT_ARROW
536                } else {
537                    SyntaxKind::EQ
538                }
539            }
540            b'!' => {
541                if self.peek() == b'=' {
542                    self.pos += 1;
543                    SyntaxKind::NEQ
544                } else {
545                    self.error("unexpected '!'", start);
546                    SyntaxKind::BANG
547                }
548            }
549            b'<' => {
550                if self.options.dialect.supports_null_safe_eq()
551                    && self.peek() == b'='
552                    && self.peek_at(1) == b'>'
553                {
554                    self.pos += 2;
555                    SyntaxKind::NULL_SAFE_EQ
556                } else if self.peek() == b'=' {
557                    self.pos += 1;
558                    SyntaxKind::LTE
559                } else if self.peek() == b'>' {
560                    self.pos += 1;
561                    SyntaxKind::NEQ
562                } else {
563                    SyntaxKind::LT
564                }
565            }
566            b'>' => {
567                if self.peek() == b'=' {
568                    self.pos += 1;
569                    SyntaxKind::GTE
570                } else {
571                    SyntaxKind::GT
572                }
573            }
574            b':' => {
575                if self.peek() == b':' {
576                    self.pos += 1;
577                    SyntaxKind::COLON2
578                } else if self.peek() == b'=' {
579                    self.pos += 1;
580                    SyntaxKind::ASSIGN
581                } else {
582                    SyntaxKind::COLON
583                }
584            }
585            b'-' => {
586                if self.peek() == b'>' && self.peek_at(1) == b'>' {
587                    self.pos += 2;
588                    SyntaxKind::FLOW_PIPE
589                } else if self.peek() == b'>' {
590                    self.pos += 1;
591                    SyntaxKind::ARROW
592                } else {
593                    SyntaxKind::MINUS
594                }
595            }
596            b'|' => {
597                if self.peek() == b'>' {
598                    self.pos += 1;
599                    SyntaxKind::PIPE_GT
600                } else if self.peek() == b'|' {
601                    self.pos += 1;
602                    SyntaxKind::CONCAT
603                } else {
604                    SyntaxKind::PIPE
605                }
606            }
607            other => {
608                self.error(format!("unexpected character {:?}", other as char), start);
609                SyntaxKind::ERROR
610            }
611        };
612        self.push(kind, start);
613    }
614}
615
616/// A scope on the placeholder scanner's context stack. See [`Lexer::placeholder`].
617#[derive(Clone, Copy)]
618enum PlaceholderCtx {
619    /// JS-expression / brace scope, opened by `{` (or the initial `${`) and closed by `}`.
620    Brace,
621    /// Back-tick template-literal scope, opened and closed by `` ` ``.
622    Template,
623}
624
625#[inline]
626fn is_ident_start(c: u8) -> bool {
627    c.is_ascii_alphabetic() || c == b'_'
628}
629
630#[inline]
631fn is_ident_continue(c: u8) -> bool {
632    c.is_ascii_alphanumeric() || c == b'_' || c == b'$'
633}