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}