Skip to main content

spg_sql/
lexer.rs

1//! Lexer for the PG-dialect subset that SPG accepts.
2//!
3//! v0.2 token stream is value-only — no source spans yet. Errors do report
4//! the byte offset where the offending construct started. Identifiers are
5//! ASCII case-folded to lower-case (matches PG when un-quoted). Quoted
6//! identifiers (`"..."`) preserve case; `""` is an embedded quote.
7//! String literals (`'...'`) follow PG single-quote convention with `''`
8//! as the embedded quote. The lexer accepts but does not interpret E-strings
9//! or dollar-quoted strings — those land in a later milestone.
10
11use alloc::string::{String, ToString};
12use alloc::vec::Vec;
13use core::fmt;
14
15#[derive(Debug, Clone, PartialEq)]
16pub enum Token {
17    // Keywords
18    Select,
19    From,
20    Where,
21    As,
22    Null,
23    True,
24    False,
25    And,
26    Or,
27    Not,
28    Create,
29    Table,
30    Insert,
31    Into,
32    Values,
33    Index,
34    On,
35    Begin,
36    Commit,
37    Rollback,
38    Order,
39    By,
40    Limit,
41
42    // Identifiers
43    Ident(String),       // ASCII case-folded
44    QuotedIdent(String), // original case, "" → "
45    /// v7.14.0 — MySQL session / user variable reference
46    /// (`@VAR` / `@@VAR`). The wrapped string is the verbatim
47    /// source form (including the `@` / `@@` prefix). Used by
48    /// mysqldump preamble (`SET @OLD_FOREIGN_KEY_CHECKS =
49    /// @@FOREIGN_KEY_CHECKS, …`); SPG accepts the token and
50    /// the SET parser treats the assignment as a no-op apart
51    /// from any second LHS that targets a real session
52    /// parameter (e.g. `FOREIGN_KEY_CHECKS=0`).
53    SessionVar(String),
54
55    // Literals
56    Integer(i64),
57    Float(f64),
58    String(String),
59
60    // Operators
61    Plus,
62    Minus,
63    Star,
64    Slash,
65    Eq,
66    NotEq,
67    Lt,
68    LtEq,
69    Gt,
70    GtEq,
71    /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR strict contained-in
72    /// `<<`. LHS is strictly inside RHS (no equality).
73    InetContainedBy,
74    /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR contained-in-or-equal
75    /// `<<=`. LHS network ⊆ RHS network.
76    InetContainedByEq,
77    /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR strict contains `>>`.
78    /// LHS strictly contains RHS.
79    InetContains,
80    /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR contains-or-equal `>>=`.
81    /// LHS network ⊇ RHS network.
82    InetContainsEq,
83    /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR network overlap `&&`.
84    /// Either side contains any address of the other.
85    InetOverlap,
86
87    // Punctuation
88    LParen,
89    RParen,
90    LBracket,
91    RBracket,
92    Comma,
93    Semicolon,
94    Dot,
95    /// v7.17.0 Phase 2.6 — standalone `@` punctuation. Emitted when
96    /// `@` is NOT followed by an ident-start byte (i.e. the
97    /// `@VAR` / `@@VAR` SessionVar path doesn't match). Lets the
98    /// parser stitch the MySQL `'user'@'host'` DEFINER form back
99    /// together as String + At + String. Pre-2.6 this same shape
100    /// surfaced as a `LexErrorKind::UnknownChar('@')` and broke
101    /// every mysqldump CREATE VIEW with a DEFINER clause at lex
102    /// time.
103    At,
104    /// pgvector L2 distance operator `<->`. Lexed as one token so the
105    /// parser can give it its own precedence rung.
106    /// v4.14 `->` — JSON object/array element access, returns json.
107    JsonGet,
108    /// v4.14 `->>` — same access, returns text.
109    JsonGetText,
110    /// v6.4.5 `#>` — JSON path walk, returns json. Path is the
111    /// right-hand TEXT with PG `{a,b,0}` syntax.
112    JsonGetPath,
113    /// v6.4.5 `#>>` — same walk, returns text.
114    JsonGetPathText,
115    /// v6.4.5 `@>` — JSON containment. `j @> sub` returns true if
116    /// every key/value in `sub` is present in `j` with structural
117    /// containment for objects + arrays.
118    JsonContains,
119    /// v7.37.6-A `<@` — JSON contained-by. `a <@ b` ⇔ `b @> a`.
120    JsonContainedBy,
121    /// v7.37.6-A `?` — JSON key exists (object), or element-as-text
122    /// exists (array). `j ? 'key'` returns BOOL.
123    JsonKeyExists,
124    /// v7.37.6-A `?|` — JSON any-key-exists. `j ?| ARRAY['a','b']`
125    /// returns BOOL; true if any one of the listed keys exists in `j`.
126    JsonKeysAny,
127    /// v7.37.6-A `?&` — JSON all-keys-exist. `j ?& ARRAY['a','b']`
128    /// returns BOOL; true if every listed key exists in `j`.
129    JsonKeysAll,
130    /// v7.12.2 `@@` — tsvector / tsquery match. Either ordering
131    /// (`vec @@ q` or `q @@ vec`) parses; engine eval normalises
132    /// before matching.
133    TsMatch,
134    L2Distance,
135    /// pgvector inner-product operator `<#>` (returns negative dot product
136    /// so smaller still means more similar — same semantics as pgvector).
137    InnerProduct,
138    /// pgvector cosine distance operator `<=>`.
139    CosineDistance,
140    /// PG-style cast `expr::type` — single token because we want it to bind
141    /// at postfix precedence.
142    DoubleColon,
143    /// v7.12.4 — PL/pgSQL assignment operator `:=`.
144    /// Outside PL/pgSQL bodies this token has no SQL-side meaning.
145    ColonEq,
146    /// v7.12.4 — bare `:` separator. Used inside `tsvector` external-form
147    /// literals (`'cat:1 dog:2'::tsvector`) and as the fallback path for
148    /// the PL/pgSQL assignment lexer.
149    Colon,
150    /// Standard SQL string concatenation `||`.
151    Concat,
152    /// Bitwise OR `|` (single pipe — `||` lexes as Concat first).
153    Pipe,
154    /// Bitwise AND `&` (single amp — `&&` lexes as InetOverlap first).
155    Amp,
156    /// Bitwise NOT `~` (prefix).
157    Tilde,
158    /// `IS` keyword — postfix `IS NULL` / `IS NOT NULL` predicates.
159    Is,
160    Between,
161    In,
162    Like,
163    Group,
164    Distinct,
165    Union,
166    All,
167    Join,
168    Inner,
169    Left,
170    Cross,
171    Outer,
172    Default,
173    Savepoint,
174    Release,
175    To,
176    Having,
177    Show,
178    Extract,
179    Offset,
180    Asc,
181    Desc,
182    /// `INTERVAL` — followed by a string literal carrying the span text
183    /// (e.g. `INTERVAL '1 day 2 hours'`).
184    Interval,
185    /// v6.1.1 — `$N` parameter placeholder for the extended query
186    /// protocol. The number N is 1-based per PostgreSQL convention.
187    /// `0` and `$0` are not valid; the lexer rejects them.
188    Placeholder(u16),
189
190    /// v6.1.2 — `DROP` keyword. Used by `DROP PUBLICATION <name>`.
191    /// Reserved for future `DROP TABLE` / `DROP INDEX` / `DROP USER`
192    /// surface that currently goes through SHOW-shaped admin SQL.
193    Drop,
194    /// v6.1.2 — `FOR` keyword (publication scope).
195    For,
196    /// v6.1.2 — `TABLES` plural keyword (`FOR ALL TABLES`,
197    /// `FOR ALL TABLES EXCEPT …`). The existing `TABLE` keyword
198    /// stays a separate token so `CREATE TABLE`'s single-table
199    /// form keeps lexing as today.
200    Tables,
201    /// v6.1.3 (reserved at v6.1.2 to keep the AST shape stable) —
202    /// `EXCEPT` keyword for `FOR ALL TABLES EXCEPT t1, t2`.
203    Except,
204    /// v6.1.2 — `PUBLICATION` keyword.
205    Publication,
206    /// v6.1.4 (reserved at v6.1.2) — `SUBSCRIPTION` keyword.
207    Subscription,
208    /// v6.1.4 — `CONNECTION` keyword (for
209    /// `CREATE SUBSCRIPTION … CONNECTION '<conn_str>' …`).
210    Connection,
211    /// v7.37.6-B(sentori Epic 2 P0)— `PARTITION` keyword. Drives
212    /// both `CREATE TABLE p (…) PARTITION BY RANGE (key)` (declarative
213    /// parent) and `CREATE TABLE c PARTITION OF p FOR VALUES FROM
214    /// (a) TO (b) | DEFAULT` (child). `OF` / `MINVALUE` / `MAXVALUE`
215    /// stay PG-context-sensitive identifiers — the parser matches them
216    /// as case-insensitive `Token::Ident` strings off the back of this
217    /// reserved keyword, mirroring how `INSERT … RETURNING` handles
218    /// `RETURNING` without burning a global keyword slot.
219    Partition,
220
221    Eof,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub enum LexErrorKind {
226    UnknownChar(char),
227    UnterminatedString,
228    UnterminatedQuotedIdent,
229    UnterminatedBlockComment,
230    BadNumber(String),
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct LexError {
235    pub kind: LexErrorKind,
236    pub pos: usize,
237}
238
239impl fmt::Display for LexError {
240    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
241        match &self.kind {
242            LexErrorKind::UnknownChar(c) => write!(f, "unknown char {c:?} at byte {}", self.pos),
243            LexErrorKind::UnterminatedString => {
244                write!(f, "unterminated string literal at byte {}", self.pos)
245            }
246            LexErrorKind::UnterminatedQuotedIdent => {
247                write!(f, "unterminated quoted identifier at byte {}", self.pos)
248            }
249            LexErrorKind::UnterminatedBlockComment => {
250                write!(f, "unterminated /* */ comment at byte {}", self.pos)
251            }
252            LexErrorKind::BadNumber(s) => {
253                write!(f, "invalid number literal {s:?} at byte {}", self.pos)
254            }
255        }
256    }
257}
258
259/// Tokenize `input` into a `Vec<Token>` ending in `Token::Eof`,
260/// with PG string semantics (backslash is a literal byte inside
261/// `'…'`; `''` is the only escape).
262pub fn tokenize(input: &str) -> Result<Vec<Token>, LexError> {
263    tokenize_with(input, false)
264}
265
266/// v7.22 (round-13 T3) — dialect-aware tokenizer entry. With
267/// `backslash_escapes = true`, plain `'…'` strings honour MySQL /
268/// pre-9.1-PG backslash escapes (`\'` `\\` `\n` …, the same decode
269/// the `E'…'` form uses). mysqldump ALWAYS emits `\'`-escaped data
270/// sections, and pg_dump ALWAYS announces PG semantics via
271/// `SET standard_conforming_strings = on` — the engine flips this
272/// flag off/on from those deterministic session signals.
273#[allow(clippy::too_many_lines)] // big match — splitting would obscure the dispatch table
274pub fn tokenize_with(input: &str, backslash_escapes: bool) -> Result<Vec<Token>, LexError> {
275    let bytes = input.as_bytes();
276    let mut i = 0usize;
277    let mut out = Vec::new();
278
279    while i < bytes.len() {
280        let b = bytes[i];
281        match b {
282            b' ' | b'\t' | b'\n' | b'\r' => {
283                i += 1;
284            }
285            b'-' if peek_eq(bytes, i + 1, b'-') => {
286                i += 2;
287                while i < bytes.len() && bytes[i] != b'\n' {
288                    i += 1;
289                }
290            }
291            b'/' if peek_eq(bytes, i + 1, b'*') => {
292                let start = i;
293                // v7.14.0 — MySQL versioned conditional comment
294                // `/*!NNNNN <body> */`. The body is real SQL that
295                // MySQL/MariaDB executes when the runtime version
296                // matches the 5-digit code; PG strips the whole
297                // thing as a block comment. SPG sides with MySQL
298                // semantics for dump compatibility: skip the
299                // `/*!NNNNN ` prefix and continue lexing the body
300                // as ordinary tokens. The closing `*/` is later
301                // matched + skipped by the symmetric arm below.
302                if peek_eq(bytes, i + 2, b'!') {
303                    let mut j = i + 3;
304                    // skip the optional 5-digit version code +
305                    // following single whitespace
306                    while j < bytes.len() && bytes[j].is_ascii_digit() {
307                        j += 1;
308                    }
309                    if j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
310                        j += 1;
311                    }
312                    i = j;
313                    continue;
314                }
315                i += 2;
316                let mut closed = false;
317                while i + 1 < bytes.len() {
318                    if bytes[i] == b'*' && bytes[i + 1] == b'/' {
319                        i += 2;
320                        closed = true;
321                        break;
322                    }
323                    i += 1;
324                }
325                if !closed {
326                    return Err(LexError {
327                        kind: LexErrorKind::UnterminatedBlockComment,
328                        pos: start,
329                    });
330                }
331            }
332            // v7.14.0 — bare `*/` (closing of the v7.14 MySQL
333            // versioned-comment opener that didn't consume the
334            // closer). We treat it as an inline comment terminator
335            // and skip 2 bytes.
336            b'*' if peek_eq(bytes, i + 1, b'/') => {
337                i += 2;
338            }
339            b'\'' => {
340                let (tok, consumed) = if backslash_escapes {
341                    // MySQL-dialect session: plain strings decode
342                    // backslash escapes — same machinery as E'…'.
343                    lex_escape_string(input, i)?
344                } else {
345                    lex_quoted(input, i, b'\'', false)?
346                };
347                out.push(tok);
348                i += consumed;
349            }
350            // v7.18 — PG escape-string literal `E'...'` / `e'...'`.
351            // Closes the mailrs D-pre #3 reverse-acceptance gap:
352            // `INSERT INTO oq VALUES (E'\\xdeadbeef'::bytea)` needs
353            // the `E` prefix so `\\` decodes to a single `\`. The
354            // produced Token::String carries the decoded body so
355            // downstream parser / cast paths treat it identically
356            // to a regular string literal.
357            b'E' | b'e' if peek_eq(bytes, i + 1, b'\'') => {
358                let (tok, consumed) = lex_escape_string(input, i + 1)?;
359                out.push(tok);
360                i += 1 + consumed;
361            }
362            b'"' => {
363                let (tok, consumed) = lex_quoted(input, i, b'"', true)?;
364                out.push(tok);
365                i += consumed;
366            }
367            // MySQL-flavoured backtick-quoted identifier. Same semantics
368            // as the standard `"..."` form, including embedded "``" as
369            // a literal backtick.
370            b'`' => {
371                let (tok, consumed) = lex_quoted(input, i, b'`', true)?;
372                out.push(tok);
373                i += consumed;
374            }
375            b if b.is_ascii_alphabetic() || b == b'_' => {
376                let start = i;
377                i += 1;
378                while i < bytes.len() {
379                    let c = bytes[i];
380                    if c.is_ascii_alphanumeric() || c == b'_' {
381                        i += 1;
382                    } else {
383                        break;
384                    }
385                }
386                let raw = &input[start..i];
387                // v3.0.5: try the keyword table case-insensitively
388                // without allocating; only the ident fall-through
389                // pays for a lowercase String.
390                out.push(keyword_or_ident_raw(raw));
391            }
392            b if b.is_ascii_digit() => {
393                let (tok, consumed) =
394                    lex_number(&input[i..]).map_err(|kind| LexError { kind, pos: i })?;
395                out.push(tok);
396                i += consumed;
397            }
398            b'.' if peek_pred(bytes, i + 1, u8::is_ascii_digit) => {
399                let (tok, consumed) =
400                    lex_number(&input[i..]).map_err(|kind| LexError { kind, pos: i })?;
401                out.push(tok);
402                i += consumed;
403            }
404            b'+' => single(&mut out, Token::Plus, &mut i),
405            // v7.37.6-A — PG JSONB `?` / `?|` / `?&`. Longest-match
406            // order matters: try `?|` and `?&` before bare `?`.
407            // SPG doesn't use `?` as a placeholder (uses `$N`
408            // instead), so the bare `?` slot is free for JSONB.
409            b'?' if peek_eq(bytes, i + 1, b'|') => {
410                out.push(Token::JsonKeysAny);
411                i += 2;
412            }
413            b'?' if peek_eq(bytes, i + 1, b'&') => {
414                out.push(Token::JsonKeysAll);
415                i += 2;
416            }
417            b'?' => single(&mut out, Token::JsonKeyExists, &mut i),
418            b'-' => {
419                // v4.14: `->>` and `->` for JSON path access. `->>`
420                // must be tried before `->` (longest match).
421                if peek_eq(bytes, i + 1, b'>') && peek_eq(bytes, i + 2, b'>') {
422                    out.push(Token::JsonGetText);
423                    i += 3;
424                } else if peek_eq(bytes, i + 1, b'>') {
425                    out.push(Token::JsonGet);
426                    i += 2;
427                } else {
428                    single(&mut out, Token::Minus, &mut i);
429                }
430            }
431            // v6.4.5: `#>>` and `#>` JSON path walk.
432            b'#' => {
433                if peek_eq(bytes, i + 1, b'>') && peek_eq(bytes, i + 2, b'>') {
434                    out.push(Token::JsonGetPathText);
435                    i += 3;
436                } else if peek_eq(bytes, i + 1, b'>') {
437                    out.push(Token::JsonGetPath);
438                    i += 2;
439                } else {
440                    return Err(LexError {
441                        kind: LexErrorKind::UnknownChar('#'),
442                        pos: i,
443                    });
444                }
445            }
446            // v6.4.5: `@>` JSON containment.
447            // v7.12.2: `@@` tsvector / tsquery match.
448            // v7.14.0: `@@NAME` MySQL session variable ref +
449            //          `@NAME` user variable ref. mysqldump preamble
450            //          uses both heavily (`SET @OLD_FOREIGN_KEY_CHECKS
451            //          = @@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0`).
452            //          We lex both as a single SessionVar token so
453            //          the parser can accept and ignore them.
454            b'@' => {
455                if peek_eq(bytes, i + 1, b'>') {
456                    out.push(Token::JsonContains);
457                    i += 2;
458                } else if peek_eq(bytes, i + 1, b'@')
459                    && !is_session_var_ident_start(bytes.get(i + 2).copied())
460                {
461                    // `@@` not followed by an ident-start byte is
462                    // the tsquery `@@` operator.
463                    out.push(Token::TsMatch);
464                    i += 2;
465                } else {
466                    // `@VAR` / `@@VAR` — MySQL user / session
467                    // variable reference. Consume the ident-shaped
468                    // tail and emit as Token::SessionVar so the
469                    // SET parser can accept-and-ignore.
470                    let prefix_end = if peek_eq(bytes, i + 1, b'@') {
471                        i + 2
472                    } else {
473                        i + 1
474                    };
475                    let mut end = prefix_end;
476                    while end < bytes.len() && is_session_var_ident_continue(bytes[end]) {
477                        end += 1;
478                    }
479                    if end == prefix_end {
480                        // v7.17.0 Phase 2.6 — `@` not followed by an
481                        // ident-shaped tail. mysqldump's DEFINER
482                        // form `'user'@'host'` lands here (next
483                        // byte is `'`). Emit as Token::At so the
484                        // parser can stitch the surrounding String
485                        // tokens. Single `@@` already short-circuits
486                        // to Token::TsMatch above, so this only
487                        // fires for a true lone `@`.
488                        out.push(Token::At);
489                        i = prefix_end;
490                        continue;
491                    }
492                    out.push(Token::SessionVar(input[i..end].to_string()));
493                    i = end;
494                }
495            }
496            b'*' => single(&mut out, Token::Star, &mut i),
497            b'/' => single(&mut out, Token::Slash, &mut i),
498            b'(' => single(&mut out, Token::LParen, &mut i),
499            b')' => single(&mut out, Token::RParen, &mut i),
500            b'[' => single(&mut out, Token::LBracket, &mut i),
501            b']' => single(&mut out, Token::RBracket, &mut i),
502            b',' => single(&mut out, Token::Comma, &mut i),
503            b';' => single(&mut out, Token::Semicolon, &mut i),
504            b'.' => single(&mut out, Token::Dot, &mut i),
505            b'=' => single(&mut out, Token::Eq, &mut i),
506            b'<' => {
507                if peek_eq(bytes, i + 1, b'=') && peek_eq(bytes, i + 2, b'>') {
508                    out.push(Token::CosineDistance);
509                    i += 3;
510                } else if peek_eq(bytes, i + 1, b'#') && peek_eq(bytes, i + 2, b'>') {
511                    out.push(Token::InnerProduct);
512                    i += 3;
513                } else if peek_eq(bytes, i + 1, b'-') && peek_eq(bytes, i + 2, b'>') {
514                    out.push(Token::L2Distance);
515                    i += 3;
516                } else if peek_eq(bytes, i + 1, b'<') && peek_eq(bytes, i + 2, b'=') {
517                    // v7.17.0 Phase 3.P0-47 — PG INET `<<=` contained-or-equal.
518                    out.push(Token::InetContainedByEq);
519                    i += 3;
520                } else if peek_eq(bytes, i + 1, b'<') {
521                    // v7.17.0 Phase 3.P0-47 — PG INET `<<` strict contained.
522                    out.push(Token::InetContainedBy);
523                    i += 2;
524                } else if peek_eq(bytes, i + 1, b'@') {
525                    // v7.37.6-A — PG JSONB `<@` contained-by.
526                    out.push(Token::JsonContainedBy);
527                    i += 2;
528                } else if peek_eq(bytes, i + 1, b'=') {
529                    out.push(Token::LtEq);
530                    i += 2;
531                } else if peek_eq(bytes, i + 1, b'>') {
532                    out.push(Token::NotEq);
533                    i += 2;
534                } else {
535                    out.push(Token::Lt);
536                    i += 1;
537                }
538            }
539            b':' if peek_eq(bytes, i + 1, b':') => {
540                out.push(Token::DoubleColon);
541                i += 2;
542            }
543            b':' if peek_eq(bytes, i + 1, b'=') => {
544                // v7.12.4 — PL/pgSQL assignment operator `:=`.
545                out.push(Token::ColonEq);
546                i += 2;
547            }
548            b':' => {
549                // v7.12.4 — bare `:`. Used inside `tsvector` external-form
550                // literals which the cast parser consumes in-token, and as a
551                // separator the PL/pgSQL assignment lexer can recover from.
552                out.push(Token::Colon);
553                i += 1;
554            }
555            b'|' if peek_eq(bytes, i + 1, b'|') => {
556                out.push(Token::Concat);
557                i += 2;
558            }
559            // Bitwise operators (PG integer ops; mailrs IMAP flag
560            // masks: `flags | $1`, `flags & ~$1`).
561            b'|' => {
562                single(&mut out, Token::Pipe, &mut i);
563            }
564            b'~' => {
565                single(&mut out, Token::Tilde, &mut i);
566            }
567            b'>' => {
568                if peek_eq(bytes, i + 1, b'>') && peek_eq(bytes, i + 2, b'=') {
569                    // v7.17.0 Phase 3.P0-47 — PG INET `>>=` contains-or-equal.
570                    out.push(Token::InetContainsEq);
571                    i += 3;
572                } else if peek_eq(bytes, i + 1, b'>') {
573                    // v7.17.0 Phase 3.P0-47 — PG INET `>>` strict contains.
574                    out.push(Token::InetContains);
575                    i += 2;
576                } else if peek_eq(bytes, i + 1, b'=') {
577                    out.push(Token::GtEq);
578                    i += 2;
579                } else {
580                    out.push(Token::Gt);
581                    i += 1;
582                }
583            }
584            b'&' if peek_eq(bytes, i + 1, b'&') => {
585                // v7.17.0 Phase 3.P0-47 — PG INET network overlap `&&`.
586                out.push(Token::InetOverlap);
587                i += 2;
588            }
589            b'&' => {
590                single(&mut out, Token::Amp, &mut i);
591            }
592            b'!' if peek_eq(bytes, i + 1, b'=') => {
593                out.push(Token::NotEq);
594                i += 2;
595            }
596            // v7.9.27 — PG dollar-quoted string `$$ … $$` (or
597            // `$tag$ … $tag$`). Used in `DO $$ … $$ LANGUAGE
598            // plpgsql;` blocks that pg_dump emits for idempotent
599            // migrations. SPG has no PL/pgSQL, so the lexer
600            // consumes the entire string as a single Token::String
601            // and the parser treats the surrounding `DO …;` as a
602            // no-op. mailrs follow-up H1.
603            b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'$' => {
604                // Empty tag form: `$$ … $$`.
605                let end = find_dollar_tag_end(bytes, i + 2, b"$$");
606                let body = match end {
607                    Some(e) => &input[i + 2..e],
608                    None => {
609                        return Err(LexError {
610                            kind: LexErrorKind::UnterminatedString,
611                            pos: i,
612                        });
613                    }
614                };
615                out.push(Token::String(body.to_string()));
616                i = end.unwrap() + 2;
617            }
618            b'$' if i + 1 < bytes.len()
619                && (bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'_') =>
620            {
621                // Tagged form: `$foo$ … $foo$`. Scan the tag
622                // ident, find the closing copy.
623                let mut j = i + 1;
624                while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
625                    j += 1;
626                }
627                if j >= bytes.len() || bytes[j] != b'$' {
628                    // Not a dollar-quoted string — fall through
629                    // to the generic-unknown-char path.
630                    let ch = input[i..].chars().next().unwrap_or('?');
631                    return Err(LexError {
632                        kind: LexErrorKind::UnknownChar(ch),
633                        pos: i,
634                    });
635                }
636                let close: alloc::vec::Vec<u8> = bytes[i..=j].to_vec();
637                let end = find_dollar_tag_end(bytes, j + 1, &close);
638                let body = match end {
639                    Some(e) => &input[j + 1..e],
640                    None => {
641                        return Err(LexError {
642                            kind: LexErrorKind::UnterminatedString,
643                            pos: i,
644                        });
645                    }
646                };
647                out.push(Token::String(body.to_string()));
648                i = end.unwrap() + close.len();
649            }
650            // v6.1.1: `$N` parameter placeholder for the extended
651            // query protocol. PG numbers them 1..=N; we reject $0
652            // and a bare `$` not followed by a digit.
653            b'$' if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() => {
654                let mut j = i + 1;
655                let mut n: u32 = 0;
656                while j < bytes.len() && bytes[j].is_ascii_digit() {
657                    n = n
658                        .saturating_mul(10)
659                        .saturating_add(u32::from(bytes[j] - b'0'));
660                    j += 1;
661                }
662                if n == 0 || n > u32::from(u16::MAX) {
663                    return Err(LexError {
664                        kind: LexErrorKind::BadNumber(input[i..j].to_string()),
665                        pos: i,
666                    });
667                }
668                #[allow(clippy::cast_possible_truncation)]
669                out.push(Token::Placeholder(n as u16));
670                i = j;
671            }
672            _ => {
673                let ch = input[i..].chars().next().unwrap_or('?');
674                return Err(LexError {
675                    kind: LexErrorKind::UnknownChar(ch),
676                    pos: i,
677                });
678            }
679        }
680    }
681    out.push(Token::Eof);
682    Ok(out)
683}
684
685fn peek_eq(bytes: &[u8], i: usize, target: u8) -> bool {
686    bytes.get(i) == Some(&target)
687}
688
689/// v7.14.0 — recognise the first byte of a MySQL session/user
690/// variable name (after `@` or `@@`). PG-strict idents are ASCII
691/// letter or underscore; MySQL also allows leading digits inside
692/// quoted names but unquoted vars match the same shape.
693fn is_session_var_ident_start(b: Option<u8>) -> bool {
694    matches!(b, Some(c) if c.is_ascii_alphabetic() || c == b'_')
695}
696
697/// Continuation byte for a `@VAR`/`@@VAR` ident (after the first
698/// alphabet/underscore byte). Letters, digits, underscore, dot
699/// (MySQL allows session-scope qualifiers like
700/// `@@global.sql_mode`) and `$` (some MySQL versions accept it).
701fn is_session_var_ident_continue(b: u8) -> bool {
702    b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'$'
703}
704
705/// v7.9.27 — find the start index of the next occurrence of `tag`
706/// (e.g. `b"$$"` or `b"$foo$"`) in `bytes` starting at `from`.
707fn find_dollar_tag_end(bytes: &[u8], from: usize, tag: &[u8]) -> Option<usize> {
708    if tag.is_empty() || from > bytes.len() {
709        return None;
710    }
711    let mut i = from;
712    while i + tag.len() <= bytes.len() {
713        if &bytes[i..i + tag.len()] == tag {
714            return Some(i);
715        }
716        i += 1;
717    }
718    None
719}
720
721fn peek_pred<F: Fn(&u8) -> bool>(bytes: &[u8], i: usize, pred: F) -> bool {
722    bytes.get(i).is_some_and(pred)
723}
724
725fn single(out: &mut Vec<Token>, tok: Token, i: &mut usize) {
726    out.push(tok);
727    *i += 1;
728}
729
730/// Length-first ASCII-CI keyword lookup. Avoids allocating a
731/// lowercase `String` when the input matches a keyword; only the ident
732/// fall-through path pays for the lowercase copy.
733///
734/// Grouped by length so the outer `match` becomes a small jump table.
735/// Within a length bucket every keyword has either a unique first
736/// byte (cheap dispatch) or a small set of disambiguating
737/// trailing-byte comparisons. All comparisons are ASCII-CI (XOR
738/// 0x20 on each byte before the compare).
739fn keyword_or_ident_raw(raw: &str) -> Token {
740    let b = raw.as_bytes();
741    let tok = match b.len() {
742        2 => kw_len2(b),
743        3 => kw_len3(b),
744        4 => kw_len4(b),
745        5 => kw_len5(b),
746        6 => kw_len6(b),
747        7 => kw_len7(b),
748        8 => kw_len8(b),
749        9 => kw_len9(b),
750        10 => kw_len10(b),
751        11 => kw_len11(b),
752        12 => kw_len12(b),
753        _ => None,
754    };
755    match tok {
756        Some(t) => t,
757        // Ident fall-through: this is the only path that allocates.
758        None => Token::Ident(raw.to_ascii_lowercase()),
759    }
760}
761
762/// ASCII-CI equality on a byte slice against a lowercase literal.
763/// Letters that differ only in case satisfy `(a ^ b) == 0x20`; other
764/// mismatches set bits outside the 0x20 mask. We compare each byte
765/// against its lowercase form via `to_ascii_lowercase` for clarity;
766/// the compiler folds the loop into a tight cmov chain.
767#[inline]
768fn eq_ci(input: &[u8], lower: &[u8]) -> bool {
769    if input.len() != lower.len() {
770        return false;
771    }
772    for i in 0..lower.len() {
773        if input[i].to_ascii_lowercase() != lower[i] {
774            return false;
775        }
776    }
777    true
778}
779
780#[inline]
781fn kw_len2(b: &[u8]) -> Option<Token> {
782    // 7 keywords: as, by, in, is, on, or, to
783    if eq_ci(b, b"as") {
784        return Some(Token::As);
785    }
786    if eq_ci(b, b"by") {
787        return Some(Token::By);
788    }
789    if eq_ci(b, b"in") {
790        return Some(Token::In);
791    }
792    if eq_ci(b, b"is") {
793        return Some(Token::Is);
794    }
795    if eq_ci(b, b"on") {
796        return Some(Token::On);
797    }
798    if eq_ci(b, b"or") {
799        return Some(Token::Or);
800    }
801    if eq_ci(b, b"to") {
802        return Some(Token::To);
803    }
804    None
805}
806
807#[inline]
808fn kw_len3(b: &[u8]) -> Option<Token> {
809    // 5 keywords: all, and, asc, not, for
810    if eq_ci(b, b"for") {
811        return Some(Token::For);
812    }
813    if eq_ci(b, b"all") {
814        return Some(Token::All);
815    }
816    if eq_ci(b, b"and") {
817        return Some(Token::And);
818    }
819    if eq_ci(b, b"asc") {
820        return Some(Token::Asc);
821    }
822    if eq_ci(b, b"not") {
823        return Some(Token::Not);
824    }
825    None
826}
827
828#[inline]
829fn kw_len4(b: &[u8]) -> Option<Token> {
830    // 10 keywords: from, null, true, into, like, join, left, show, desc, drop
831    if eq_ci(b, b"from") {
832        return Some(Token::From);
833    }
834    if eq_ci(b, b"drop") {
835        return Some(Token::Drop);
836    }
837    if eq_ci(b, b"null") {
838        return Some(Token::Null);
839    }
840    if eq_ci(b, b"true") {
841        return Some(Token::True);
842    }
843    if eq_ci(b, b"into") {
844        return Some(Token::Into);
845    }
846    if eq_ci(b, b"like") {
847        return Some(Token::Like);
848    }
849    if eq_ci(b, b"join") {
850        return Some(Token::Join);
851    }
852    if eq_ci(b, b"left") {
853        return Some(Token::Left);
854    }
855    if eq_ci(b, b"show") {
856        return Some(Token::Show);
857    }
858    if eq_ci(b, b"desc") {
859        return Some(Token::Desc);
860    }
861    None
862}
863
864#[inline]
865fn kw_len5(b: &[u8]) -> Option<Token> {
866    // 12 keywords: false, where, table, index, begin, order, limit,
867    // group, union, inner, cross, outer
868    if eq_ci(b, b"false") {
869        return Some(Token::False);
870    }
871    if eq_ci(b, b"where") {
872        return Some(Token::Where);
873    }
874    if eq_ci(b, b"table") {
875        return Some(Token::Table);
876    }
877    if eq_ci(b, b"index") {
878        return Some(Token::Index);
879    }
880    if eq_ci(b, b"begin") {
881        return Some(Token::Begin);
882    }
883    if eq_ci(b, b"order") {
884        return Some(Token::Order);
885    }
886    if eq_ci(b, b"limit") {
887        return Some(Token::Limit);
888    }
889    if eq_ci(b, b"group") {
890        return Some(Token::Group);
891    }
892    if eq_ci(b, b"union") {
893        return Some(Token::Union);
894    }
895    if eq_ci(b, b"inner") {
896        return Some(Token::Inner);
897    }
898    if eq_ci(b, b"cross") {
899        return Some(Token::Cross);
900    }
901    if eq_ci(b, b"outer") {
902        return Some(Token::Outer);
903    }
904    None
905}
906
907#[inline]
908fn kw_len6(b: &[u8]) -> Option<Token> {
909    // 9 keywords: select, create, insert, values, commit, having, offset, tables, except
910    if eq_ci(b, b"select") {
911        return Some(Token::Select);
912    }
913    if eq_ci(b, b"tables") {
914        return Some(Token::Tables);
915    }
916    if eq_ci(b, b"except") {
917        return Some(Token::Except);
918    }
919    if eq_ci(b, b"create") {
920        return Some(Token::Create);
921    }
922    if eq_ci(b, b"insert") {
923        return Some(Token::Insert);
924    }
925    if eq_ci(b, b"values") {
926        return Some(Token::Values);
927    }
928    if eq_ci(b, b"commit") {
929        return Some(Token::Commit);
930    }
931    if eq_ci(b, b"having") {
932        return Some(Token::Having);
933    }
934    if eq_ci(b, b"offset") {
935        return Some(Token::Offset);
936    }
937    None
938}
939
940#[inline]
941fn kw_len7(b: &[u8]) -> Option<Token> {
942    // 4 keywords: between, default, release, extract
943    if eq_ci(b, b"between") {
944        return Some(Token::Between);
945    }
946    if eq_ci(b, b"default") {
947        return Some(Token::Default);
948    }
949    if eq_ci(b, b"release") {
950        return Some(Token::Release);
951    }
952    if eq_ci(b, b"extract") {
953        return Some(Token::Extract);
954    }
955    None
956}
957
958#[inline]
959fn kw_len8(b: &[u8]) -> Option<Token> {
960    // 3 keywords: rollback, distinct, interval
961    if eq_ci(b, b"rollback") {
962        return Some(Token::Rollback);
963    }
964    if eq_ci(b, b"distinct") {
965        return Some(Token::Distinct);
966    }
967    if eq_ci(b, b"interval") {
968        return Some(Token::Interval);
969    }
970    None
971}
972
973#[inline]
974fn kw_len9(b: &[u8]) -> Option<Token> {
975    // 2 keywords: savepoint, partition
976    if eq_ci(b, b"savepoint") {
977        return Some(Token::Savepoint);
978    }
979    if eq_ci(b, b"partition") {
980        return Some(Token::Partition);
981    }
982    None
983}
984
985#[inline]
986fn kw_len10(b: &[u8]) -> Option<Token> {
987    // 1 keyword: connection
988    if eq_ci(b, b"connection") {
989        return Some(Token::Connection);
990    }
991    None
992}
993
994#[inline]
995fn kw_len11(b: &[u8]) -> Option<Token> {
996    // 1 keyword: publication
997    if eq_ci(b, b"publication") {
998        return Some(Token::Publication);
999    }
1000    None
1001}
1002
1003#[inline]
1004fn kw_len12(b: &[u8]) -> Option<Token> {
1005    // 1 keyword: subscription
1006    if eq_ci(b, b"subscription") {
1007        return Some(Token::Subscription);
1008    }
1009    None
1010}
1011
1012/// Lex a `'...'` string literal or `"..."` quoted identifier. The opening
1013/// quote sits at `input[start]`; `quote` is its byte value. `is_ident` selects
1014/// the resulting token shape.
1015///
1016/// PG-style doubling escapes the quote: `''` inside `'...'` is a literal `'`,
1017/// same for `""` inside `"..."`.
1018fn lex_quoted(
1019    input: &str,
1020    start: usize,
1021    quote: u8,
1022    is_ident: bool,
1023) -> Result<(Token, usize), LexError> {
1024    let bytes = input.as_bytes();
1025    let mut i = start + 1;
1026    let mut s = String::new();
1027    loop {
1028        if i >= bytes.len() {
1029            return Err(LexError {
1030                kind: if is_ident {
1031                    LexErrorKind::UnterminatedQuotedIdent
1032                } else {
1033                    LexErrorKind::UnterminatedString
1034                },
1035                pos: start,
1036            });
1037        }
1038        if bytes[i] == quote {
1039            if peek_eq(bytes, i + 1, quote) {
1040                s.push(quote as char);
1041                i += 2;
1042            } else {
1043                i += 1;
1044                break;
1045            }
1046        } else {
1047            let ch = input[i..].chars().next().expect("non-empty UTF-8 boundary");
1048            s.push(ch);
1049            i += ch.len_utf8();
1050        }
1051    }
1052    let tok = if is_ident {
1053        Token::QuotedIdent(s)
1054    } else {
1055        Token::String(s)
1056    };
1057    Ok((tok, i - start))
1058}
1059
1060/// v7.18 — Lex a PG escape-string literal `E'...'`. `start` points
1061/// at the opening single quote (the `E` was matched by the caller
1062/// and is NOT part of `start`'s offset semantics — the consumed
1063/// count returned excludes the `E`, which the caller adds).
1064///
1065/// Recognised escape sequences:
1066///   \\ \' \" — literal backslash / quote
1067///   \n \r \t \b \f — standard whitespace controls
1068///   \0 — NUL
1069///   \xHH — single hex byte (1–2 hex digits)
1070///   \NNN — octal byte (1–3 octal digits)
1071/// Any other `\X` decodes to the literal byte `X` (PG warns; SPG
1072/// follows the lenient behaviour pg_dump output relies on).
1073///
1074/// Doubled `''` is still a literal `'` (same as the non-E form).
1075fn lex_escape_string(input: &str, start: usize) -> Result<(Token, usize), LexError> {
1076    let bytes = input.as_bytes();
1077    debug_assert_eq!(bytes[start], b'\'');
1078    let mut i = start + 1;
1079    let mut s = String::new();
1080    loop {
1081        if i >= bytes.len() {
1082            return Err(LexError {
1083                kind: LexErrorKind::UnterminatedString,
1084                pos: start,
1085            });
1086        }
1087        let b = bytes[i];
1088        if b == b'\'' {
1089            if peek_eq(bytes, i + 1, b'\'') {
1090                s.push('\'');
1091                i += 2;
1092                continue;
1093            }
1094            i += 1;
1095            break;
1096        }
1097        if b == b'\\' && i + 1 < bytes.len() {
1098            let n = bytes[i + 1];
1099            match n {
1100                b'\\' => {
1101                    s.push('\\');
1102                    i += 2;
1103                }
1104                b'\'' => {
1105                    s.push('\'');
1106                    i += 2;
1107                }
1108                b'"' => {
1109                    s.push('"');
1110                    i += 2;
1111                }
1112                b'n' => {
1113                    s.push('\n');
1114                    i += 2;
1115                }
1116                b'r' => {
1117                    s.push('\r');
1118                    i += 2;
1119                }
1120                b't' => {
1121                    s.push('\t');
1122                    i += 2;
1123                }
1124                b'b' => {
1125                    s.push('\u{0008}');
1126                    i += 2;
1127                }
1128                b'f' => {
1129                    s.push('\u{000C}');
1130                    i += 2;
1131                }
1132                b'0' if i + 2 >= bytes.len() || !bytes[i + 2].is_ascii_digit() => {
1133                    s.push('\0');
1134                    i += 2;
1135                }
1136                b'x' => {
1137                    // \xH or \xHH — single byte by hex.
1138                    let h1 = bytes.get(i + 2).copied();
1139                    let h2 = bytes.get(i + 3).copied();
1140                    let n1 = h1.and_then(hex_digit_value);
1141                    let n2 = h2.and_then(hex_digit_value);
1142                    match (n1, n2) {
1143                        (Some(a), Some(b2)) => {
1144                            s.push((((a << 4) | b2) as u8) as char);
1145                            i += 4;
1146                        }
1147                        (Some(a), _) => {
1148                            s.push((a as u8) as char);
1149                            i += 3;
1150                        }
1151                        _ => {
1152                            // \x with no hex follows — literal x.
1153                            s.push('x');
1154                            i += 2;
1155                        }
1156                    }
1157                }
1158                d if d.is_ascii_digit() && d < b'8' => {
1159                    // \NNN octal — up to 3 octal digits.
1160                    let mut value: u32 = u32::from(d - b'0');
1161                    let mut take = 2;
1162                    while take < 4 {
1163                        let next = bytes.get(i + take).copied();
1164                        match next {
1165                            Some(c) if c.is_ascii_digit() && c < b'8' => {
1166                                value = (value << 3) | u32::from(c - b'0');
1167                                take += 1;
1168                            }
1169                            _ => break,
1170                        }
1171                    }
1172                    if let Some(c) = char::from_u32(value) {
1173                        s.push(c);
1174                    } else {
1175                        // Invalid Unicode — preserve as raw byte char.
1176                        s.push((value & 0xFF) as u8 as char);
1177                    }
1178                    i += take;
1179                }
1180                other => {
1181                    // Lenient fallback — same as PG with
1182                    // `standard_conforming_strings = off` warning:
1183                    // decode `\X` to literal `X`.
1184                    s.push(other as char);
1185                    i += 2;
1186                }
1187            }
1188        } else {
1189            let ch = input[i..].chars().next().expect("non-empty UTF-8 boundary");
1190            s.push(ch);
1191            i += ch.len_utf8();
1192        }
1193    }
1194    Ok((Token::String(s), i - start))
1195}
1196
1197fn hex_digit_value(b: u8) -> Option<u32> {
1198    match b {
1199        b'0'..=b'9' => Some(u32::from(b - b'0')),
1200        b'a'..=b'f' => Some(u32::from(b - b'a' + 10)),
1201        b'A'..=b'F' => Some(u32::from(b - b'A' + 10)),
1202        _ => None,
1203    }
1204}
1205
1206fn lex_number(s: &str) -> Result<(Token, usize), LexErrorKind> {
1207    let bytes = s.as_bytes();
1208    let mut i = 0usize;
1209    let mut is_float = false;
1210
1211    while i < bytes.len() && bytes[i].is_ascii_digit() {
1212        i += 1;
1213    }
1214    if i < bytes.len() && bytes[i] == b'.' {
1215        is_float = true;
1216        i += 1;
1217        while i < bytes.len() && bytes[i].is_ascii_digit() {
1218            i += 1;
1219        }
1220    }
1221    if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
1222        is_float = true;
1223        i += 1;
1224        if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
1225            i += 1;
1226        }
1227        let exp_start = i;
1228        while i < bytes.len() && bytes[i].is_ascii_digit() {
1229            i += 1;
1230        }
1231        if exp_start == i {
1232            return Err(LexErrorKind::BadNumber(s[..i].to_string()));
1233        }
1234    }
1235
1236    let lit = &s[..i];
1237    if is_float {
1238        lit.parse::<f64>()
1239            .map(|v| (Token::Float(v), i))
1240            .map_err(|_| LexErrorKind::BadNumber(lit.to_string()))
1241    } else {
1242        lit.parse::<i64>()
1243            .map(|v| (Token::Integer(v), i))
1244            .map_err(|_| LexErrorKind::BadNumber(lit.to_string()))
1245    }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250    use super::*;
1251    use alloc::vec;
1252
1253    fn lex(s: &str) -> Vec<Token> {
1254        tokenize(s).expect("lex ok")
1255    }
1256
1257    #[test]
1258    fn empty_yields_only_eof() {
1259        assert_eq!(lex(""), vec![Token::Eof]);
1260    }
1261
1262    #[test]
1263    fn whitespace_only_yields_only_eof() {
1264        assert_eq!(lex("   \t\n  "), vec![Token::Eof]);
1265    }
1266
1267    #[test]
1268    fn keywords_are_case_insensitive() {
1269        assert_eq!(
1270            lex("SELECT select Select"),
1271            vec![Token::Select, Token::Select, Token::Select, Token::Eof]
1272        );
1273    }
1274
1275    #[test]
1276    fn identifiers_lowercase_ascii() {
1277        assert_eq!(
1278            lex("hello WORLD _x x1"),
1279            vec![
1280                Token::Ident("hello".into()),
1281                Token::Ident("world".into()),
1282                Token::Ident("_x".into()),
1283                Token::Ident("x1".into()),
1284                Token::Eof,
1285            ]
1286        );
1287    }
1288
1289    #[test]
1290    fn quoted_identifier_keeps_case_and_handles_embedded_quote() {
1291        assert_eq!(
1292            lex(r#""User Name" "a""b""#),
1293            vec![
1294                Token::QuotedIdent("User Name".into()),
1295                Token::QuotedIdent("a\"b".into()),
1296                Token::Eof,
1297            ]
1298        );
1299    }
1300
1301    #[test]
1302    fn integer_and_float_literals() {
1303        assert_eq!(
1304            lex("0 42 1.5 .5 1e10 2.5e-3"),
1305            vec![
1306                Token::Integer(0),
1307                Token::Integer(42),
1308                Token::Float(1.5),
1309                Token::Float(0.5),
1310                Token::Float(1e10),
1311                Token::Float(2.5e-3),
1312                Token::Eof,
1313            ]
1314        );
1315    }
1316
1317    #[test]
1318    fn negative_number_is_minus_then_integer() {
1319        // PG follows this: unary minus is a separate token, parser folds it.
1320        assert_eq!(
1321            lex("-42"),
1322            vec![Token::Minus, Token::Integer(42), Token::Eof]
1323        );
1324    }
1325
1326    #[test]
1327    fn string_literal_doubled_quote_escape() {
1328        assert_eq!(
1329            lex("'hello' 'it''s'"),
1330            vec![
1331                Token::String("hello".into()),
1332                Token::String("it's".into()),
1333                Token::Eof,
1334            ]
1335        );
1336    }
1337
1338    #[test]
1339    fn all_comparison_and_arithmetic_operators() {
1340        assert_eq!(
1341            lex("= <> != < <= > >= + - * /"),
1342            vec![
1343                Token::Eq,
1344                Token::NotEq,
1345                Token::NotEq,
1346                Token::Lt,
1347                Token::LtEq,
1348                Token::Gt,
1349                Token::GtEq,
1350                Token::Plus,
1351                Token::Minus,
1352                Token::Star,
1353                Token::Slash,
1354                Token::Eof,
1355            ]
1356        );
1357    }
1358
1359    #[test]
1360    fn punctuation() {
1361        assert_eq!(
1362            lex("( ) , ; ."),
1363            vec![
1364                Token::LParen,
1365                Token::RParen,
1366                Token::Comma,
1367                Token::Semicolon,
1368                Token::Dot,
1369                Token::Eof,
1370            ]
1371        );
1372    }
1373
1374    #[test]
1375    fn line_comment_skipped() {
1376        assert_eq!(
1377            lex("SELECT -- trailing junk\nFROM"),
1378            vec![Token::Select, Token::From, Token::Eof]
1379        );
1380    }
1381
1382    #[test]
1383    fn block_comment_skipped() {
1384        assert_eq!(
1385            lex("SELECT /* skipped */ 1"),
1386            vec![Token::Select, Token::Integer(1), Token::Eof]
1387        );
1388    }
1389
1390    #[test]
1391    fn unterminated_string_errors() {
1392        let err = tokenize("'oops").unwrap_err();
1393        assert!(matches!(err.kind, LexErrorKind::UnterminatedString));
1394        assert_eq!(err.pos, 0);
1395    }
1396
1397    #[test]
1398    fn unterminated_block_comment_errors() {
1399        let err = tokenize("/* never closed").unwrap_err();
1400        assert!(matches!(err.kind, LexErrorKind::UnterminatedBlockComment));
1401    }
1402
1403    #[test]
1404    fn unknown_char_errors() {
1405        // v7.17.0 Phase 2.6 — `@` standalone now lexes as
1406        // Token::At (mysqldump `'user'@'host'` DEFINER stitching).
1407        // Use `?` for the unknown-char regression; PG `?` operator
1408        // family is parsed as JSON ops in the prefix `?` shape
1409        // would land in lex paths; bare `?` is unknown.
1410        let err = tokenize("\x07").unwrap_err();
1411        assert!(matches!(err.kind, LexErrorKind::UnknownChar(_)));
1412    }
1413
1414    #[test]
1415    fn at_alone_lexes_as_punctuation() {
1416        // v7.17.0 Phase 2.6 — the `'user'@'host'` MySQL DEFINER
1417        // form needs `@` to lex as a standalone token.
1418        assert_eq!(
1419            lex("'u'@'h'"),
1420            vec![
1421                Token::String("u".into()),
1422                Token::At,
1423                Token::String("h".into()),
1424                Token::Eof,
1425            ]
1426        );
1427    }
1428
1429    #[test]
1430    fn dot_in_qualified_column() {
1431        assert_eq!(
1432            lex("t.col"),
1433            vec![
1434                Token::Ident("t".into()),
1435                Token::Dot,
1436                Token::Ident("col".into()),
1437                Token::Eof,
1438            ]
1439        );
1440    }
1441
1442    // --- v0.11 brackets + distance op + vector keyword --------------------
1443
1444    #[test]
1445    fn brackets_are_distinct_tokens() {
1446        assert_eq!(
1447            lex("[ ]"),
1448            vec![Token::LBracket, Token::RBracket, Token::Eof]
1449        );
1450    }
1451
1452    #[test]
1453    fn l2_distance_is_three_char_token() {
1454        assert_eq!(
1455            lex("a <-> b"),
1456            vec![
1457                Token::Ident("a".into()),
1458                Token::L2Distance,
1459                Token::Ident("b".into()),
1460                Token::Eof,
1461            ]
1462        );
1463        // Bare `<-` should NOT match L2Distance.
1464        assert_eq!(
1465            lex("a <- b"),
1466            vec![
1467                Token::Ident("a".into()),
1468                Token::Lt,
1469                Token::Minus,
1470                Token::Ident("b".into()),
1471                Token::Eof,
1472            ]
1473        );
1474    }
1475
1476    #[test]
1477    fn order_by_limit_are_keywords() {
1478        assert_eq!(
1479            lex("ORDER BY LIMIT"),
1480            vec![Token::Order, Token::By, Token::Limit, Token::Eof]
1481        );
1482    }
1483
1484    // --- v1.2: pgvector distance ops + PG cast --------------------------
1485
1486    #[test]
1487    fn inner_product_operator_3char() {
1488        assert_eq!(
1489            lex("a <#> b"),
1490            vec![
1491                Token::Ident("a".into()),
1492                Token::InnerProduct,
1493                Token::Ident("b".into()),
1494                Token::Eof,
1495            ]
1496        );
1497    }
1498
1499    #[test]
1500    fn cosine_distance_operator_3char() {
1501        assert_eq!(
1502            lex("a <=> b"),
1503            vec![
1504                Token::Ident("a".into()),
1505                Token::CosineDistance,
1506                Token::Ident("b".into()),
1507                Token::Eof,
1508            ]
1509        );
1510        // Make sure `<=` and `<>` and `<->` still lex right when `<=>` is
1511        // around (greedy match takes the longest).
1512        assert_eq!(
1513            lex("a <= b"),
1514            vec![
1515                Token::Ident("a".into()),
1516                Token::LtEq,
1517                Token::Ident("b".into()),
1518                Token::Eof,
1519            ]
1520        );
1521    }
1522
1523    #[test]
1524    fn double_colon_cast_token() {
1525        assert_eq!(
1526            lex("x::INT"),
1527            vec![
1528                Token::Ident("x".into()),
1529                Token::DoubleColon,
1530                Token::Ident("int".into()),
1531                Token::Eof,
1532            ]
1533        );
1534    }
1535
1536    #[test]
1537    fn lone_single_colon_lexes_as_colon_token() {
1538        // v7.12.4 — single `:` is now a token (PL/pgSQL surface
1539        // + tsvector external-form literal both need it). The
1540        // pre-v7.12.4 "single colon = unknown char" behaviour
1541        // was incidental.
1542        let toks = tokenize(":x").expect("colon now lexes");
1543        assert_eq!(toks[0], Token::Colon);
1544    }
1545
1546    #[test]
1547    fn colon_eq_lexes_as_assignment() {
1548        // v7.12.4 — PL/pgSQL assignment operator.
1549        let toks = tokenize("x := 1").expect("colon-eq lexes");
1550        // Tokens: Ident("x"), ColonEq, NumberLiteral
1551        assert!(matches!(toks[1], Token::ColonEq));
1552    }
1553
1554    #[test]
1555    fn pg_escape_string_double_backslash_decodes_to_single() {
1556        // v7.18 — E'\\xdeadbeef' decodes to literal `\xdeadbeef`
1557        // (10 chars: backslash + xdeadbeef). The downstream
1558        // `::bytea` cast then reads that as the PG hex-form bytea
1559        // literal. mailrs D-pre #3.
1560        let toks = tokenize(r"E'\\xdeadbeef'").expect("E-string lexes");
1561        assert_eq!(toks, vec![Token::String(r"\xdeadbeef".into()), Token::Eof]);
1562    }
1563
1564    #[test]
1565    fn pg_escape_string_supports_basic_escapes() {
1566        // \n / \t / \' / \\ — the PG standard set.
1567        let toks = tokenize(r"E'a\nb\tc\'d\\e'").expect("E-string lexes");
1568        assert_eq!(toks, vec![Token::String("a\nb\tc'd\\e".into()), Token::Eof]);
1569    }
1570
1571    #[test]
1572    fn pg_escape_string_hex_byte() {
1573        // \xHH single byte. \x41 = 'A'.
1574        let toks = tokenize(r"E'\x41B\x42'").expect("E-string lexes");
1575        assert_eq!(toks, vec![Token::String("ABB".into()), Token::Eof]);
1576    }
1577
1578    #[test]
1579    fn pg_escape_string_lowercase_e_prefix() {
1580        let toks = tokenize(r"e'hi\n'").expect("e-string lexes");
1581        assert_eq!(toks, vec![Token::String("hi\n".into()), Token::Eof]);
1582    }
1583
1584    #[test]
1585    fn pg_escape_string_doubled_quote() {
1586        // Even in E-string the doubled '' is a literal '.
1587        let toks = tokenize(r"E'it''s ok'").expect("E-string lexes");
1588        assert_eq!(toks, vec![Token::String("it's ok".into()), Token::Eof]);
1589    }
1590}