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                // $1 / $name variables (but not body delimiters, handled above). Gated on dollar
190                // quoting so non-Snowflake dialects lex a bare `$` as the DOLLAR operator instead.
191                b'$' if dollar_quoting
192                    && (is_ident_start(self.peek_at(1)) || self.peek_at(1).is_ascii_digit()) =>
193                {
194                    self.pos += 1; // $
195                    self.eat_while(is_ident_continue);
196                    self.push(SyntaxKind::VARIABLE, start);
197                }
198                c if c.is_ascii_digit() => self.number(start),
199                // Leading-dot float like `.5` (a bare `.` is the DOT operator, handled below).
200                b'.' if self.peek_at(1).is_ascii_digit() => self.number(start),
201                c if is_ident_start(c)
202                    && self.options.dialect.supports_stage_refs()
203                    && self.at_file_uri_start() =>
204                {
205                    self.file_uri();
206                    self.push(SyntaxKind::FILE_URI, start);
207                }
208                c if is_ident_start(c) => {
209                    self.eat_while(is_ident_continue);
210                    self.push(SyntaxKind::IDENT, start);
211                }
212                _ => self.operator(start),
213            }
214        }
215        Lexed {
216            tokens: self.tokens,
217            errors: self.errors,
218        }
219    }
220
221    /// Whether the current position begins an unquoted Snowflake client-file URI. Recognizing the
222    /// entire `file://...` run before identifier/comment dispatch prevents the URI's `//` from
223    /// being mistaken for a line comment.
224    fn at_file_uri_start(&self) -> bool {
225        self.bytes
226            .get(self.pos..self.pos.saturating_add(b"file://".len()))
227            .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"file://"))
228    }
229
230    /// Consume an unquoted `file://...` URI through the next SQL separator. Paths containing
231    /// spaces or other separators must be single-quoted per Snowflake syntax and are therefore
232    /// handled by [`Self::string_body`] instead.
233    fn file_uri(&mut self) {
234        self.eat_while(|c| !c.is_ascii_whitespace() && c != b';');
235    }
236
237    /// Consume `--`/`//` and the rest of the physical line (not the newline itself).
238    fn line_comment(&mut self) {
239        self.pos += 2;
240        while !self.at_end() {
241            let c = self.peek();
242            if c == b'\n' || c == b'\r' {
243                break;
244            }
245            self.pos += 1;
246        }
247    }
248
249    /// Consume `/* ... */`. Records an error if unterminated. Non-nesting (Snowflake semantics).
250    fn block_comment(&mut self, start: usize) {
251        self.pos += 2; // /*
252        loop {
253            if self.at_end() {
254                self.error("unterminated block comment", start);
255                break;
256            }
257            if self.peek() == b'*' && self.peek_at(1) == b'/' {
258                self.pos += 2;
259                break;
260            }
261            self.pos += 1;
262        }
263    }
264
265    /// Consume a single-quoted string. Handles `''` (doubled quote) and `\` escapes
266    /// (Snowflake interprets backslash escape sequences in string literals by default).
267    fn string_body(&mut self, start: usize, backslash_escapes: bool) {
268        self.pos += 1; // opening '
269        loop {
270            if self.at_end() {
271                self.error("unterminated string literal", start);
272                break;
273            }
274            match self.bump() {
275                b'\\' if backslash_escapes => {
276                    // Escape: consume the next byte if present (e.g. \' or \\).
277                    if !self.at_end() {
278                        self.pos += 1;
279                    }
280                }
281                b'\'' => {
282                    if self.peek() == b'\'' {
283                        self.pos += 1; // doubled quote → escaped quote, keep going
284                    } else {
285                        break; // closing quote
286                    }
287                }
288                _ => {}
289            }
290        }
291    }
292
293    /// Consume a `"quoted identifier"`. Handles `""` (doubled quote). No backslash escapes.
294    fn quoted_ident_body(&mut self, start: usize) {
295        self.pos += 1; // opening "
296        loop {
297            if self.at_end() {
298                self.error("unterminated quoted identifier", start);
299                break;
300            }
301            if self.bump() == b'"' {
302                if self.peek() == b'"' {
303                    self.pos += 1; // doubled quote → escaped, keep going
304                } else {
305                    break; // closing quote
306                }
307            }
308        }
309    }
310
311    /// Consume a Databricks/Spark `` `quoted identifier` ``. Handles doubled backticks.
312    fn backtick_ident_body(&mut self, start: usize) {
313        self.pos += 1; // opening `
314        loop {
315            if self.at_end() {
316                self.error("unterminated backtick identifier", start);
317                break;
318            }
319            if self.bump() == b'`' {
320                if self.peek() == b'`' {
321                    self.pos += 1; // doubled backtick, keep going
322                } else {
323                    break; // closing backtick
324                }
325            }
326        }
327    }
328
329    fn body_delimiter_at(&self) -> Option<BodyDelimiter> {
330        self.options
331            .body_delimiters
332            .iter()
333            .copied()
334            .filter(|delimiter| {
335                !delimiter.opener.is_empty()
336                    && !delimiter.closer.is_empty()
337                    && self.starts_with(delimiter.opener)
338            })
339            .max_by_key(|delimiter| delimiter.opener.len())
340    }
341
342    /// Consume a delimited embedded body. The current Snowflake default is `$$...$$`.
343    fn delimited_body(&mut self, start: usize, delimiter: BodyDelimiter) {
344        self.pos += delimiter.opener.len();
345        loop {
346            if self.at_end() {
347                self.error(format!("unterminated {}", delimiter.name), start);
348                break;
349            }
350            if self.starts_with(delimiter.closer) {
351                self.pos += delimiter.closer.len();
352                break;
353            }
354            self.pos += 1;
355        }
356    }
357
358    /// Lex a numeric literal. Entered on a digit or on `.` immediately followed by a digit.
359    fn number(&mut self, start: usize) {
360        let mut is_float = false;
361        if self.peek() == b'.' {
362            // Leading-dot float: `.5`
363            is_float = true;
364            self.pos += 1;
365            self.eat_while(|c| c.is_ascii_digit());
366        } else {
367            self.eat_while(|c| c.is_ascii_digit());
368            if self.peek() == b'.' {
369                // Fractional part (or a trailing dot, e.g. `100.`).
370                is_float = true;
371                self.pos += 1;
372                self.eat_while(|c| c.is_ascii_digit());
373            }
374        }
375        // Optional exponent: e / E [ + | - ] digits. Backtrack if no digits follow.
376        if self.peek() == b'e' || self.peek() == b'E' {
377            let save = self.pos;
378            self.pos += 1;
379            if self.peek() == b'+' || self.peek() == b'-' {
380                self.pos += 1;
381            }
382            if self.peek().is_ascii_digit() {
383                is_float = true;
384                self.eat_while(|c| c.is_ascii_digit());
385            } else {
386                self.pos = save; // not actually an exponent
387            }
388        }
389        self.push(
390            if is_float {
391                SyntaxKind::FLOAT_NUMBER
392            } else {
393                SyntaxKind::INT_NUMBER
394            },
395            start,
396        );
397    }
398
399    /// Lex punctuation / operators (everything not handled by the dispatch in `run`).
400    fn operator(&mut self, start: usize) {
401        let c = self.peek();
402        // A stray multi-byte (non-ASCII) char outside any literal: consume the whole char so
403        // we never slice the &str off a UTF-8 boundary, and report it. `c >= 0x80` guarantees a
404        // char starts here, but fall back to a single byte rather than panic if that ever changes.
405        if c >= 0x80 {
406            if let Some(ch) = self.input[self.pos..].chars().next() {
407                self.pos += ch.len_utf8();
408                self.error(format!("unexpected character {ch:?}"), start);
409            } else {
410                self.pos += 1;
411                self.error("unexpected byte", start);
412            }
413            self.push(SyntaxKind::ERROR, start);
414            return;
415        }
416
417        self.pos += 1; // consume `c`
418        let kind = match c {
419            b'(' => SyntaxKind::L_PAREN,
420            b')' => SyntaxKind::R_PAREN,
421            b'[' => SyntaxKind::L_BRACKET,
422            b']' => SyntaxKind::R_BRACKET,
423            b'{' => SyntaxKind::L_BRACE,
424            b'}' => SyntaxKind::R_BRACE,
425            b',' => SyntaxKind::COMMA,
426            b';' => SyntaxKind::SEMICOLON,
427            b'+' => SyntaxKind::PLUS,
428            b'*' => SyntaxKind::STAR,
429            b'/' => SyntaxKind::SLASH,
430            b'%' => SyntaxKind::PERCENT,
431            b'&' => SyntaxKind::AMP,
432            b'^' => SyntaxKind::CARET,
433            b'~' => SyntaxKind::TILDE,
434            // `@` introduces stage references (`@stage`, `@~`, `@%table`). Dialects without stage
435            // refs (none yet) treat it as an unexpected character rather than the AT operator.
436            b'@' if self.options.dialect.supports_stage_refs() => SyntaxKind::AT,
437            b'?' => SyntaxKind::QUESTION,
438            b'.' => SyntaxKind::DOT,
439            b'$' => SyntaxKind::DOLLAR,
440            b'=' => {
441                if self.peek() == b'>' {
442                    self.pos += 1;
443                    SyntaxKind::FAT_ARROW
444                } else {
445                    SyntaxKind::EQ
446                }
447            }
448            b'!' => {
449                if self.peek() == b'=' {
450                    self.pos += 1;
451                    SyntaxKind::NEQ
452                } else {
453                    self.error("unexpected '!'", start);
454                    SyntaxKind::BANG
455                }
456            }
457            b'<' => {
458                if self.options.dialect.supports_null_safe_eq()
459                    && self.peek() == b'='
460                    && self.peek_at(1) == b'>'
461                {
462                    self.pos += 2;
463                    SyntaxKind::NULL_SAFE_EQ
464                } else if self.peek() == b'=' {
465                    self.pos += 1;
466                    SyntaxKind::LTE
467                } else if self.peek() == b'>' {
468                    self.pos += 1;
469                    SyntaxKind::NEQ
470                } else {
471                    SyntaxKind::LT
472                }
473            }
474            b'>' => {
475                if self.peek() == b'=' {
476                    self.pos += 1;
477                    SyntaxKind::GTE
478                } else {
479                    SyntaxKind::GT
480                }
481            }
482            b':' => {
483                if self.peek() == b':' {
484                    self.pos += 1;
485                    SyntaxKind::COLON2
486                } else if self.peek() == b'=' {
487                    self.pos += 1;
488                    SyntaxKind::ASSIGN
489                } else {
490                    SyntaxKind::COLON
491                }
492            }
493            b'-' => {
494                if self.peek() == b'>' && self.peek_at(1) == b'>' {
495                    self.pos += 2;
496                    SyntaxKind::FLOW_PIPE
497                } else if self.peek() == b'>' {
498                    self.pos += 1;
499                    SyntaxKind::ARROW
500                } else {
501                    SyntaxKind::MINUS
502                }
503            }
504            b'|' => {
505                if self.peek() == b'>' {
506                    self.pos += 1;
507                    SyntaxKind::PIPE_GT
508                } else if self.peek() == b'|' {
509                    self.pos += 1;
510                    SyntaxKind::CONCAT
511                } else {
512                    SyntaxKind::PIPE
513                }
514            }
515            other => {
516                self.error(format!("unexpected character {:?}", other as char), start);
517                SyntaxKind::ERROR
518            }
519        };
520        self.push(kind, start);
521    }
522}
523
524#[inline]
525fn is_ident_start(c: u8) -> bool {
526    c.is_ascii_alphabetic() || c == b'_'
527}
528
529#[inline]
530fn is_ident_continue(c: u8) -> bool {
531    c.is_ascii_alphanumeric() || c == b'_' || c == b'$'
532}