Skip to main content

squawk_lexer/
lib.rs

1mod cursor;
2mod token;
3use cursor::{Cursor, EOF_CHAR};
4pub use token::{Base, LiteralKind, Token, TokenKind};
5
6// via: https://github.com/postgres/postgres/blob/db0c96cc18aec417101e37e59fcc53d4bf647915/src/backend/parser/scan.l#L346
7// ident_start		[A-Za-z\200-\377_]
8const fn is_ident_start(c: char) -> bool {
9    matches!(c, 'a'..='z' | 'A'..='Z' | '_' | '\u{80}'..)
10}
11
12// ident_cont		[A-Za-z\200-\377_0-9\$]
13const fn is_ident_cont(c: char) -> bool {
14    matches!(c, 'a'..='z' | 'A'..='Z' | '_' | '0'..='9' | '$' | '\u{80}'..)
15}
16
17pub const BOM: &str = "\u{feff}";
18
19// see:
20// - https://github.com/postgres/postgres/blob/db0c96cc18aec417101e37e59fcc53d4bf647915/src/backend/parser/scansup.c#L107-L128
21// - https://github.com/postgres/postgres/blob/db0c96cc18aec417101e37e59fcc53d4bf647915/src/backend/parser/scan.l#L204-L229
22const fn is_whitespace(c: char) -> bool {
23    matches!(
24        c,
25        ' ' // space
26        | '\t' // tab
27        | '\n' // newline
28        | '\r' // carriage return
29        | '\u{000B}' // vertical tab
30        | '\u{000C}' // form feed
31    )
32}
33
34impl Cursor<'_> {
35    // see: https://github.com/rust-lang/rust/blob/ba1d7f4a083e6402679105115ded645512a7aea8/compiler/rustc_lexer/src/lib.rs#L339
36    pub(crate) fn advance_token(&mut self) -> Token {
37        let Some(first_char) = self.bump() else {
38            return Token::new(TokenKind::Eof, 0);
39        };
40        let token_kind = match first_char {
41            // Slash, comment or block comment.
42            '/' => match self.first() {
43                '*' => self.block_comment(),
44                _ => TokenKind::Slash,
45            },
46            '-' => match self.first() {
47                '-' => self.line_comment(),
48                _ => TokenKind::Minus,
49            },
50
51            // // Whitespace sequence.
52            c if is_whitespace(c) => self.whitespace(),
53
54            // https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-STRINGS-UESCAPE
55            'u' | 'U' => {
56                if self.first() == '&' && matches!(self.second(), '\'' | '"') {
57                    self.bump();
58                    self.prefixed_string(
59                        |terminated| LiteralKind::UnicodeEscStr { terminated },
60                        true,
61                        false,
62                    )
63                } else {
64                    self.ident()
65                }
66            }
67            // escaped strings
68            'e' | 'E' => {
69                self.prefixed_string(|terminated| LiteralKind::EscStr { terminated }, false, true)
70            }
71
72            // bit string
73            'b' | 'B' => self.prefixed_string(
74                |terminated| LiteralKind::BitStr { terminated },
75                false,
76                false,
77            ),
78
79            // hexadecimal byte string
80            'x' | 'X' => self.prefixed_string(
81                |terminated| LiteralKind::ByteStr { terminated },
82                false,
83                false,
84            ),
85
86            // national character string
87            'n' | 'N' => match self.first() {
88                '\'' => {
89                    self.bump();
90                    let terminated = self.single_quoted_string(false);
91                    TokenKind::Literal {
92                        kind: LiteralKind::NationalStr { terminated },
93                    }
94                }
95                _ => self.ident(),
96            },
97
98            // Identifier (this should be checked after other variant that can
99            // start as identifier).
100            c if is_ident_start(c) => self.ident(),
101
102            // Numeric literal.
103            // see: https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-CONSTANTS-NUMERIC
104            c @ '0'..='9' => {
105                let literal_kind = self.number(c);
106                TokenKind::Literal { kind: literal_kind }
107            }
108            '.' => match self.first() {
109                '0'..='9' => {
110                    let literal_kind = self.number('.');
111                    TokenKind::Literal { kind: literal_kind }
112                }
113                _ => TokenKind::Dot,
114            },
115            // One-symbol tokens.
116            ';' => TokenKind::Semi,
117            ',' => TokenKind::Comma,
118            '(' => TokenKind::OpenParen,
119            ')' => TokenKind::CloseParen,
120            '[' => TokenKind::OpenBracket,
121            ']' => TokenKind::CloseBracket,
122            '{' => TokenKind::OpenCurly,
123            '}' => TokenKind::CloseCurly,
124            '@' => TokenKind::At,
125            '#' => TokenKind::Pound,
126            '~' => TokenKind::Tilde,
127            '?' => TokenKind::Question,
128            ':' => TokenKind::Colon,
129            '$' => {
130                if self.is_dollar_quote_start() {
131                    self.dollar_quoted_string()
132                } else {
133                    // Parameters
134                    while self.first().is_ascii_digit() {
135                        self.bump();
136                    }
137                    let trailing_junk_start = self.pos_within_token();
138                    self.eat_identifier();
139                    TokenKind::PositionalParam {
140                        trailing_junk_start,
141                    }
142                }
143            }
144            '`' => TokenKind::Backtick,
145            '=' => TokenKind::Eq,
146            '!' => TokenKind::Bang,
147            '<' => TokenKind::Lt,
148            '>' => TokenKind::Gt,
149            '&' => TokenKind::And,
150            '|' => TokenKind::Or,
151            '+' => TokenKind::Plus,
152            '*' => TokenKind::Star,
153            '^' => TokenKind::Caret,
154            '%' => TokenKind::Percent,
155
156            // String literal
157            '\'' => {
158                let terminated = self.single_quoted_string(false);
159                let kind = LiteralKind::Str { terminated };
160                TokenKind::Literal { kind }
161            }
162
163            // Quoted indentifiers
164            '"' => {
165                let terminated = self.double_quoted_string();
166                TokenKind::QuotedIdent {
167                    terminated,
168                    uescape: false,
169                }
170            }
171            _ => TokenKind::Unknown,
172        };
173        let res = Token::new(token_kind, self.pos_within_token());
174        self.reset_pos_within_token();
175        res
176    }
177    pub(crate) fn ident(&mut self) -> TokenKind {
178        self.eat_while(is_ident_cont);
179        TokenKind::Ident
180    }
181
182    pub(crate) fn whitespace(&mut self) -> TokenKind {
183        self.eat_while(is_whitespace);
184        TokenKind::Whitespace
185    }
186
187    // see: https://github.com/postgres/postgres/blob/db0c96cc18aec417101e37e59fcc53d4bf647915/src/backend/parser/scan.l#L227
188    // comment			("--"{non_newline}*)
189    pub(crate) fn line_comment(&mut self) -> TokenKind {
190        self.bump();
191
192        self.eat_while(|c| c != '\n' && c != '\r');
193        TokenKind::LineComment
194    }
195
196    // see: https://github.com/postgres/postgres/blob/db0c96cc18aec417101e37e59fcc53d4bf647915/src/backend/parser/scan.l#L324-L344
197    pub(crate) fn block_comment(&mut self) -> TokenKind {
198        self.bump();
199
200        let mut depth = 1usize;
201        while let Some(c) = self.bump() {
202            match c {
203                '/' if self.first() == '*' => {
204                    self.bump();
205                    depth += 1;
206                }
207                '*' if self.first() == '/' => {
208                    self.bump();
209                    depth -= 1;
210                    if depth == 0 {
211                        // This block comment is closed, so for a construction like "/* */ */"
212                        // there will be a successfully parsed block comment "/* */"
213                        // and " */" will be processed separately.
214                        break;
215                    }
216                }
217                _ => (),
218            }
219        }
220
221        TokenKind::BlockComment {
222            terminated: depth == 0,
223        }
224    }
225
226    fn prefixed_string(
227        &mut self,
228        mk_kind: fn(bool) -> LiteralKind,
229        allows_double: bool,
230        backslash_escapes: bool,
231    ) -> TokenKind {
232        match self.first() {
233            '\'' => {
234                self.bump();
235                let terminated = self.single_quoted_string(backslash_escapes);
236                let kind = mk_kind(terminated);
237                TokenKind::Literal { kind }
238            }
239            '"' if allows_double => {
240                self.bump();
241                let terminated = self.double_quoted_string();
242                TokenKind::QuotedIdent {
243                    terminated,
244                    uescape: true,
245                }
246            }
247            _ => self.ident(),
248        }
249    }
250
251    fn number(&mut self, first_digit: char) -> LiteralKind {
252        let mut base = Base::Decimal;
253        if first_digit == '.' {
254            return self.eat_fractional();
255        }
256        if first_digit == '0' {
257            // Attempt to parse encoding base.
258            match self.first() {
259                // https://github.com/postgres/postgres/blob/db0c96cc18aec417101e37e59fcc53d4bf647915/src/backend/parser/scan.l#L403
260                'b' | 'B' => {
261                    base = Base::Binary;
262                    self.bump();
263                    let has_digits = self.eat_decimal_digits();
264                    return self.finish_base_prefixed_int(base, has_digits);
265                }
266                // https://github.com/postgres/postgres/blob/db0c96cc18aec417101e37e59fcc53d4bf647915/src/backend/parser/scan.l#L402
267                'o' | 'O' => {
268                    base = Base::Octal;
269                    self.bump();
270                    let has_digits = self.eat_decimal_digits();
271                    return self.finish_base_prefixed_int(base, has_digits);
272                }
273                // https://github.com/postgres/postgres/blob/db0c96cc18aec417101e37e59fcc53d4bf647915/src/backend/parser/scan.l#L401
274                'x' | 'X' => {
275                    base = Base::Hexadecimal;
276                    self.bump();
277                    let has_digits = self.eat_hexadecimal_digits();
278                    return self.finish_base_prefixed_int(base, has_digits);
279                }
280                // Not a base prefix; consume additional digits.
281                '0'..='9' | '_' => {
282                    self.eat_decimal_digits();
283                }
284
285                // Also not a base prefix; nothing more to do here.
286                '.' | 'e' | 'E' => {}
287
288                // Just a 0.
289                _ => {
290                    let trailing_junk_start = self.pos_within_token();
291                    self.eat_identifier();
292                    return LiteralKind::Int {
293                        base,
294                        empty_int: false,
295                        trailing_junk_start,
296                    };
297                }
298            }
299        } else {
300            // No base prefix, parse number in the usual way.
301            self.eat_decimal_digits();
302        };
303
304        match self.first() {
305            '.' => {
306                self.bump();
307                self.eat_fractional()
308            }
309            'e' | 'E' => {
310                let exponent_start = self.pos_within_token();
311                self.bump();
312                let empty_exponent_start = (!self.eat_numeric_exponent()).then_some(exponent_start);
313                let trailing_junk_start = self.pos_within_token();
314                self.eat_identifier();
315                LiteralKind::Numeric {
316                    empty_exponent_start,
317                    trailing_junk_start,
318                }
319            }
320            _ => {
321                let trailing_junk_start = self.pos_within_token();
322                self.eat_identifier();
323                LiteralKind::Int {
324                    base,
325                    empty_int: false,
326                    trailing_junk_start,
327                }
328            }
329        }
330    }
331
332    fn single_quoted_string(&mut self, backslash_escapes: bool) -> bool {
333        // Parse until either quotes are terminated or error is detected.
334        loop {
335            match self.first() {
336                '\\' if backslash_escapes => {
337                    // backslash
338                    self.bump();
339                    // escaped char
340                    self.bump();
341                }
342                // Quotes might be terminated.
343                '\'' => {
344                    self.bump();
345
346                    match self.first() {
347                        // encountered an escaped quote ''
348                        '\'' => {
349                            self.bump();
350                        }
351                        // encountered terminating quote
352                        _ => return true,
353                    }
354                }
355                // End of file, stop parsing.
356                EOF_CHAR if self.is_eof() => break,
357                // Skip the character.
358                _ => {
359                    self.bump();
360                }
361            }
362        }
363        // String was not terminated.
364        false
365    }
366
367    /// Eats double-quoted string and returns true
368    /// if string is terminated.
369    fn double_quoted_string(&mut self) -> bool {
370        while let Some(c) = self.bump() {
371            match c {
372                '"' if self.first() == '"' => {
373                    // Bump again to skip escaped character.
374                    self.bump();
375                }
376                '"' => {
377                    return true;
378                }
379                _ => (),
380            }
381        }
382        // End of file reached.
383        false
384    }
385
386    /// Check for `$$` and `$tag$`
387    fn is_dollar_quote_start(&self) -> bool {
388        let mut chars = self.chars();
389        match chars.next() {
390            // `$$...` -- empty tag
391            Some('$') => true,
392            // `$tag$...` -- tag chars terminated by `$`
393            Some(c) if is_ident_start(c) => {
394                for c in chars {
395                    if c == '$' {
396                        return true;
397                    }
398                    if !is_ident_cont(c) {
399                        return false;
400                    }
401                }
402                false
403            }
404            _ => false,
405        }
406    }
407
408    // https://www.postgresql.org/docs/16/sql-syntax-lexical.html#SQL-SYNTAX-DOLLAR-QUOTING
409    fn dollar_quoted_string(&mut self) -> TokenKind {
410        // Get the start sequence of the dollar quote, i.e., 'foo' in
411        // $foo$hello$foo$
412        let mut start = vec![];
413        while let Some(c) = self.bump() {
414            match c {
415                '$' => {
416                    break;
417                }
418                _ => {
419                    start.push(c);
420                }
421            }
422        }
423
424        // we have a dollar quoted string deliminated with `$$`
425        if start.is_empty() {
426            loop {
427                self.eat_while(|c| c != '$');
428                if self.is_eof() {
429                    return TokenKind::Literal {
430                        kind: LiteralKind::DollarQuotedString { terminated: false },
431                    };
432                }
433                // eat $
434                self.bump();
435                if self.first() == '$' {
436                    self.bump();
437                    return TokenKind::Literal {
438                        kind: LiteralKind::DollarQuotedString { terminated: true },
439                    };
440                }
441            }
442        } else {
443            loop {
444                self.eat_while(|c| c != '$');
445                if self.is_eof() {
446                    return TokenKind::Literal {
447                        kind: LiteralKind::DollarQuotedString { terminated: false },
448                    };
449                }
450
451                // Eat the leading '$' of a possible closing delimiter.
452                self.bump();
453
454                let mut matches_tag = true;
455                for start_char in &start {
456                    if self.first() == *start_char {
457                        self.bump();
458                    } else {
459                        matches_tag = false;
460                        break;
461                    }
462                }
463
464                if matches_tag && self.first() == '$' {
465                    self.bump();
466                    return TokenKind::Literal {
467                        kind: LiteralKind::DollarQuotedString { terminated: true },
468                    };
469                }
470            }
471        }
472    }
473
474    fn eat_decimal_digits(&mut self) -> bool {
475        let mut has_digits = false;
476        loop {
477            match self.first() {
478                '_' if self.second().is_ascii_digit() => {
479                    self.bump();
480                }
481                '0'..='9' => {
482                    has_digits = true;
483                    self.bump();
484                }
485                _ => break,
486            }
487        }
488        has_digits
489    }
490
491    fn finish_base_prefixed_int(&mut self, base: Base, has_digits: bool) -> LiteralKind {
492        let trailing_junk_start = self.pos_within_token();
493        self.eat_identifier();
494        let has_trailing_junk = self.pos_within_token() > trailing_junk_start;
495        LiteralKind::Int {
496            base,
497            empty_int: !has_digits && !has_trailing_junk,
498            trailing_junk_start,
499        }
500    }
501
502    fn eat_hexadecimal_digits(&mut self) -> bool {
503        let mut has_digits = false;
504        loop {
505            match self.first() {
506                '_' if self.second().is_ascii_hexdigit() => {
507                    self.bump();
508                }
509                '0'..='9' | 'a'..='f' | 'A'..='F' => {
510                    has_digits = true;
511                    self.bump();
512                }
513                _ => break,
514            }
515        }
516        has_digits
517    }
518
519    /// Eats the numeric exponent. Returns true if at least one digit was met,
520    /// and returns false otherwise.
521    fn eat_numeric_exponent(&mut self) -> bool {
522        if self.first() == '-' || self.first() == '+' {
523            if !self.second().is_ascii_digit() {
524                return false;
525            }
526            self.bump();
527        } else if !self.first().is_ascii_digit() {
528            return false;
529        }
530        self.eat_decimal_digits()
531    }
532
533    fn eat_identifier(&mut self) {
534        if is_ident_start(self.first()) {
535            self.eat_while(is_ident_cont);
536        }
537    }
538
539    pub(crate) fn eat_fractional(&mut self) -> crate::LiteralKind {
540        let mut empty_exponent_start = None;
541        if self.first().is_ascii_digit() {
542            self.eat_decimal_digits();
543        }
544        match self.first() {
545            'e' | 'E' => {
546                let exponent_start = self.pos_within_token();
547                self.bump();
548                if !self.eat_numeric_exponent() {
549                    empty_exponent_start = Some(exponent_start);
550                }
551            }
552            _ => (),
553        }
554        let trailing_junk_start = self.pos_within_token();
555        self.eat_identifier();
556        LiteralKind::Numeric {
557            empty_exponent_start,
558            trailing_junk_start,
559        }
560    }
561}
562
563/// Creates an iterator that produces tokens from the input string.
564pub fn tokenize(input: &str) -> impl Iterator<Item = Token> + '_ {
565    let (bom, input) = match input.strip_prefix(BOM) {
566        Some(input) => (
567            Some(Token::new(TokenKind::Whitespace, BOM.len() as u32)),
568            input,
569        ),
570        None => (None, input),
571    };
572    let mut cursor = Cursor::new(input);
573    bom.into_iter().chain(std::iter::from_fn(move || {
574        let token = cursor.advance_token();
575        if token.kind != TokenKind::Eof {
576            Some(token)
577        } else {
578            None
579        }
580    }))
581}
582
583#[cfg(test)]
584mod tests {
585    use std::fmt;
586
587    use super::*;
588    use insta::assert_debug_snapshot;
589
590    struct TokenDebug<'a> {
591        content: &'a str,
592        token: Token,
593    }
594    impl fmt::Debug for TokenDebug<'_> {
595        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
596            write!(f, "{:?} @ {:?}", self.content, self.token.kind)
597        }
598    }
599
600    impl<'a> TokenDebug<'a> {
601        fn new(token: Token, input: &'a str, start: u32) -> TokenDebug<'a> {
602            TokenDebug {
603                token,
604                content: &input[start as usize..(start + token.len) as usize],
605            }
606        }
607    }
608
609    fn lex(input: &str) -> Vec<TokenDebug<'_>> {
610        let mut tokens = vec![];
611        let mut start = 0;
612
613        for token in tokenize(input) {
614            let length = token.len;
615            tokens.push(TokenDebug::new(token, input, start));
616            start += length;
617        }
618        tokens
619    }
620    #[test]
621    fn lex_statement() {
622        let result = lex("select 1;");
623        assert_debug_snapshot!(result);
624    }
625
626    #[test]
627    fn block_comment() {
628        let result = lex(r#"
629/*
630 * foo
631 * bar
632*/"#);
633        assert_debug_snapshot!(result);
634    }
635
636    #[test]
637    fn block_comment_unterminated() {
638        let result = lex(r#"
639/*
640 * foo
641 * bar
642 /*
643*/"#);
644        assert_debug_snapshot!(result);
645    }
646
647    #[test]
648    fn line_comment() {
649        let result = lex(r#"
650-- foooooooooooo bar buzz
651"#);
652        assert_debug_snapshot!(result);
653    }
654
655    #[test]
656    fn line_comment_cr_newline() {
657        assert_debug_snapshot!(lex("select 1; -- comment\rselect 2;"), @r#"
658        [
659            "select" @ Ident,
660            " " @ Whitespace,
661            "1" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 1 } },
662            ";" @ Semi,
663            " " @ Whitespace,
664            "-- comment" @ LineComment,
665            "\r" @ Whitespace,
666            "select" @ Ident,
667            " " @ Whitespace,
668            "2" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 1 } },
669            ";" @ Semi,
670        ]
671        "#);
672    }
673
674    #[test]
675    fn line_comment_whitespace() {
676        assert_debug_snapshot!(lex(r#"
677select 'Hello' -- This is a comment
678' World';"#))
679    }
680
681    #[test]
682    fn dollar_quoting() {
683        assert_debug_snapshot!(lex(r#"
684$$Dianne's horse$$
685$SomeTag$Dianne's horse$SomeTag$
686
687-- with dollar inside and matching tags
688$foo$hello$world$bar$
689"#))
690    }
691
692    #[test]
693    fn dollar_strings_part2() {
694        assert_debug_snapshot!(lex(r#"
695DO $doblock$
696end
697$doblock$;"#))
698    }
699
700    #[test]
701    fn dollar_quote_mismatch_tags_simple() {
702        assert_debug_snapshot!(lex(r#"
703-- dollar quoting with mismatched tags
704$foo$hello world$bar$
705"#));
706    }
707
708    #[test]
709    fn dollar_quote_mismatch_tags_complex() {
710        assert_debug_snapshot!(lex(r#"
711-- with dollar inside but mismatched tags
712$foo$hello$world$bar$
713"#));
714    }
715
716    #[test]
717    fn numeric() {
718        assert_debug_snapshot!(lex(r#"
71942
7203.5
7214.
722.001
723.123e10
7245e2
7251.925e-3
7261e-10
7271e+10
7281e10
7294664.E+5
730"#))
731    }
732
733    #[test]
734    fn numeric_non_decimal() {
735        assert_debug_snapshot!(lex(r#"
7360b100101
7370B10011001
7380o273
7390O755
7400x42f
7410XFFFF
742"#))
743    }
744
745    #[test]
746    fn numeric_base_prefix_does_not_swallow_dollar_tokens() {
747        assert_debug_snapshot!(lex("123$abc 0b101$2 0o12$abc 0x12$abc 0xFF$1 0x1$$foo$$ 123$$foo$$"), @r#"
748        [
749            "123" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 3 } },
750            "$abc" @ PositionalParam { trailing_junk_start: 1 },
751            " " @ Whitespace,
752            "0b101" @ Literal { kind: Int { base: Binary, empty_int: false, trailing_junk_start: 5 } },
753            "$2" @ PositionalParam { trailing_junk_start: 2 },
754            " " @ Whitespace,
755            "0o12" @ Literal { kind: Int { base: Octal, empty_int: false, trailing_junk_start: 4 } },
756            "$abc" @ PositionalParam { trailing_junk_start: 1 },
757            " " @ Whitespace,
758            "0x12" @ Literal { kind: Int { base: Hexadecimal, empty_int: false, trailing_junk_start: 4 } },
759            "$abc" @ PositionalParam { trailing_junk_start: 1 },
760            " " @ Whitespace,
761            "0xFF" @ Literal { kind: Int { base: Hexadecimal, empty_int: false, trailing_junk_start: 4 } },
762            "$1" @ PositionalParam { trailing_junk_start: 2 },
763            " " @ Whitespace,
764            "0x1" @ Literal { kind: Int { base: Hexadecimal, empty_int: false, trailing_junk_start: 3 } },
765            "$$foo$$" @ Literal { kind: DollarQuotedString { terminated: true } },
766            " " @ Whitespace,
767            "123" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 3 } },
768            "$$foo$$" @ Literal { kind: DollarQuotedString { terminated: true } },
769        ]
770        "#);
771    }
772
773    #[test]
774    fn numeric_with_seperators() {
775        assert_debug_snapshot!(lex(r#"
7761_500_000_000
7770b10001000_00000000
7780o_1_755
7790xFFFF_FFFF
7801.618_034
781"#))
782    }
783
784    #[test]
785    fn numeric_leading_dot_with_separators() {
786        assert_debug_snapshot!(lex(".1_2 .5_5 .1_2e3"), @r#"
787        [
788            ".1_2" @ Literal { kind: Numeric { empty_exponent_start: None, trailing_junk_start: 4 } },
789            " " @ Whitespace,
790            ".5_5" @ Literal { kind: Numeric { empty_exponent_start: None, trailing_junk_start: 4 } },
791            " " @ Whitespace,
792            ".1_2e3" @ Literal { kind: Numeric { empty_exponent_start: None, trailing_junk_start: 6 } },
793        ]
794        "#)
795    }
796
797    #[test]
798    fn numeric_exponent_underscore_after_sign() {
799        assert_debug_snapshot!(lex("1e+_2 1e-_2 1.0e+_2 .1e+_2"), @r#"
800        [
801            "1e" @ Literal { kind: Numeric { empty_exponent_start: Some(1), trailing_junk_start: 2 } },
802            "+" @ Plus,
803            "_2" @ Ident,
804            " " @ Whitespace,
805            "1e" @ Literal { kind: Numeric { empty_exponent_start: Some(1), trailing_junk_start: 2 } },
806            "-" @ Minus,
807            "_2" @ Ident,
808            " " @ Whitespace,
809            "1.0e" @ Literal { kind: Numeric { empty_exponent_start: Some(3), trailing_junk_start: 4 } },
810            "+" @ Plus,
811            "_2" @ Ident,
812            " " @ Whitespace,
813            ".1e" @ Literal { kind: Numeric { empty_exponent_start: Some(2), trailing_junk_start: 3 } },
814            "+" @ Plus,
815            "_2" @ Ident,
816        ]
817        "#)
818    }
819
820    #[test]
821    fn select_with_period() {
822        assert_debug_snapshot!(lex(r#"
823select public.users;
824"#))
825    }
826
827    #[test]
828    fn bitstring() {
829        assert_debug_snapshot!(lex(r#"
830B'1001'
831b'1001'
832X'1FF'
833x'1FF'
834"#))
835    }
836
837    #[test]
838    fn national_character_string() {
839        assert_debug_snapshot!(lex("N'foo' n'bar' numeric'1'"), @r#"
840        [
841            "N'foo'" @ Literal { kind: NationalStr { terminated: true } },
842            " " @ Whitespace,
843            "n'bar'" @ Literal { kind: NationalStr { terminated: true } },
844            " " @ Whitespace,
845            "numeric" @ Ident,
846            "'1'" @ Literal { kind: Str { terminated: true } },
847        ]
848        "#);
849    }
850
851    #[test]
852    fn ident_prefix_then_string_is_consistent() {
853        assert_debug_snapshot!(
854            lex("N1'foo' E1'foo' B1'foo' X1'foo' U1'foo' uuid'00000000'"),
855            @r#"
856        [
857            "N1" @ Ident,
858            "'foo'" @ Literal { kind: Str { terminated: true } },
859            " " @ Whitespace,
860            "E1" @ Ident,
861            "'foo'" @ Literal { kind: Str { terminated: true } },
862            " " @ Whitespace,
863            "B1" @ Ident,
864            "'foo'" @ Literal { kind: Str { terminated: true } },
865            " " @ Whitespace,
866            "X1" @ Ident,
867            "'foo'" @ Literal { kind: Str { terminated: true } },
868            " " @ Whitespace,
869            "U1" @ Ident,
870            "'foo'" @ Literal { kind: Str { terminated: true } },
871            " " @ Whitespace,
872            "uuid" @ Ident,
873            "'00000000'" @ Literal { kind: Str { terminated: true } },
874        ]
875        "#);
876    }
877
878    #[test]
879    fn string() {
880        assert_debug_snapshot!(lex(r#"
881'Dianne''s horse'
882
883select 'foo ''
884bar';
885
886select 'foooo'   
887   'bar';
888
889
890'foo \\ \n \tbar'
891
892'forgot to close the string
893"#))
894    }
895
896    #[test]
897    fn params() {
898        assert_debug_snapshot!(lex(r#"
899select $1 + $2;
900
901select $1123123123123;
902
903select $;
904"#))
905    }
906
907    #[test]
908    fn string_with_escapes() {
909        // https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-STRINGS-ESCAPE
910
911        assert_debug_snapshot!(lex(r#"
912E'foo'
913
914e'bar'
915
916e'\b\f\n\r\t'
917
918e'\0\11\777'
919
920e'\x0\x11\xFF'
921
922e'\uAAAA \UFFFFFFFF'
923
924"#))
925    }
926
927    #[test]
928    fn escape_string_with_backslash_escaped_quote() {
929        assert_debug_snapshot!(lex(r"E'foo\'bar'"), @r#"
930        [
931            "E'foo\\'bar'" @ Literal { kind: EscStr { terminated: true } },
932        ]
933        "#);
934    }
935
936    #[test]
937    fn escape_string_with_escaped_terminal_quote_is_unterminated() {
938        assert_debug_snapshot!(lex(r"E'foo\';"), @r#"
939        [
940            "E'foo\\';" @ Literal { kind: EscStr { terminated: false } },
941        ]
942        "#);
943    }
944
945    #[test]
946    fn escape_string_with_even_backslashes_before_quote_is_terminated() {
947        assert_debug_snapshot!(lex(r"E'foo\\'"), @r#"
948        [
949            "E'foo\\\\'" @ Literal { kind: EscStr { terminated: true } },
950        ]
951        "#);
952    }
953
954    #[test]
955    fn string_unicode_escape() {
956        // https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-STRINGS-UESCAPE
957
958        assert_debug_snapshot!(lex(r#"
959U&"d\0061t\+000061"
960
961U&"\0441\043B\043E\043D"
962
963u&'\0441\043B'
964
965U&"d!0061t!+000061" UESCAPE '!'
966"#))
967    }
968
969    #[test]
970    fn quoted_ident() {
971        assert_debug_snapshot!(lex(r#"
972"hello &1 -world";
973
974
975"hello-world
976"#))
977    }
978
979    #[test]
980    fn quoted_ident_with_escape_quote() {
981        assert_debug_snapshot!(lex(r#"
982"foo "" bar"
983"#))
984    }
985
986    #[test]
987    fn dollar_quoted_string() {
988        assert_debug_snapshot!(lex("$$$$"), @r#"
989        [
990            "$$$$" @ Literal { kind: DollarQuotedString { terminated: true } },
991        ]
992        "#);
993    }
994
995    #[test]
996    fn tagged_dollar_quote_requires_leading_dollar() {
997        assert_debug_snapshot!(lex("select $foo$abcfoo$def$foo$;"), @r#"
998        [
999            "select" @ Ident,
1000            " " @ Whitespace,
1001            "$foo$abcfoo$def$foo$" @ Literal { kind: DollarQuotedString { terminated: true } },
1002            ";" @ Semi,
1003        ]
1004        "#);
1005    }
1006
1007    #[test]
1008    fn unclosed_dollar_tag_does_not_swallow_rest_of_input() {
1009        assert_debug_snapshot!(lex("select $x;\ndrop table users;"), @r#"
1010        [
1011            "select" @ Ident,
1012            " " @ Whitespace,
1013            "$x" @ PositionalParam { trailing_junk_start: 1 },
1014            ";" @ Semi,
1015            "\n" @ Whitespace,
1016            "drop" @ Ident,
1017            " " @ Whitespace,
1018            "table" @ Ident,
1019            " " @ Whitespace,
1020            "users" @ Ident,
1021            ";" @ Semi,
1022        ]
1023        "#);
1024    }
1025
1026    #[test]
1027    fn bom_at_start() {
1028        assert_debug_snapshot!(lex("\u{feff}select 1;"), @r#"
1029        [
1030            "\u{feff}" @ Whitespace,
1031            "select" @ Ident,
1032            " " @ Whitespace,
1033            "1" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 1 } },
1034            ";" @ Semi,
1035        ]
1036        "#);
1037    }
1038
1039    #[test]
1040    fn bom_after_start_is_an_ident_char() {
1041        assert_debug_snapshot!(lex("select 1;\n\u{feff}select 2;"), @r#"
1042        [
1043            "select" @ Ident,
1044            " " @ Whitespace,
1045            "1" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 1 } },
1046            ";" @ Semi,
1047            "\n" @ Whitespace,
1048            "\u{feff}select" @ Ident,
1049            " " @ Whitespace,
1050            "2" @ Literal { kind: Int { base: Decimal, empty_int: false, trailing_junk_start: 1 } },
1051            ";" @ Semi,
1052        ]
1053        "#);
1054    }
1055
1056    #[test]
1057    fn ident_non_ascii_above_latin1() {
1058        assert_debug_snapshot!(lex("ẞ Ā 漢字 𐐷"), @r#"
1059        [
1060            "ẞ" @ Ident,
1061            " " @ Whitespace,
1062            "Ā" @ Ident,
1063            " " @ Whitespace,
1064            "漢字" @ Ident,
1065            " " @ Whitespace,
1066            "𐐷" @ Ident,
1067        ]
1068        "#);
1069    }
1070}