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