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    // v7.38 (read01) — exact decimal literal (`1.5`, `0.1`) and any integer
59    // literal too large for i64. PG types a dotted literal as NUMERIC (not
60    // double) and an over-i64 integer as NUMERIC; the exact source text is
61    // carried so no precision is lost before it becomes a Value::Numeric.
62    Numeric(String),
63    String(String),
64    /// v7.39 (round 367, M20) — a MySQL `0x…` hexadecimal literal in the
65    /// MySQL dialect: a BINARY STRING, not an integer. Carries the raw hex
66    /// digits (parser decodes, left-padding an odd count). The PG dialect
67    /// never emits this — there `0x…` stays a radix-16 `Integer`.
68    HexBytes(String),
69
70    // Operators
71    Plus,
72    Minus,
73    Star,
74    Slash,
75    /// v7.37.7 C.1.7 — PG `%` integer modulo operator (also short for
76    /// `mod(y, x)`). MySQL accepts `MOD` keyword + `%`; SPG follows
77    /// the PG form here. Token alone (no `%=` etc., kept simple).
78    Percent,
79    Eq,
80    NotEq,
81    Lt,
82    LtEq,
83    Gt,
84    GtEq,
85    /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR strict contained-in
86    /// `<<`. LHS is strictly inside RHS (no equality).
87    InetContainedBy,
88    /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR contained-in-or-equal
89    /// `<<=`. LHS network ⊆ RHS network.
90    InetContainedByEq,
91    /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR strict contains `>>`.
92    /// LHS strictly contains RHS.
93    InetContains,
94    /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR contains-or-equal `>>=`.
95    /// LHS network ⊇ RHS network.
96    InetContainsEq,
97    /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR network overlap `&&`.
98    /// Either side contains any address of the other.
99    InetOverlap,
100    /// v7.39 — range `&<` / `&>`.
101    OverLeft,
102    OverRight,
103
104    // Punctuation
105    LParen,
106    RParen,
107    LBracket,
108    RBracket,
109    Comma,
110    Semicolon,
111    Dot,
112    /// v7.37.20 (20.4) — `..` range operator, used by PL/pgSQL
113    /// `FOR i IN 1..10 LOOP` bounds. Emitted by the lexer as a
114    /// single token so parse_expr doesn't have to distinguish
115    /// range-`.` from struct-field-`.`.
116    DotDot,
117    /// v7.39 (round 353, M10) — MySQL's `!` (logical negation). Its own
118    /// token because its precedence is nothing like `NOT`'s.
119    Bang,
120    /// v7.17.0 Phase 2.6 — standalone `@` punctuation. Emitted when
121    /// `@` is NOT followed by an ident-start byte (i.e. the
122    /// `@VAR` / `@@VAR` SessionVar path doesn't match). Lets the
123    /// parser stitch the MySQL `'user'@'host'` DEFINER form back
124    /// together as String + At + String. Pre-2.6 this same shape
125    /// surfaced as a `LexErrorKind::UnknownChar('@')` and broke
126    /// every mysqldump CREATE VIEW with a DEFINER clause at lex
127    /// time.
128    At,
129    /// pgvector L2 distance operator `<->`. Lexed as one token so the
130    /// parser can give it its own precedence rung.
131    /// v4.14 `->` — JSON object/array element access, returns json.
132    JsonGet,
133    /// v4.14 `->>` — same access, returns text.
134    JsonGetText,
135    /// v6.4.5 `#>` — JSON path walk, returns json. Path is the
136    /// right-hand TEXT with PG `{a,b,0}` syntax.
137    JsonGetPath,
138    /// v6.4.5 `#>>` — same walk, returns text.
139    JsonGetPathText,
140    /// `#-` — delete the value at a nested JSON path. RHS is a PG
141    /// text-array literal `{a,b}`.
142    JsonDeletePath,
143    /// v6.4.5 `@>` — JSON containment. `j @> sub` returns true if
144    /// every key/value in `sub` is present in `j` with structural
145    /// containment for objects + arrays.
146    JsonContains,
147    /// `@?` jsonpath existence operator.
148    JsonPathExists,
149    /// v7.37.6-A `<@` — JSON contained-by. `a <@ b` ⇔ `b @> a`.
150    JsonContainedBy,
151    /// v7.37.6-A `?` — JSON key exists (object), or element-as-text
152    /// exists (array). `j ? 'key'` returns BOOL.
153    JsonKeyExists,
154    /// v7.37.6-A `?|` — JSON any-key-exists. `j ?| ARRAY['a','b']`
155    /// returns BOOL; true if any one of the listed keys exists in `j`.
156    JsonKeysAny,
157    /// v7.39 (read01 geo_ops.c) — `?||` "is parallel" (lseg / line).
158    GeomParallel,
159    /// v7.39 (read01 geo_ops.c) — `?-|` "is perpendicular" (lseg / line).
160    GeomPerp,
161    /// v7.39 (read01 geo_ops.c) — `~=` "same as" (geometric equality).
162    GeomSameAs,
163    /// v7.39 (read01 geo_ops.c) — `##` closest point.
164    ClosestPoint,
165    /// v7.39 (read01 geo_ops.c) — `?-` "is horizontal" (binary points /
166    /// prefix lseg-line).
167    GeomHoriz,
168    /// v7.37.6-A `?&` — JSON all-keys-exist. `j ?& ARRAY['a','b']`
169    /// returns BOOL; true if every listed key exists in `j`.
170    JsonKeysAll,
171    /// v7.12.2 `@@` — tsvector / tsquery match. Either ordering
172    /// (`vec @@ q` or `q @@ vec`) parses; engine eval normalises
173    /// before matching.
174    TsMatch,
175    /// v7.39 (round 508) — `@@@`, PG's deprecated spelling of `@@`. Kept
176    /// because `pg_operator` still carries it and old application SQL still
177    /// writes it.
178    TsMatchOld,
179    /// v7.39 (round 508) — `@-@`, "length of" (lseg, path).
180    AtMinusAt,
181    /// v7.39 (round 508) — `?#`, "do these intersect" (box / line / lseg /
182    /// path, in every combination PG defines).
183    Intersects,
184    /// v7.39 (round 508) — `<^` "is strictly below" and `>^` "is strictly
185    /// above" (point, box).
186    IsBelow,
187    IsAbove,
188    /// v7.39 (round 508) — the `text_pattern_ops` comparisons `~<~`, `~<=~`,
189    /// `~>~`, `~>=~`. They compare BYTES, ignoring collation, which is what
190    /// makes them index-usable for LIKE prefixes: `'A' ~<~ 'a'` is true
191    /// where `'A' < 'a'` is false under a non-C collation. pg_dump emits
192    /// them, so a dump of an ordinary database would not restore.
193    PatternLt,
194    PatternLtEq,
195    PatternGt,
196    PatternGtEq,
197    L2Distance,
198    /// pgvector inner-product operator `<#>` (returns negative dot product
199    /// so smaller still means more similar — same semantics as pgvector).
200    InnerProduct,
201    /// pgvector cosine distance operator `<=>`.
202    CosineDistance,
203    /// PG-style cast `expr::type` — single token because we want it to bind
204    /// at postfix precedence.
205    DoubleColon,
206    /// v7.12.4 — PL/pgSQL assignment operator `:=`.
207    /// Outside PL/pgSQL bodies this token has no SQL-side meaning.
208    ColonEq,
209    /// v7.38 (read01, T14) — `=>` names a function argument
210    /// (`make_date(year => 2024, …)`).
211    FatArrow,
212    /// v7.12.4 — bare `:` separator. Used inside `tsvector` external-form
213    /// literals (`'cat:1 dog:2'::tsvector`) and as the fallback path for
214    /// the PL/pgSQL assignment lexer.
215    Colon,
216    /// Standard SQL string concatenation `||`.
217    Concat,
218    /// Bitwise OR `|` (single pipe — `||` lexes as Concat first).
219    Pipe,
220    /// Bitwise AND `&` (single amp — `&&` lexes as InetOverlap first).
221    Amp,
222    /// Bitwise NOT `~` (prefix); regex match in binary position.
223    Tilde,
224    /// Case-insensitive regex match `~*`.
225    TildeStar,
226    /// Negated regex match `!~`.
227    NotTilde,
228    /// Negated case-insensitive regex match `!~*`.
229    NotTildeStar,
230    /// LIKE operator `~~` (PG's operator form of `LIKE`).
231    DoubleTilde,
232    /// ILIKE operator `~~*` (case-insensitive LIKE).
233    DoubleTildeStar,
234    /// NOT LIKE operator `!~~`.
235    NotDoubleTilde,
236    /// NOT ILIKE operator `!~~*`.
237    NotDoubleTildeStar,
238    /// Power operator `^`.
239    Caret,
240    /// Starts-with operator `^@` (PG 11+).
241    CaretAt,
242    /// Integer XOR operator `#`.
243    Hash,
244    /// Range "is adjacent to" operator `-|-`.
245    Adjacent,
246    /// tsquery prefix negation operator `!!`.
247    DoubleBang,
248    /// `IS` keyword — postfix `IS NULL` / `IS NOT NULL` predicates.
249    Is,
250    Between,
251    In,
252    Like,
253    Group,
254    Distinct,
255    Union,
256    All,
257    Join,
258    Inner,
259    Left,
260    Cross,
261    Outer,
262    Right,
263    Full,
264    Default,
265    Savepoint,
266    Release,
267    To,
268    Having,
269    Show,
270    Extract,
271    Offset,
272    Asc,
273    Desc,
274    /// `INTERVAL` — followed by a string literal carrying the span text
275    /// (e.g. `INTERVAL '1 day 2 hours'`).
276    Interval,
277    /// v6.1.1 — `$N` parameter placeholder for the extended query
278    /// protocol. The number N is 1-based per PostgreSQL convention.
279    /// `0` and `$0` are not valid; the lexer rejects them.
280    Placeholder(u16),
281
282    /// v6.1.2 — `DROP` keyword. Used by `DROP PUBLICATION <name>`.
283    /// Reserved for future `DROP TABLE` / `DROP INDEX` / `DROP USER`
284    /// surface that currently goes through SHOW-shaped admin SQL.
285    Drop,
286    /// v6.1.2 — `FOR` keyword (publication scope).
287    For,
288    /// v6.1.2 — `TABLES` plural keyword (`FOR ALL TABLES`,
289    /// `FOR ALL TABLES EXCEPT …`). The existing `TABLE` keyword
290    /// stays a separate token so `CREATE TABLE`'s single-table
291    /// form keeps lexing as today.
292    Tables,
293    /// v6.1.3 (reserved at v6.1.2 to keep the AST shape stable) —
294    /// `EXCEPT` keyword for `FOR ALL TABLES EXCEPT t1, t2`.
295    Except,
296    /// v6.1.2 — `PUBLICATION` keyword.
297    Publication,
298    /// v6.1.4 (reserved at v6.1.2) — `SUBSCRIPTION` keyword.
299    Subscription,
300    /// v6.1.4 — `CONNECTION` keyword (for
301    /// `CREATE SUBSCRIPTION … CONNECTION '<conn_str>' …`).
302    Connection,
303    /// v7.37.6-B(sentori Epic 2 P0)— `PARTITION` keyword. Drives
304    /// both `CREATE TABLE p (…) PARTITION BY RANGE (key)` (declarative
305    /// parent) and `CREATE TABLE c PARTITION OF p FOR VALUES FROM
306    /// (a) TO (b) | DEFAULT` (child). `OF` / `MINVALUE` / `MAXVALUE`
307    /// stay PG-context-sensitive identifiers — the parser matches them
308    /// as case-insensitive `Token::Ident` strings off the back of this
309    /// reserved keyword, mirroring how `INSERT … RETURNING` handles
310    /// `RETURNING` without burning a global keyword slot.
311    Partition,
312
313    Eof,
314}
315
316#[derive(Debug, Clone, PartialEq, Eq)]
317pub enum LexErrorKind {
318    /// v7.39 (round 773, F31 J3) — an E-string's byte escapes decoded
319    /// to an invalid UTF-8 sequence. PG decodes `\NNN` / `\xHH` as
320    /// BYTES and validates the whole literal (`E'\303\251'` is `é`;
321    /// `E'\777'` is byte 0xFF and refuses); the old decoder mapped
322    /// each byte to its Latin-1 codepoint, silently mangling every
323    /// multi-byte sequence.
324    InvalidByteSequence(u8),
325    UnknownChar(char),
326    UnterminatedString,
327    UnterminatedQuotedIdent,
328    UnterminatedBlockComment,
329    BadNumber(String),
330    /// v7.39 (round 184) — a numeric literal followed directly by an
331    /// identifier character (`12__34`, `123_`, `1.5_`, `123abc`). PG
332    /// rejects at scan time; pre-r184 SPG silently lexed the number
333    /// and let the tail become a column alias (`SELECT 12__34` → 12).
334    TrailingJunkAfterNumber(String),
335    /// v7.39 (round 184) — a radix prefix with no digits (`0x`, `0o`,
336    /// `0b`); pre-r184 the `0` lexed alone and the letter aliased.
337    /// Payload: (radix-name, literal-text).
338    InvalidRadixLiteral(&'static str, String),
339    InvalidUnicodeEscape,
340}
341
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct LexError {
344    pub kind: LexErrorKind,
345    pub pos: usize,
346}
347
348impl fmt::Display for LexError {
349    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
350        match &self.kind {
351            LexErrorKind::InvalidByteSequence(b) => {
352                write!(f, "invalid byte sequence for encoding \"UTF8\": 0x{b:02x}")
353            }
354            LexErrorKind::UnknownChar(c) => write!(f, "unknown char {c:?} at byte {}", self.pos),
355            LexErrorKind::UnterminatedString => {
356                write!(f, "unterminated string literal at byte {}", self.pos)
357            }
358            LexErrorKind::UnterminatedQuotedIdent => {
359                write!(f, "unterminated quoted identifier at byte {}", self.pos)
360            }
361            LexErrorKind::UnterminatedBlockComment => {
362                write!(f, "unterminated /* */ comment at byte {}", self.pos)
363            }
364            LexErrorKind::BadNumber(s) => {
365                write!(f, "invalid number literal {s:?} at byte {}", self.pos)
366            }
367            LexErrorKind::TrailingJunkAfterNumber(s) => {
368                write!(f, "trailing junk after numeric literal at or near \"{s}\"")
369            }
370            LexErrorKind::InvalidRadixLiteral(radix, s) => {
371                write!(f, "invalid {radix} integer at or near \"{s}\"")
372            }
373            LexErrorKind::InvalidUnicodeEscape => {
374                write!(f, "invalid Unicode escape at byte {}", self.pos)
375            }
376        }
377    }
378}
379
380/// Tokenize `input` into a `Vec<Token>` ending in `Token::Eof`,
381/// with PG string semantics (backslash is a literal byte inside
382/// `'…'`; `''` is the only escape).
383pub fn tokenize(input: &str) -> Result<Vec<Token>, LexError> {
384    tokenize_with(input, false)
385}
386
387/// v7.22 (round-13 T3) — dialect-aware tokenizer entry. With
388/// `backslash_escapes = true`, plain `'…'` strings honour MySQL /
389/// pre-9.1-PG backslash escapes (`\'` `\\` `\n` …, the same decode
390/// the `E'…'` form uses). mysqldump ALWAYS emits `\'`-escaped data
391/// sections, and pg_dump ALWAYS announces PG semantics via
392/// `SET standard_conforming_strings = on` — the engine flips this
393/// flag off/on from those deterministic session signals.
394pub fn tokenize_with(input: &str, backslash_escapes: bool) -> Result<Vec<Token>, LexError> {
395    tokenize_with_offsets(input, backslash_escapes).map(|(tokens, _)| tokens)
396}
397
398/// v7.39 (read01 round 95) — like [`tokenize_with`] but also returns, for each
399/// token, the byte offset in `input` where it started (the `Eof` token maps to
400/// `input.len()`). The parser uses this to translate a failing token index into
401/// PG's 1-based character error position (the ErrorResponse `P` field that psql
402/// renders as `LINE n: … ^`).
403#[allow(clippy::too_many_lines)] // big match — splitting would obscure the dispatch table
404pub fn tokenize_with_offsets(
405    input: &str,
406    backslash_escapes: bool,
407) -> Result<(Vec<Token>, Vec<usize>), LexError> {
408    let bytes = input.as_bytes();
409    let mut i = 0usize;
410    let mut out = Vec::new();
411    // Parallel to `out`: the start byte of each token. Filled at the tail of
412    // every loop iteration for whatever token(s) that iteration pushed, so no
413    // per-push-site bookkeeping is needed. (The only `continue` inside a
414    // token-producing arm — the lone `@` — was rewritten to fall through.)
415    let mut offsets: Vec<usize> = Vec::new();
416
417    while i < bytes.len() {
418        let start = i;
419        let b = bytes[i];
420        match b {
421            b' ' | b'\t' | b'\n' | b'\r' => {
422                i += 1;
423            }
424            b'-' if peek_eq(bytes, i + 1, b'-') => {
425                i += 2;
426                while i < bytes.len() && bytes[i] != b'\n' {
427                    i += 1;
428                }
429            }
430            b'/' if peek_eq(bytes, i + 1, b'*') => {
431                let start = i;
432                // v7.14.0 — MySQL versioned conditional comment
433                // `/*!NNNNN <body> */`. The body is real SQL that
434                // MySQL/MariaDB executes when the runtime version
435                // matches the 5-digit code; PG strips the whole
436                // thing as a block comment. SPG sides with MySQL
437                // semantics for dump compatibility: skip the
438                // `/*!NNNNN ` prefix and continue lexing the body
439                // as ordinary tokens. The closing `*/` is later
440                // matched + skipped by the symmetric arm below.
441                if peek_eq(bytes, i + 2, b'!') {
442                    let mut j = i + 3;
443                    // skip the optional 5-digit version code +
444                    // following single whitespace
445                    while j < bytes.len() && bytes[j].is_ascii_digit() {
446                        j += 1;
447                    }
448                    if j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
449                        j += 1;
450                    }
451                    i = j;
452                    continue;
453                }
454                i += 2;
455                let mut closed = false;
456                while i + 1 < bytes.len() {
457                    if bytes[i] == b'*' && bytes[i + 1] == b'/' {
458                        i += 2;
459                        closed = true;
460                        break;
461                    }
462                    i += 1;
463                }
464                if !closed {
465                    return Err(LexError {
466                        kind: LexErrorKind::UnterminatedBlockComment,
467                        pos: start,
468                    });
469                }
470            }
471            // v7.14.0 — bare `*/` (closing of the v7.14 MySQL
472            // versioned-comment opener that didn't consume the
473            // closer). We treat it as an inline comment terminator
474            // and skip 2 bytes.
475            b'*' if peek_eq(bytes, i + 1, b'/') => {
476                i += 2;
477            }
478            b'\'' => {
479                let (tok, consumed) = if backslash_escapes {
480                    // MySQL-dialect session: plain strings decode
481                    // backslash escapes — same machinery as E'…'.
482                    lex_escape_string(input, i, true)?
483                } else {
484                    lex_quoted(input, i, b'\'', false)?
485                };
486                out.push(tok);
487                i += consumed;
488            }
489            // v7.18 — PG escape-string literal `E'...'` / `e'...'`.
490            // Closes the mailrs D-pre #3 reverse-acceptance gap:
491            // `INSERT INTO oq VALUES (E'\\xdeadbeef'::bytea)` needs
492            // the `E` prefix so `\\` decodes to a single `\`. The
493            // produced Token::String carries the decoded body so
494            // downstream parser / cast paths treat it identically
495            // to a regular string literal.
496            b'E' | b'e' if peek_eq(bytes, i + 1, b'\'') => {
497                let (tok, consumed) = lex_escape_string(input, i + 1, false)?;
498                out.push(tok);
499                i += 1 + consumed;
500            }
501            // v7.38 (read01, T18) — PG `U&'...'` Unicode string literal.
502            b'U' | b'u' if peek_eq(bytes, i + 1, b'&') && peek_eq(bytes, i + 2, b'\'') => {
503                let (tok, consumed) = lex_unicode_string(input, i + 2)?;
504                out.push(tok);
505                i += 2 + consumed;
506            }
507            b'"' => {
508                let (tok, consumed) = lex_quoted(input, i, b'"', true)?;
509                out.push(tok);
510                i += consumed;
511            }
512            // MySQL-flavoured backtick-quoted identifier. Same semantics
513            // as the standard `"..."` form, including embedded "``" as
514            // a literal backtick.
515            b'`' => {
516                let (tok, consumed) = lex_quoted(input, i, b'`', true)?;
517                out.push(tok);
518                i += consumed;
519            }
520            b if b.is_ascii_alphabetic() || b == b'_' => {
521                let start = i;
522                i += 1;
523                while i < bytes.len() {
524                    let c = bytes[i];
525                    if c.is_ascii_alphanumeric() || c == b'_' {
526                        i += 1;
527                    } else {
528                        break;
529                    }
530                }
531                let raw = &input[start..i];
532                // v3.0.5: try the keyword table case-insensitively
533                // without allocating; only the ident fall-through
534                // pays for a lowercase String.
535                out.push(keyword_or_ident_raw(raw));
536            }
537            b if b.is_ascii_digit() => {
538                let (tok, consumed) = lex_number(&input[i..], backslash_escapes)
539                    .map_err(|kind| LexError { kind, pos: i })?;
540                out.push(tok);
541                i += consumed;
542            }
543            b'.' if peek_pred(bytes, i + 1, u8::is_ascii_digit) => {
544                let (tok, consumed) = lex_number(&input[i..], backslash_escapes)
545                    .map_err(|kind| LexError { kind, pos: i })?;
546                out.push(tok);
547                i += consumed;
548            }
549            b'+' => single(&mut out, Token::Plus, &mut i),
550            // v7.37.6-A — PG JSONB `?` / `?|` / `?&`. Longest-match
551            // order matters: try `?|` and `?&` before bare `?`.
552            // SPG doesn't use `?` as a placeholder (uses `$N`
553            // instead), so the bare `?` slot is free for JSONB.
554            // v7.39 (read01 geo_ops.c) — `?||` (parallel) and `?-|`
555            // (perpendicular) must win over `?|` / bare `?`.
556            b'?' if peek_eq(bytes, i + 1, b'|') && peek_eq(bytes, i + 2, b'|') => {
557                out.push(Token::GeomParallel);
558                i += 3;
559            }
560            b'?' if peek_eq(bytes, i + 1, b'-') && peek_eq(bytes, i + 2, b'|') => {
561                out.push(Token::GeomPerp);
562                i += 3;
563            }
564            b'?' if peek_eq(bytes, i + 1, b'|') => {
565                out.push(Token::JsonKeysAny);
566                i += 2;
567            }
568            b'?' if peek_eq(bytes, i + 1, b'&') => {
569                out.push(Token::JsonKeysAll);
570                i += 2;
571            }
572            // v7.39 (read01 geo_ops.c) — `?-` "is horizontal" (after `?-|`
573            // above claims the perpendicular spelling).
574            b'?' if peek_eq(bytes, i + 1, b'-') => {
575                out.push(Token::GeomHoriz);
576                i += 2;
577            }
578            b'?' if peek_eq(bytes, i + 1, b'#') => {
579                // v7.39 (round 508) — `?#` "do these intersect".
580                out.push(Token::Intersects);
581                i += 2;
582            }
583            b'?' => single(&mut out, Token::JsonKeyExists, &mut i),
584            b'-' => {
585                // Range `-|-` "is adjacent to" — longest match ahead of `->`.
586                if peek_eq(bytes, i + 1, b'|') && peek_eq(bytes, i + 2, b'-') {
587                    out.push(Token::Adjacent);
588                    i += 3;
589                }
590                // v4.14: `->>` and `->` for JSON path access. `->>`
591                // must be tried before `->` (longest match).
592                else if peek_eq(bytes, i + 1, b'>') && peek_eq(bytes, i + 2, b'>') {
593                    out.push(Token::JsonGetText);
594                    i += 3;
595                } else if peek_eq(bytes, i + 1, b'>') {
596                    out.push(Token::JsonGet);
597                    i += 2;
598                } else {
599                    single(&mut out, Token::Minus, &mut i);
600                }
601            }
602            // v6.4.5: `#>>` and `#>` JSON path walk; bare `#` is
603            // the integer XOR operator.
604            b'#' => {
605                if peek_eq(bytes, i + 1, b'>') && peek_eq(bytes, i + 2, b'>') {
606                    out.push(Token::JsonGetPathText);
607                    i += 3;
608                // v7.39 (read01 geo_ops.c) — `##` closest-point operator.
609                } else if peek_eq(bytes, i + 1, b'#') {
610                    out.push(Token::ClosestPoint);
611                    i += 2;
612                } else if peek_eq(bytes, i + 1, b'>') {
613                    out.push(Token::JsonGetPath);
614                    i += 2;
615                } else if peek_eq(bytes, i + 1, b'-') {
616                    out.push(Token::JsonDeletePath);
617                    i += 2;
618                } else {
619                    single(&mut out, Token::Hash, &mut i);
620                }
621            }
622            // v6.4.5: `@>` JSON containment.
623            // v7.12.2: `@@` tsvector / tsquery match.
624            // v7.14.0: `@@NAME` MySQL session variable ref +
625            //          `@NAME` user variable ref. mysqldump preamble
626            //          uses both heavily (`SET @OLD_FOREIGN_KEY_CHECKS
627            //          = @@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0`).
628            //          We lex both as a single SessionVar token so
629            //          the parser can accept and ignore them.
630            b'@' => {
631                if peek_eq(bytes, i + 1, b'>') {
632                    out.push(Token::JsonContains);
633                    i += 2;
634                } else if peek_eq(bytes, i + 1, b'?') {
635                    // v7.37 — `@?` jsonpath existence operator
636                    // (`jsonb @? jsonpath` = jsonb_path_exists).
637                    out.push(Token::JsonPathExists);
638                    i += 2;
639                } else if peek_eq(bytes, i + 1, b'@') && peek_eq(bytes, i + 2, b'@') {
640                    // v7.39 (round 508) — `@@@`, before `@@`: longest match.
641                    out.push(Token::TsMatchOld);
642                    i += 3;
643                } else if peek_eq(bytes, i + 1, b'-') && peek_eq(bytes, i + 2, b'@') {
644                    // v7.39 (round 508) — `@-@` "length of".
645                    out.push(Token::AtMinusAt);
646                    i += 3;
647                } else if peek_eq(bytes, i + 1, b'@')
648                    && !is_session_var_ident_start(bytes.get(i + 2).copied())
649                {
650                    // `@@` not followed by an ident-start byte is
651                    // the tsquery `@@` operator.
652                    out.push(Token::TsMatch);
653                    i += 2;
654                } else {
655                    // `@VAR` / `@@VAR` — MySQL user / session
656                    // variable reference. Consume the ident-shaped
657                    // tail and emit as Token::SessionVar so the
658                    // SET parser can accept-and-ignore.
659                    let prefix_end = if peek_eq(bytes, i + 1, b'@') {
660                        i + 2
661                    } else {
662                        i + 1
663                    };
664                    let mut end = prefix_end;
665                    while end < bytes.len() && is_session_var_ident_continue(bytes[end]) {
666                        end += 1;
667                    }
668                    if end == prefix_end {
669                        // v7.17.0 Phase 2.6 — `@` not followed by an
670                        // ident-shaped tail. mysqldump's DEFINER
671                        // form `'user'@'host'` lands here (next
672                        // byte is `'`). Emit as Token::At so the
673                        // parser can stitch the surrounding String
674                        // tokens. Single `@@` already short-circuits
675                        // to Token::TsMatch above, so this only
676                        // fires for a true lone `@`.
677                        // v7.39 (read01 round 95) — falls through to the
678                        // per-token offset fill at the loop tail (was a
679                        // `continue`, which would have skipped it).
680                        out.push(Token::At);
681                        i = prefix_end;
682                    } else {
683                        out.push(Token::SessionVar(input[i..end].to_string()));
684                        i = end;
685                    }
686                }
687            }
688            b'*' => single(&mut out, Token::Star, &mut i),
689            b'/' => single(&mut out, Token::Slash, &mut i),
690            b'%' => single(&mut out, Token::Percent, &mut i),
691            b'(' => single(&mut out, Token::LParen, &mut i),
692            b')' => single(&mut out, Token::RParen, &mut i),
693            b'[' => single(&mut out, Token::LBracket, &mut i),
694            b']' => single(&mut out, Token::RBracket, &mut i),
695            b',' => single(&mut out, Token::Comma, &mut i),
696            b';' => single(&mut out, Token::Semicolon, &mut i),
697            b'.' => {
698                // v7.37.20 (20.4) — `..` range operator for PL/pgSQL
699                // FOR LOOP bounds emits a single Token::DotDot so the
700                // range parser sees one atomic token instead of two
701                // consecutive Dots (which parse_expr couldn't reliably
702                // distinguish from struct-field access after an atom).
703                if peek_eq(bytes, i + 1, b'.') {
704                    out.push(Token::DotDot);
705                    i += 2;
706                } else {
707                    single(&mut out, Token::Dot, &mut i);
708                }
709            }
710            b'=' => {
711                // v7.38 (read01, T14) — `=>` names a function argument.
712                if peek_eq(bytes, i + 1, b'>') {
713                    out.push(Token::FatArrow);
714                    i += 2;
715                } else {
716                    single(&mut out, Token::Eq, &mut i);
717                }
718            }
719            b'<' => {
720                if peek_eq(bytes, i + 1, b'=') && peek_eq(bytes, i + 2, b'>') {
721                    out.push(Token::CosineDistance);
722                    i += 3;
723                } else if peek_eq(bytes, i + 1, b'#') && peek_eq(bytes, i + 2, b'>') {
724                    out.push(Token::InnerProduct);
725                    i += 3;
726                } else if peek_eq(bytes, i + 1, b'-') && peek_eq(bytes, i + 2, b'>') {
727                    out.push(Token::L2Distance);
728                    i += 3;
729                } else if peek_eq(bytes, i + 1, b'<') && peek_eq(bytes, i + 2, b'=') {
730                    // v7.17.0 Phase 3.P0-47 — PG INET `<<=` contained-or-equal.
731                    out.push(Token::InetContainedByEq);
732                    i += 3;
733                } else if peek_eq(bytes, i + 1, b'<') {
734                    // v7.17.0 Phase 3.P0-47 — PG INET `<<` strict contained.
735                    out.push(Token::InetContainedBy);
736                    i += 2;
737                } else if peek_eq(bytes, i + 1, b'^') {
738                    // v7.39 (round 508) — `<^` "is strictly below".
739                    out.push(Token::IsBelow);
740                    i += 2;
741                } else if peek_eq(bytes, i + 1, b'@') {
742                    // v7.37.6-A — PG JSONB `<@` contained-by.
743                    out.push(Token::JsonContainedBy);
744                    i += 2;
745                } else if peek_eq(bytes, i + 1, b'=') {
746                    out.push(Token::LtEq);
747                    i += 2;
748                } else if peek_eq(bytes, i + 1, b'>') {
749                    out.push(Token::NotEq);
750                    i += 2;
751                } else {
752                    out.push(Token::Lt);
753                    i += 1;
754                }
755            }
756            b':' if peek_eq(bytes, i + 1, b':') => {
757                out.push(Token::DoubleColon);
758                i += 2;
759            }
760            b':' if peek_eq(bytes, i + 1, b'=') => {
761                // v7.12.4 — PL/pgSQL assignment operator `:=`.
762                out.push(Token::ColonEq);
763                i += 2;
764            }
765            b':' => {
766                // v7.12.4 — bare `:`. Used inside `tsvector` external-form
767                // literals which the cast parser consumes in-token, and as a
768                // separator the PL/pgSQL assignment lexer can recover from.
769                out.push(Token::Colon);
770                i += 1;
771            }
772            b'|' if peek_eq(bytes, i + 1, b'|') => {
773                out.push(Token::Concat);
774                i += 2;
775            }
776            // Bitwise operators (PG integer ops; mailrs IMAP flag
777            // masks: `flags | $1`, `flags & ~$1`).
778            b'|' => {
779                single(&mut out, Token::Pipe, &mut i);
780            }
781            // `~~*` (ILIKE) / `~~` (LIKE) — check the double-tilde forms before
782            // `~*` and single `~` so PG's operator spellings of LIKE/ILIKE parse.
783            b'~' if peek_eq(bytes, i + 1, b'~') && peek_eq(bytes, i + 2, b'*') => {
784                out.push(Token::DoubleTildeStar);
785                i += 3;
786            }
787            b'~' if peek_eq(bytes, i + 1, b'~') => {
788                out.push(Token::DoubleTilde);
789                i += 2;
790            }
791            b'~' if peek_eq(bytes, i + 1, b'*') => {
792                out.push(Token::TildeStar);
793                i += 2;
794            }
795            // v7.39 (read01 geo_ops.c) — `~=` geometric "same as".
796            b'~' if peek_eq(bytes, i + 1, b'=') => {
797                out.push(Token::GeomSameAs);
798                i += 2;
799            }
800            // v7.39 (round 508) — the `text_pattern_ops` comparisons, longest
801            // match first so `~<=~` beats `~<~`.
802            b'~' if peek_eq(bytes, i + 1, b'<')
803                && peek_eq(bytes, i + 2, b'=')
804                && peek_eq(bytes, i + 3, b'~') =>
805            {
806                out.push(Token::PatternLtEq);
807                i += 4;
808            }
809            b'~' if peek_eq(bytes, i + 1, b'>')
810                && peek_eq(bytes, i + 2, b'=')
811                && peek_eq(bytes, i + 3, b'~') =>
812            {
813                out.push(Token::PatternGtEq);
814                i += 4;
815            }
816            b'~' if peek_eq(bytes, i + 1, b'<') && peek_eq(bytes, i + 2, b'~') => {
817                out.push(Token::PatternLt);
818                i += 3;
819            }
820            b'~' if peek_eq(bytes, i + 1, b'>') && peek_eq(bytes, i + 2, b'~') => {
821                out.push(Token::PatternGt);
822                i += 3;
823            }
824            b'~' => {
825                single(&mut out, Token::Tilde, &mut i);
826            }
827            b'^' if peek_eq(bytes, i + 1, b'@') => {
828                out.push(Token::CaretAt);
829                i += 2;
830            }
831            b'^' => {
832                single(&mut out, Token::Caret, &mut i);
833            }
834            b'>' => {
835                if peek_eq(bytes, i + 1, b'^') {
836                    // v7.39 (round 508) — `>^` "is strictly above".
837                    out.push(Token::IsAbove);
838                    i += 2;
839                } else if peek_eq(bytes, i + 1, b'>') && peek_eq(bytes, i + 2, b'=') {
840                    // v7.17.0 Phase 3.P0-47 — PG INET `>>=` contains-or-equal.
841                    out.push(Token::InetContainsEq);
842                    i += 3;
843                } else if peek_eq(bytes, i + 1, b'>') {
844                    // v7.17.0 Phase 3.P0-47 — PG INET `>>` strict contains.
845                    out.push(Token::InetContains);
846                    i += 2;
847                } else if peek_eq(bytes, i + 1, b'=') {
848                    out.push(Token::GtEq);
849                    i += 2;
850                } else {
851                    out.push(Token::Gt);
852                    i += 1;
853                }
854            }
855            b'&' if peek_eq(bytes, i + 1, b'&') => {
856                // v7.17.0 Phase 3.P0-47 — PG INET network overlap `&&`.
857                out.push(Token::InetOverlap);
858                i += 2;
859            }
860            // v7.39 (read01 rangetypes.c) — range `&<` (does not extend to
861            // the right of) / `&>` (does not extend to the left of).
862            b'&' if peek_eq(bytes, i + 1, b'<') => {
863                out.push(Token::OverLeft);
864                i += 2;
865            }
866            b'&' if peek_eq(bytes, i + 1, b'>') => {
867                out.push(Token::OverRight);
868                i += 2;
869            }
870            b'&' => {
871                single(&mut out, Token::Amp, &mut i);
872            }
873            b'!' if peek_eq(bytes, i + 1, b'!') => {
874                // tsquery `!!` prefix negation. Two bangs, ahead of `!=`/`!~`.
875                out.push(Token::DoubleBang);
876                i += 2;
877            }
878            b'!' if peek_eq(bytes, i + 1, b'=') => {
879                out.push(Token::NotEq);
880                i += 2;
881            }
882            // `!~~*` (NOT ILIKE) / `!~~` (NOT LIKE) — before `!~*` / `!~`.
883            b'!' if peek_eq(bytes, i + 1, b'~')
884                && peek_eq(bytes, i + 2, b'~')
885                && peek_eq(bytes, i + 3, b'*') =>
886            {
887                out.push(Token::NotDoubleTildeStar);
888                i += 4;
889            }
890            b'!' if peek_eq(bytes, i + 1, b'~') && peek_eq(bytes, i + 2, b'~') => {
891                out.push(Token::NotDoubleTilde);
892                i += 3;
893            }
894            b'!' if peek_eq(bytes, i + 1, b'~') && peek_eq(bytes, i + 2, b'*') => {
895                out.push(Token::NotTildeStar);
896                i += 3;
897            }
898            b'!' if peek_eq(bytes, i + 1, b'~') => {
899                out.push(Token::NotTilde);
900                i += 2;
901            }
902            // v7.39 (round 353, M10) — MySQL's `!` negation, after every
903            // two- and three-byte `!…` operator above so none is stolen.
904            // It reuses the NOT token; the parser gives it MySQL's tight
905            // precedence (`!1 + 1` is 1 — `(!1)+1` — while `NOT 1 + 1`
906            // is 0, measured on MariaDB 11).
907            b'!' => {
908                out.push(Token::Bang);
909                i += 1;
910            }
911            // v7.9.27 — PG dollar-quoted string `$$ … $$` (or
912            // `$tag$ … $tag$`). Used in `DO $$ … $$ LANGUAGE
913            // plpgsql;` blocks that pg_dump emits for idempotent
914            // migrations. SPG has no PL/pgSQL, so the lexer
915            // consumes the entire string as a single Token::String
916            // and the parser treats the surrounding `DO …;` as a
917            // no-op. mailrs follow-up H1.
918            b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'$' => {
919                // Empty tag form: `$$ … $$`.
920                let end = find_dollar_tag_end(bytes, i + 2, b"$$");
921                let body = match end {
922                    Some(e) => &input[i + 2..e],
923                    None => {
924                        return Err(LexError {
925                            kind: LexErrorKind::UnterminatedString,
926                            pos: i,
927                        });
928                    }
929                };
930                out.push(Token::String(body.to_string()));
931                i = end.unwrap() + 2;
932            }
933            b'$' if i + 1 < bytes.len()
934                && (bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'_') =>
935            {
936                // Tagged form: `$foo$ … $foo$`. Scan the tag
937                // ident, find the closing copy.
938                let mut j = i + 1;
939                while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
940                    j += 1;
941                }
942                if j >= bytes.len() || bytes[j] != b'$' {
943                    // Not a dollar-quoted string — fall through
944                    // to the generic-unknown-char path.
945                    let ch = input[i..].chars().next().unwrap_or('?');
946                    return Err(LexError {
947                        kind: LexErrorKind::UnknownChar(ch),
948                        pos: i,
949                    });
950                }
951                let close: alloc::vec::Vec<u8> = bytes[i..=j].to_vec();
952                let end = find_dollar_tag_end(bytes, j + 1, &close);
953                let body = match end {
954                    Some(e) => &input[j + 1..e],
955                    None => {
956                        return Err(LexError {
957                            kind: LexErrorKind::UnterminatedString,
958                            pos: i,
959                        });
960                    }
961                };
962                out.push(Token::String(body.to_string()));
963                i = end.unwrap() + close.len();
964            }
965            // v6.1.1: `$N` parameter placeholder for the extended
966            // query protocol. PG numbers them 1..=N; we reject $0
967            // and a bare `$` not followed by a digit.
968            b'$' if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() => {
969                let mut j = i + 1;
970                let mut n: u32 = 0;
971                while j < bytes.len() && bytes[j].is_ascii_digit() {
972                    n = n
973                        .saturating_mul(10)
974                        .saturating_add(u32::from(bytes[j] - b'0'));
975                    j += 1;
976                }
977                if n == 0 || n > u32::from(u16::MAX) {
978                    return Err(LexError {
979                        kind: LexErrorKind::BadNumber(input[i..j].to_string()),
980                        pos: i,
981                    });
982                }
983                #[allow(clippy::cast_possible_truncation)]
984                out.push(Token::Placeholder(n as u16));
985                i = j;
986            }
987            _ => {
988                let ch = input[i..].chars().next().unwrap_or('?');
989                return Err(LexError {
990                    kind: LexErrorKind::UnknownChar(ch),
991                    pos: i,
992                });
993            }
994        }
995        // Assign the iteration's start byte to any token(s) pushed above.
996        // Whitespace/comment arms push nothing, so this adds nothing for them.
997        while offsets.len() < out.len() {
998            offsets.push(start);
999        }
1000    }
1001    out.push(Token::Eof);
1002    offsets.push(bytes.len());
1003    Ok((out, offsets))
1004}
1005
1006fn peek_eq(bytes: &[u8], i: usize, target: u8) -> bool {
1007    bytes.get(i) == Some(&target)
1008}
1009
1010/// v7.14.0 — recognise the first byte of a MySQL session/user
1011/// variable name (after `@` or `@@`). PG-strict idents are ASCII
1012/// letter or underscore; MySQL also allows leading digits inside
1013/// quoted names but unquoted vars match the same shape.
1014fn is_session_var_ident_start(b: Option<u8>) -> bool {
1015    matches!(b, Some(c) if c.is_ascii_alphabetic() || c == b'_')
1016}
1017
1018/// Continuation byte for a `@VAR`/`@@VAR` ident (after the first
1019/// alphabet/underscore byte). Letters, digits, underscore, dot
1020/// (MySQL allows session-scope qualifiers like
1021/// `@@global.sql_mode`) and `$` (some MySQL versions accept it).
1022fn is_session_var_ident_continue(b: u8) -> bool {
1023    b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'$'
1024}
1025
1026/// v7.9.27 — find the start index of the next occurrence of `tag`
1027/// (e.g. `b"$$"` or `b"$foo$"`) in `bytes` starting at `from`.
1028fn find_dollar_tag_end(bytes: &[u8], from: usize, tag: &[u8]) -> Option<usize> {
1029    if tag.is_empty() || from > bytes.len() {
1030        return None;
1031    }
1032    let mut i = from;
1033    while i + tag.len() <= bytes.len() {
1034        if &bytes[i..i + tag.len()] == tag {
1035            return Some(i);
1036        }
1037        i += 1;
1038    }
1039    None
1040}
1041
1042fn peek_pred<F: Fn(&u8) -> bool>(bytes: &[u8], i: usize, pred: F) -> bool {
1043    bytes.get(i).is_some_and(pred)
1044}
1045
1046fn single(out: &mut Vec<Token>, tok: Token, i: &mut usize) {
1047    out.push(tok);
1048    *i += 1;
1049}
1050
1051/// Length-first ASCII-CI keyword lookup. Avoids allocating a
1052/// lowercase `String` when the input matches a keyword; only the ident
1053/// fall-through path pays for the lowercase copy.
1054///
1055/// Grouped by length so the outer `match` becomes a small jump table.
1056/// Within a length bucket every keyword has either a unique first
1057/// byte (cheap dispatch) or a small set of disambiguating
1058/// trailing-byte comparisons. All comparisons are ASCII-CI (XOR
1059/// 0x20 on each byte before the compare).
1060fn keyword_or_ident_raw(raw: &str) -> Token {
1061    let b = raw.as_bytes();
1062    let tok = match b.len() {
1063        2 => kw_len2(b),
1064        3 => kw_len3(b),
1065        4 => kw_len4(b),
1066        5 => kw_len5(b),
1067        6 => kw_len6(b),
1068        7 => kw_len7(b),
1069        8 => kw_len8(b),
1070        9 => kw_len9(b),
1071        10 => kw_len10(b),
1072        11 => kw_len11(b),
1073        12 => kw_len12(b),
1074        _ => None,
1075    };
1076    match tok {
1077        Some(t) => t,
1078        // Ident fall-through: this is the only path that allocates.
1079        None => Token::Ident(raw.to_ascii_lowercase()),
1080    }
1081}
1082
1083/// ASCII-CI equality on a byte slice against a lowercase literal.
1084/// Letters that differ only in case satisfy `(a ^ b) == 0x20`; other
1085/// mismatches set bits outside the 0x20 mask. We compare each byte
1086/// against its lowercase form via `to_ascii_lowercase` for clarity;
1087/// the compiler folds the loop into a tight cmov chain.
1088#[inline]
1089fn eq_ci(input: &[u8], lower: &[u8]) -> bool {
1090    if input.len() != lower.len() {
1091        return false;
1092    }
1093    for i in 0..lower.len() {
1094        if input[i].to_ascii_lowercase() != lower[i] {
1095            return false;
1096        }
1097    }
1098    true
1099}
1100
1101#[inline]
1102fn kw_len2(b: &[u8]) -> Option<Token> {
1103    // v7.39 (round 621) — 6 keywords: as, in, is, on, or, to.
1104    //
1105    // `by` used to be here, and lexing it made it unusable as a name: a
1106    // `by` column could not be created, read, written, indexed or aliased.
1107    // `pg_get_keywords()` classes it `U` (unreserved) — alone among these
1108    // seven — so it is an ordinary identifier, and the clauses that own the
1109    // word (GROUP BY, ORDER BY, PARTITION BY) recognise it as one.
1110    if eq_ci(b, b"as") {
1111        return Some(Token::As);
1112    }
1113    if eq_ci(b, b"in") {
1114        return Some(Token::In);
1115    }
1116    if eq_ci(b, b"is") {
1117        return Some(Token::Is);
1118    }
1119    if eq_ci(b, b"on") {
1120        return Some(Token::On);
1121    }
1122    if eq_ci(b, b"or") {
1123        return Some(Token::Or);
1124    }
1125    if eq_ci(b, b"to") {
1126        return Some(Token::To);
1127    }
1128    None
1129}
1130
1131#[inline]
1132fn kw_len3(b: &[u8]) -> Option<Token> {
1133    // 5 keywords: all, and, asc, not, for
1134    if eq_ci(b, b"for") {
1135        return Some(Token::For);
1136    }
1137    if eq_ci(b, b"all") {
1138        return Some(Token::All);
1139    }
1140    if eq_ci(b, b"and") {
1141        return Some(Token::And);
1142    }
1143    if eq_ci(b, b"asc") {
1144        return Some(Token::Asc);
1145    }
1146    if eq_ci(b, b"not") {
1147        return Some(Token::Not);
1148    }
1149    None
1150}
1151
1152#[inline]
1153fn kw_len4(b: &[u8]) -> Option<Token> {
1154    // 10 keywords: from, null, true, into, like, join, left, show, desc, drop
1155    if eq_ci(b, b"from") {
1156        return Some(Token::From);
1157    }
1158    if eq_ci(b, b"drop") {
1159        return Some(Token::Drop);
1160    }
1161    if eq_ci(b, b"null") {
1162        return Some(Token::Null);
1163    }
1164    if eq_ci(b, b"full") {
1165        return Some(Token::Full);
1166    }
1167    if eq_ci(b, b"true") {
1168        return Some(Token::True);
1169    }
1170    if eq_ci(b, b"into") {
1171        return Some(Token::Into);
1172    }
1173    if eq_ci(b, b"like") {
1174        return Some(Token::Like);
1175    }
1176    if eq_ci(b, b"join") {
1177        return Some(Token::Join);
1178    }
1179    if eq_ci(b, b"left") {
1180        return Some(Token::Left);
1181    }
1182    if eq_ci(b, b"show") {
1183        return Some(Token::Show);
1184    }
1185    if eq_ci(b, b"desc") {
1186        return Some(Token::Desc);
1187    }
1188    None
1189}
1190
1191#[inline]
1192fn kw_len5(b: &[u8]) -> Option<Token> {
1193    // 12 keywords: false, where, table, index, begin, order, limit,
1194    // group, union, inner, cross, outer
1195    if eq_ci(b, b"false") {
1196        return Some(Token::False);
1197    }
1198    if eq_ci(b, b"where") {
1199        return Some(Token::Where);
1200    }
1201    if eq_ci(b, b"table") {
1202        return Some(Token::Table);
1203    }
1204    if eq_ci(b, b"index") {
1205        return Some(Token::Index);
1206    }
1207    if eq_ci(b, b"begin") {
1208        return Some(Token::Begin);
1209    }
1210    if eq_ci(b, b"order") {
1211        return Some(Token::Order);
1212    }
1213    if eq_ci(b, b"limit") {
1214        return Some(Token::Limit);
1215    }
1216    if eq_ci(b, b"group") {
1217        return Some(Token::Group);
1218    }
1219    if eq_ci(b, b"union") {
1220        return Some(Token::Union);
1221    }
1222    if eq_ci(b, b"inner") {
1223        return Some(Token::Inner);
1224    }
1225    if eq_ci(b, b"cross") {
1226        return Some(Token::Cross);
1227    }
1228    if eq_ci(b, b"outer") {
1229        return Some(Token::Outer);
1230    }
1231    if eq_ci(b, b"right") {
1232        return Some(Token::Right);
1233    }
1234    None
1235}
1236
1237#[inline]
1238fn kw_len6(b: &[u8]) -> Option<Token> {
1239    // 9 keywords: select, create, insert, values, commit, having, offset, tables, except
1240    if eq_ci(b, b"select") {
1241        return Some(Token::Select);
1242    }
1243    if eq_ci(b, b"tables") {
1244        return Some(Token::Tables);
1245    }
1246    if eq_ci(b, b"except") {
1247        return Some(Token::Except);
1248    }
1249    if eq_ci(b, b"create") {
1250        return Some(Token::Create);
1251    }
1252    if eq_ci(b, b"insert") {
1253        return Some(Token::Insert);
1254    }
1255    if eq_ci(b, b"values") {
1256        return Some(Token::Values);
1257    }
1258    if eq_ci(b, b"commit") {
1259        return Some(Token::Commit);
1260    }
1261    if eq_ci(b, b"having") {
1262        return Some(Token::Having);
1263    }
1264    if eq_ci(b, b"offset") {
1265        return Some(Token::Offset);
1266    }
1267    None
1268}
1269
1270#[inline]
1271fn kw_len7(b: &[u8]) -> Option<Token> {
1272    // 4 keywords: between, default, release, extract
1273    if eq_ci(b, b"between") {
1274        return Some(Token::Between);
1275    }
1276    if eq_ci(b, b"default") {
1277        return Some(Token::Default);
1278    }
1279    if eq_ci(b, b"release") {
1280        return Some(Token::Release);
1281    }
1282    if eq_ci(b, b"extract") {
1283        return Some(Token::Extract);
1284    }
1285    None
1286}
1287
1288#[inline]
1289fn kw_len8(b: &[u8]) -> Option<Token> {
1290    // 3 keywords: rollback, distinct, interval
1291    if eq_ci(b, b"rollback") {
1292        return Some(Token::Rollback);
1293    }
1294    if eq_ci(b, b"distinct") {
1295        return Some(Token::Distinct);
1296    }
1297    if eq_ci(b, b"interval") {
1298        return Some(Token::Interval);
1299    }
1300    None
1301}
1302
1303#[inline]
1304fn kw_len9(b: &[u8]) -> Option<Token> {
1305    // 2 keywords: savepoint, partition
1306    if eq_ci(b, b"savepoint") {
1307        return Some(Token::Savepoint);
1308    }
1309    if eq_ci(b, b"partition") {
1310        return Some(Token::Partition);
1311    }
1312    None
1313}
1314
1315#[inline]
1316fn kw_len10(b: &[u8]) -> Option<Token> {
1317    // 1 keyword: connection
1318    if eq_ci(b, b"connection") {
1319        return Some(Token::Connection);
1320    }
1321    None
1322}
1323
1324#[inline]
1325fn kw_len11(b: &[u8]) -> Option<Token> {
1326    // 1 keyword: publication
1327    if eq_ci(b, b"publication") {
1328        return Some(Token::Publication);
1329    }
1330    None
1331}
1332
1333#[inline]
1334fn kw_len12(b: &[u8]) -> Option<Token> {
1335    // 1 keyword: subscription
1336    if eq_ci(b, b"subscription") {
1337        return Some(Token::Subscription);
1338    }
1339    None
1340}
1341
1342/// Lex a `'...'` string literal or `"..."` quoted identifier. The opening
1343/// quote sits at `input[start]`; `quote` is its byte value. `is_ident` selects
1344/// the resulting token shape.
1345///
1346/// PG-style doubling escapes the quote: `''` inside `'...'` is a literal `'`,
1347/// same for `""` inside `"..."`.
1348fn lex_quoted(
1349    input: &str,
1350    start: usize,
1351    quote: u8,
1352    is_ident: bool,
1353) -> Result<(Token, usize), LexError> {
1354    let bytes = input.as_bytes();
1355    let mut i = start + 1;
1356    let mut s = String::new();
1357    loop {
1358        if i >= bytes.len() {
1359            return Err(LexError {
1360                kind: if is_ident {
1361                    LexErrorKind::UnterminatedQuotedIdent
1362                } else {
1363                    LexErrorKind::UnterminatedString
1364                },
1365                pos: start,
1366            });
1367        }
1368        if bytes[i] == quote {
1369            if peek_eq(bytes, i + 1, quote) {
1370                s.push(quote as char);
1371                i += 2;
1372            } else {
1373                i += 1;
1374                break;
1375            }
1376        } else {
1377            let ch = input[i..].chars().next().expect("non-empty UTF-8 boundary");
1378            s.push(ch);
1379            i += ch.len_utf8();
1380        }
1381    }
1382    let tok = if is_ident {
1383        Token::QuotedIdent(s)
1384    } else {
1385        Token::String(s)
1386    };
1387    Ok((tok, i - start))
1388}
1389
1390/// v7.18 — Lex a PG escape-string literal `E'...'`. `start` points
1391/// at the opening single quote (the `E` was matched by the caller
1392/// and is NOT part of `start`'s offset semantics — the consumed
1393/// count returned excludes the `E`, which the caller adds).
1394///
1395/// Recognised escape sequences:
1396///   \\ \' \" — literal backslash / quote
1397///   \n \r \t \b \f — standard whitespace controls
1398///   \0 — NUL
1399///   \xHH — single hex byte (1–2 hex digits)
1400///   \NNN — octal byte (1–3 octal digits)
1401/// Any other `\X` decodes to the literal byte `X` (PG warns; SPG
1402/// follows the lenient behaviour pg_dump output relies on).
1403///
1404/// Doubled `''` is still a literal `'` (same as the non-E form).
1405/// v7.39 (round 332, V35) — `mysql` selects MySQL's escape table instead
1406/// of PG's `E'…'` one. Measured on MariaDB 11 vs PG 18.4, the two agree on
1407/// everything except three points:
1408///
1409/// | escape | PG `E'…'` | MySQL |
1410/// |---|---|---|
1411/// | `\Z` | `Z` | **0x1A** (ctrl-Z) |
1412/// | `\%` / `\_` | `%` / `_` | **both characters kept** — the backslash is
1413///   what makes LIKE treat the wildcard literally |
1414/// | `\xHH` / `\NNN` | decoded | **not special**: the backslash is dropped
1415///   and the rest is literal text |
1416///
1417/// Sharing one table meant a MySQL client's `'\Z'` arrived as the letter
1418/// `Z`, and `'a\%b'` lost the escape LIKE needed — silently wrong bytes,
1419/// not an error.
1420fn lex_escape_string(input: &str, start: usize, mysql: bool) -> Result<(Token, usize), LexError> {
1421    let bytes = input.as_bytes();
1422    debug_assert_eq!(bytes[start], b'\'');
1423    let mut i = start + 1;
1424    // v7.39 (round 773, F31 J3) — PG decodes byte escapes into a BYTE
1425    // buffer and validates the whole literal as UTF-8 at the end
1426    // (E'\303\251' is é; E'\777' is byte 0xFF and refuses with the
1427    // encoding sentence). The old char-per-escape model mapped each
1428    // byte to its Latin-1 codepoint, silently mangling multi-byte
1429    // sequences.
1430    let mut buf: Vec<u8> = Vec::new();
1431    let mut push_char = |buf: &mut Vec<u8>, c: char| {
1432        let mut tmp = [0u8; 4];
1433        buf.extend_from_slice(c.encode_utf8(&mut tmp).as_bytes());
1434    };
1435    loop {
1436        if i >= bytes.len() {
1437            return Err(LexError {
1438                kind: LexErrorKind::UnterminatedString,
1439                pos: start,
1440            });
1441        }
1442        let b = bytes[i];
1443        if b == b'\'' {
1444            if peek_eq(bytes, i + 1, b'\'') {
1445                push_char(&mut buf, '\'');
1446                i += 2;
1447                continue;
1448            }
1449            i += 1;
1450            break;
1451        }
1452        if b == b'\\' && i + 1 < bytes.len() {
1453            let n = bytes[i + 1];
1454            // MySQL's own three points; everything below is shared.
1455            if mysql {
1456                match n {
1457                    // `\Z` is ctrl-Z, not the letter Z.
1458                    b'Z' => {
1459                        push_char(&mut buf, '\u{001A}');
1460                        i += 2;
1461                        continue;
1462                    }
1463                    // `\%` / `\_` keep BOTH characters: the backslash is
1464                    // what LIKE reads as "this wildcard is literal".
1465                    b'%' | b'_' => {
1466                        push_char(&mut buf, '\\');
1467                        push_char(&mut buf, n as char);
1468                        i += 2;
1469                        continue;
1470                    }
1471                    // `\xHH` and `\NNN` are not escapes at all here.
1472                    b'x' | b'X' => {
1473                        push_char(&mut buf, 'x');
1474                        i += 2;
1475                        continue;
1476                    }
1477                    d if d.is_ascii_digit() && d != b'0' => {
1478                        push_char(&mut buf, d as char);
1479                        i += 2;
1480                        continue;
1481                    }
1482                    _ => {}
1483                }
1484            }
1485            match n {
1486                b'\\' => {
1487                    push_char(&mut buf, '\\');
1488                    i += 2;
1489                }
1490                b'\'' => {
1491                    push_char(&mut buf, '\'');
1492                    i += 2;
1493                }
1494                b'"' => {
1495                    push_char(&mut buf, '"');
1496                    i += 2;
1497                }
1498                b'n' => {
1499                    push_char(&mut buf, '\n');
1500                    i += 2;
1501                }
1502                b'r' => {
1503                    push_char(&mut buf, '\r');
1504                    i += 2;
1505                }
1506                b't' => {
1507                    push_char(&mut buf, '\t');
1508                    i += 2;
1509                }
1510                b'b' => {
1511                    push_char(&mut buf, '\u{0008}');
1512                    i += 2;
1513                }
1514                b'f' => {
1515                    push_char(&mut buf, '\u{000C}');
1516                    i += 2;
1517                }
1518                b'v' => {
1519                    push_char(&mut buf, '\u{000B}');
1520                    i += 2;
1521                }
1522                // \uHHHH (4 hex) / \UHHHHHHHH (8 hex) Unicode escapes. A
1523                // `\u` high surrogate combines with a following `\uLLLL`
1524                // low surrogate (PG's `😀` → emoji); a lone
1525                // surrogate or short/invalid hex run is an error.
1526                b'u' | b'U' => {
1527                    let is_u = bytes[i + 1] == b'u';
1528                    let ndigits = if is_u { 4 } else { 8 };
1529                    let Some(cp) = read_hex_run(bytes, i + 2, ndigits) else {
1530                        return Err(LexError {
1531                            kind: LexErrorKind::InvalidUnicodeEscape,
1532                            pos: i,
1533                        });
1534                    };
1535                    if is_u && (0xD800..=0xDBFF).contains(&cp) {
1536                        let lo = (bytes.get(i + 6) == Some(&b'\\')
1537                            && bytes.get(i + 7) == Some(&b'u'))
1538                        .then(|| read_hex_run(bytes, i + 8, 4))
1539                        .flatten()
1540                        .filter(|l| (0xDC00..=0xDFFF).contains(l));
1541                        let Some(lo) = lo else {
1542                            return Err(LexError {
1543                                kind: LexErrorKind::InvalidUnicodeEscape,
1544                                pos: i,
1545                            });
1546                        };
1547                        let combined = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00);
1548                        push_char(
1549                            &mut buf,
1550                            char::from_u32(combined).ok_or(LexError {
1551                                kind: LexErrorKind::InvalidUnicodeEscape,
1552                                pos: i,
1553                            })?,
1554                        );
1555                        i += 12;
1556                    } else {
1557                        push_char(
1558                            &mut buf,
1559                            char::from_u32(cp).ok_or(LexError {
1560                                kind: LexErrorKind::InvalidUnicodeEscape,
1561                                pos: i,
1562                            })?,
1563                        );
1564                        i += 2 + ndigits;
1565                    }
1566                }
1567                b'0' if i + 2 >= bytes.len() || !bytes[i + 2].is_ascii_digit() => {
1568                    push_char(&mut buf, '\0');
1569                    i += 2;
1570                }
1571                b'x' => {
1572                    // \xH or \xHH — single byte by hex.
1573                    let h1 = bytes.get(i + 2).copied();
1574                    let h2 = bytes.get(i + 3).copied();
1575                    let n1 = h1.and_then(hex_digit_value);
1576                    let n2 = h2.and_then(hex_digit_value);
1577                    match (n1, n2) {
1578                        (Some(a), Some(b2)) => {
1579                            buf.push(((a << 4) | b2) as u8);
1580                            i += 4;
1581                        }
1582                        (Some(a), _) => {
1583                            buf.push(a as u8);
1584                            i += 3;
1585                        }
1586                        _ => {
1587                            // \x with no hex follows — literal x.
1588                            push_char(&mut buf, 'x');
1589                            i += 2;
1590                        }
1591                    }
1592                }
1593                d if d.is_ascii_digit() && d < b'8' => {
1594                    // \NNN octal — up to 3 octal digits.
1595                    let mut value: u32 = u32::from(d - b'0');
1596                    let mut take = 2;
1597                    while take < 4 {
1598                        let next = bytes.get(i + take).copied();
1599                        match next {
1600                            Some(c) if c.is_ascii_digit() && c < b'8' => {
1601                                value = (value << 3) | u32::from(c - b'0');
1602                                take += 1;
1603                            }
1604                            _ => break,
1605                        }
1606                    }
1607                    // A byte, as PG: \777 masks to 0xFF and the final
1608                    // UTF-8 validation refuses it.
1609                    buf.push((value & 0xFF) as u8);
1610                    i += take;
1611                }
1612                other => {
1613                    // Lenient fallback — same as PG with
1614                    // `standard_conforming_strings = off` warning:
1615                    // decode `\X` to literal `X`.
1616                    push_char(&mut buf, other as char);
1617                    i += 2;
1618                }
1619            }
1620        } else {
1621            let ch = input[i..].chars().next().expect("non-empty UTF-8 boundary");
1622            push_char(&mut buf, ch);
1623            i += ch.len_utf8();
1624        }
1625    }
1626    match String::from_utf8(buf) {
1627        Ok(decoded) => Ok((Token::String(decoded), i - start)),
1628        Err(e) => {
1629            let bad = e.as_bytes()[e.utf8_error().valid_up_to()];
1630            Err(LexError {
1631                kind: LexErrorKind::InvalidByteSequence(bad),
1632                pos: start,
1633            })
1634        }
1635    }
1636}
1637
1638/// v7.38 (read01, T18) — lex a PG `U&'...'` Unicode string literal. `start`
1639/// points at the opening quote. Decodes `\XXXX` (4 hex), `\+XXXXXX` (6 hex),
1640/// `\\` → backslash, `''` → quote; the default escape is `\`. (A trailing
1641/// `UESCAPE 'c'` clause and the `U&"..."` identifier form are separate
1642/// follow-ups.)
1643fn lex_unicode_string(input: &str, start: usize) -> Result<(Token, usize), LexError> {
1644    let bytes = input.as_bytes();
1645    debug_assert_eq!(bytes[start], b'\'');
1646    let hex_char = |hex: &str, pos: usize| -> Result<char, LexError> {
1647        u32::from_str_radix(hex, 16)
1648            .ok()
1649            .and_then(char::from_u32)
1650            .ok_or(LexError {
1651                kind: LexErrorKind::InvalidUnicodeEscape,
1652                pos,
1653            })
1654    };
1655    let mut i = start + 1;
1656    let mut s = String::new();
1657    loop {
1658        if i >= bytes.len() {
1659            return Err(LexError {
1660                kind: LexErrorKind::UnterminatedString,
1661                pos: start,
1662            });
1663        }
1664        let b = bytes[i];
1665        if b == b'\'' {
1666            if peek_eq(bytes, i + 1, b'\'') {
1667                s.push('\'');
1668                i += 2;
1669                continue;
1670            }
1671            i += 1;
1672            break;
1673        }
1674        if b == b'\\' {
1675            if peek_eq(bytes, i + 1, b'\\') {
1676                s.push('\\');
1677                i += 2;
1678                continue;
1679            }
1680            let (lo, hi) = if peek_eq(bytes, i + 1, b'+') {
1681                (i + 2, i + 8) // \+XXXXXX
1682            } else {
1683                (i + 1, i + 5) // \XXXX
1684            };
1685            if hi > bytes.len() || !input.is_char_boundary(lo) || !input.is_char_boundary(hi) {
1686                return Err(LexError {
1687                    kind: LexErrorKind::InvalidUnicodeEscape,
1688                    pos: i,
1689                });
1690            }
1691            s.push(hex_char(&input[lo..hi], i)?);
1692            i = hi;
1693            continue;
1694        }
1695        let ch = input[i..].chars().next().expect("valid utf-8 boundary");
1696        s.push(ch);
1697        i += ch.len_utf8();
1698    }
1699    Ok((Token::String(s), i - start))
1700}
1701
1702/// Read exactly `n` hex digits starting at `start`, returning their value
1703/// (or `None` if fewer than `n` hex digits are present).
1704fn read_hex_run(bytes: &[u8], start: usize, n: usize) -> Option<u32> {
1705    let mut v = 0u32;
1706    for k in 0..n {
1707        v = (v << 4) | hex_digit_value(*bytes.get(start + k)?)?;
1708    }
1709    Some(v)
1710}
1711
1712fn hex_digit_value(b: u8) -> Option<u32> {
1713    match b {
1714        b'0'..=b'9' => Some(u32::from(b - b'0')),
1715        b'a'..=b'f' => Some(u32::from(b - b'a' + 10)),
1716        b'A'..=b'F' => Some(u32::from(b - b'A' + 10)),
1717        _ => None,
1718    }
1719}
1720
1721fn lex_number(s: &str, mysql: bool) -> Result<(Token, usize), LexErrorKind> {
1722    let bytes = s.as_bytes();
1723    let mut i = 0usize;
1724    // v7.39 (round 184) — PG scan.l rejects a numeric literal that is
1725    // followed directly by an identifier character: `12__34`, `123_`,
1726    // `1.5_`, `123abc` are "trailing junk after numeric literal", not
1727    // "number + alias". Pre-r184 the tail silently became a column
1728    // alias (`SELECT 12__34` returned 12). The reported text spans the
1729    // number plus its identifier-shaped tail, like PG's error cursor.
1730    let junk_end = |from: usize| -> usize {
1731        let mut j = from;
1732        while j < bytes.len() && (bytes[j] == b'_' || bytes[j].is_ascii_alphanumeric()) {
1733            j += 1;
1734        }
1735        j
1736    };
1737    let junk_check = |end: usize| -> Result<(), LexErrorKind> {
1738        if end < bytes.len() && (bytes[end] == b'_' || bytes[end].is_ascii_alphabetic()) {
1739            return Err(LexErrorKind::TrailingJunkAfterNumber(
1740                s[..junk_end(end)].to_string(),
1741            ));
1742        }
1743        Ok(())
1744    };
1745    // v7.38 (read01) — PG 16+ non-decimal integer literals: `0x1F` (hex),
1746    // `0o17` (octal), `0b101` (binary), with optional `_` separators. Read the
1747    // radix digits, strip `_`, parse as i64 (NUMERIC on overflow).
1748    if bytes.len() >= 2 && bytes[0] == b'0' {
1749        let (radix, radix_name) = match bytes[1] {
1750            b'x' | b'X' => (Some(16u32), "hexadecimal"),
1751            b'o' | b'O' => (Some(8), "octal"),
1752            b'b' | b'B' => (Some(2), "binary"),
1753            _ => (None, ""),
1754        };
1755        if let Some(radix) = radix {
1756            // PG's shape is `0x(_?digit)+`: every `_` must be followed
1757            // by a radix digit (leading `_` allowed, trailing not).
1758            let mut j = 2;
1759            loop {
1760                let mut k = j;
1761                if k < bytes.len() && bytes[k] == b'_' {
1762                    k += 1;
1763                }
1764                if k < bytes.len() && (bytes[k] as char).is_digit(radix) {
1765                    j = k + 1;
1766                } else {
1767                    break;
1768                }
1769            }
1770            let digits: alloc::string::String = s[2..j].chars().filter(|c| *c != '_').collect();
1771            if digits.is_empty() {
1772                // `0x` / `0x_` — a radix prefix with no digits. PG:
1773                // "invalid hexadecimal integer"; pre-r184 the `0`
1774                // lexed alone and the rest aliased.
1775                return Err(LexErrorKind::InvalidRadixLiteral(
1776                    radix_name,
1777                    s[..junk_end(0)].to_string(),
1778                ));
1779            }
1780            junk_check(j)?;
1781            // v7.39 (round 367, M20) — in the MySQL dialect a `0x…`
1782            // hexadecimal literal is a BINARY STRING, not an integer
1783            // (mysqldump emits `0x…` for BINARY / BLOB column data, and
1784            // `0x41` is the string 'A'). The octal / binary radices keep
1785            // their integer reading — only `0x` diverges.
1786            if mysql && radix == 16 {
1787                return Ok((Token::HexBytes(digits), j));
1788            }
1789            return match i64::from_str_radix(&digits, radix) {
1790                Ok(v) => Ok((Token::Integer(v), j)),
1791                // Over i64 → keep as decimal NUMERIC text.
1792                Err(_) => match u128::from_str_radix(&digits, radix) {
1793                    Ok(v) => Ok((Token::Numeric(alloc::format!("{v}")), j)),
1794                    Err(_) => Err(LexErrorKind::BadNumber(s[..j].to_string())),
1795                },
1796            };
1797        }
1798    }
1799    // v7.38 (read01) — track the dot and exponent separately. PG: a dotted
1800    // literal with NO exponent is NUMERIC; an exponent (`1e5`, `1.5e3`) makes
1801    // it double precision; a bare integer is INTEGER unless it overflows i64,
1802    // in which case it is NUMERIC too.
1803    let mut has_dot = false;
1804    let mut has_exp = false;
1805
1806    // v7.38 (read01) — accept `_` digit separators between digits (PG 16+:
1807    // `1_000_000`, `1_000.5`). Stripped before parsing below.
1808    let digit_or_sep = |bytes: &[u8], i: usize| -> bool {
1809        bytes[i].is_ascii_digit()
1810            || (bytes[i] == b'_' && i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit())
1811    };
1812
1813    while i < bytes.len() && digit_or_sep(bytes, i) {
1814        i += 1;
1815    }
1816    // v7.37.20 (20.4) — do NOT consume `.` when it's part of a `..`
1817    // range operator; leave both dots for the top-level dispatcher
1818    // which will emit a single Token::DotDot.
1819    if i < bytes.len() && bytes[i] == b'.' && !(i + 1 < bytes.len() && bytes[i + 1] == b'.') {
1820        has_dot = true;
1821        i += 1;
1822        // r184 — a fraction may only START with a digit: `1._5` is
1823        // trailing junk in PG (`_` is a separator BETWEEN digits),
1824        // not 1.5. Leaving the `_` unconsumed routes it into the
1825        // junk check below.
1826        if i < bytes.len() && bytes[i].is_ascii_digit() {
1827            while i < bytes.len() && digit_or_sep(bytes, i) {
1828                i += 1;
1829            }
1830        }
1831    }
1832    if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
1833        has_exp = true;
1834        i += 1;
1835        if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
1836            i += 1;
1837        }
1838        let exp_start = i;
1839        // r184 — same rule as the fraction: the exponent must start
1840        // with a digit (`1e_5` is junk, not 1e5).
1841        if i < bytes.len() && bytes[i].is_ascii_digit() {
1842            while i < bytes.len() && digit_or_sep(bytes, i) {
1843                i += 1;
1844            }
1845        }
1846        if exp_start == i {
1847            return Err(LexErrorKind::BadNumber(s[..i].to_string()));
1848        }
1849    }
1850    // r184 — reject an identifier-shaped tail glued to the number.
1851    junk_check(i)?;
1852
1853    // Strip the `_` separators for parsing / storage (source span keeps `i`).
1854    let owned;
1855    let lit: &str = if s[..i].contains('_') {
1856        owned = s[..i].replace('_', "");
1857        &owned
1858    } else {
1859        &s[..i]
1860    };
1861    if has_exp {
1862        // v7.39 (read01 numeric.c) — an exponent literal is NUMERIC in PG
1863        // (`pg_typeof(1e5)` → numeric), not double precision. Keep the source
1864        // text; the parser expands the notation into a plain decimal.
1865        Ok((Token::Numeric(lit.to_string()), i))
1866    } else if has_dot {
1867        // Dotted literal → exact NUMERIC (keep the source text verbatim).
1868        Ok((Token::Numeric(lit.to_string()), i))
1869    } else {
1870        // Bare integer → INTEGER, or NUMERIC if it overflows i64.
1871        match lit.parse::<i64>() {
1872            Ok(v) => Ok((Token::Integer(v), i)),
1873            Err(_) => Ok((Token::Numeric(lit.to_string()), i)),
1874        }
1875    }
1876}
1877
1878#[cfg(test)]
1879mod tests {
1880    use super::*;
1881    use alloc::vec;
1882
1883    fn lex(s: &str) -> Vec<Token> {
1884        tokenize(s).expect("lex ok")
1885    }
1886
1887    #[test]
1888    fn empty_yields_only_eof() {
1889        assert_eq!(lex(""), vec![Token::Eof]);
1890    }
1891
1892    #[test]
1893    fn whitespace_only_yields_only_eof() {
1894        assert_eq!(lex("   \t\n  "), vec![Token::Eof]);
1895    }
1896
1897    #[test]
1898    fn keywords_are_case_insensitive() {
1899        assert_eq!(
1900            lex("SELECT select Select"),
1901            vec![Token::Select, Token::Select, Token::Select, Token::Eof]
1902        );
1903    }
1904
1905    #[test]
1906    fn identifiers_lowercase_ascii() {
1907        assert_eq!(
1908            lex("hello WORLD _x x1"),
1909            vec![
1910                Token::Ident("hello".into()),
1911                Token::Ident("world".into()),
1912                Token::Ident("_x".into()),
1913                Token::Ident("x1".into()),
1914                Token::Eof,
1915            ]
1916        );
1917    }
1918
1919    #[test]
1920    fn quoted_identifier_keeps_case_and_handles_embedded_quote() {
1921        assert_eq!(
1922            lex(r#""User Name" "a""b""#),
1923            vec![
1924                Token::QuotedIdent("User Name".into()),
1925                Token::QuotedIdent("a\"b".into()),
1926                Token::Eof,
1927            ]
1928        );
1929    }
1930
1931    #[test]
1932    fn integer_and_float_literals() {
1933        // v7.38 (read01) — a dotted literal lexes as NUMERIC (exact source
1934        // text); an exponent form stays double precision.
1935        assert_eq!(
1936            lex("0 42 1.5 .5 1e10 2.5e-3"),
1937            vec![
1938                Token::Integer(0),
1939                Token::Integer(42),
1940                Token::Numeric("1.5".to_string()),
1941                Token::Numeric(".5".to_string()),
1942                Token::Numeric("1e10".to_string()),
1943                Token::Numeric("2.5e-3".to_string()),
1944                Token::Eof,
1945            ]
1946        );
1947    }
1948
1949    #[test]
1950    fn negative_number_is_minus_then_integer() {
1951        // PG follows this: unary minus is a separate token, parser folds it.
1952        assert_eq!(
1953            lex("-42"),
1954            vec![Token::Minus, Token::Integer(42), Token::Eof]
1955        );
1956    }
1957
1958    #[test]
1959    fn string_literal_doubled_quote_escape() {
1960        assert_eq!(
1961            lex("'hello' 'it''s'"),
1962            vec![
1963                Token::String("hello".into()),
1964                Token::String("it's".into()),
1965                Token::Eof,
1966            ]
1967        );
1968    }
1969
1970    #[test]
1971    fn all_comparison_and_arithmetic_operators() {
1972        assert_eq!(
1973            lex("= <> != < <= > >= + - * / %"),
1974            vec![
1975                Token::Eq,
1976                Token::NotEq,
1977                Token::NotEq,
1978                Token::Lt,
1979                Token::LtEq,
1980                Token::Gt,
1981                Token::GtEq,
1982                Token::Plus,
1983                Token::Minus,
1984                Token::Star,
1985                Token::Slash,
1986                Token::Percent,
1987                Token::Eof,
1988            ]
1989        );
1990    }
1991
1992    #[test]
1993    fn punctuation() {
1994        assert_eq!(
1995            lex("( ) , ; ."),
1996            vec![
1997                Token::LParen,
1998                Token::RParen,
1999                Token::Comma,
2000                Token::Semicolon,
2001                Token::Dot,
2002                Token::Eof,
2003            ]
2004        );
2005    }
2006
2007    #[test]
2008    fn line_comment_skipped() {
2009        assert_eq!(
2010            lex("SELECT -- trailing junk\nFROM"),
2011            vec![Token::Select, Token::From, Token::Eof]
2012        );
2013    }
2014
2015    #[test]
2016    fn block_comment_skipped() {
2017        assert_eq!(
2018            lex("SELECT /* skipped */ 1"),
2019            vec![Token::Select, Token::Integer(1), Token::Eof]
2020        );
2021    }
2022
2023    #[test]
2024    fn unterminated_string_errors() {
2025        let err = tokenize("'oops").unwrap_err();
2026        assert!(matches!(err.kind, LexErrorKind::UnterminatedString));
2027        assert_eq!(err.pos, 0);
2028    }
2029
2030    #[test]
2031    fn unterminated_block_comment_errors() {
2032        let err = tokenize("/* never closed").unwrap_err();
2033        assert!(matches!(err.kind, LexErrorKind::UnterminatedBlockComment));
2034    }
2035
2036    #[test]
2037    fn unknown_char_errors() {
2038        // v7.17.0 Phase 2.6 — `@` standalone now lexes as
2039        // Token::At (mysqldump `'user'@'host'` DEFINER stitching).
2040        // Use `?` for the unknown-char regression; PG `?` operator
2041        // family is parsed as JSON ops in the prefix `?` shape
2042        // would land in lex paths; bare `?` is unknown.
2043        let err = tokenize("\x07").unwrap_err();
2044        assert!(matches!(err.kind, LexErrorKind::UnknownChar(_)));
2045    }
2046
2047    #[test]
2048    fn at_alone_lexes_as_punctuation() {
2049        // v7.17.0 Phase 2.6 — the `'user'@'host'` MySQL DEFINER
2050        // form needs `@` to lex as a standalone token.
2051        assert_eq!(
2052            lex("'u'@'h'"),
2053            vec![
2054                Token::String("u".into()),
2055                Token::At,
2056                Token::String("h".into()),
2057                Token::Eof,
2058            ]
2059        );
2060    }
2061
2062    #[test]
2063    fn dot_in_qualified_column() {
2064        assert_eq!(
2065            lex("t.col"),
2066            vec![
2067                Token::Ident("t".into()),
2068                Token::Dot,
2069                Token::Ident("col".into()),
2070                Token::Eof,
2071            ]
2072        );
2073    }
2074
2075    // --- v0.11 brackets + distance op + vector keyword --------------------
2076
2077    #[test]
2078    fn brackets_are_distinct_tokens() {
2079        assert_eq!(
2080            lex("[ ]"),
2081            vec![Token::LBracket, Token::RBracket, Token::Eof]
2082        );
2083    }
2084
2085    #[test]
2086    fn l2_distance_is_three_char_token() {
2087        assert_eq!(
2088            lex("a <-> b"),
2089            vec![
2090                Token::Ident("a".into()),
2091                Token::L2Distance,
2092                Token::Ident("b".into()),
2093                Token::Eof,
2094            ]
2095        );
2096        // Bare `<-` should NOT match L2Distance.
2097        assert_eq!(
2098            lex("a <- b"),
2099            vec![
2100                Token::Ident("a".into()),
2101                Token::Lt,
2102                Token::Minus,
2103                Token::Ident("b".into()),
2104                Token::Eof,
2105            ]
2106        );
2107    }
2108
2109    #[test]
2110    fn order_by_limit_are_keywords() {
2111        assert_eq!(
2112            lex("ORDER BY LIMIT"),
2113            vec![
2114                Token::Order,
2115                Token::Ident("by".into()),
2116                Token::Limit,
2117                Token::Eof,
2118            ]
2119        );
2120    }
2121
2122    // --- v1.2: pgvector distance ops + PG cast --------------------------
2123
2124    #[test]
2125    fn inner_product_operator_3char() {
2126        assert_eq!(
2127            lex("a <#> b"),
2128            vec![
2129                Token::Ident("a".into()),
2130                Token::InnerProduct,
2131                Token::Ident("b".into()),
2132                Token::Eof,
2133            ]
2134        );
2135    }
2136
2137    #[test]
2138    fn cosine_distance_operator_3char() {
2139        assert_eq!(
2140            lex("a <=> b"),
2141            vec![
2142                Token::Ident("a".into()),
2143                Token::CosineDistance,
2144                Token::Ident("b".into()),
2145                Token::Eof,
2146            ]
2147        );
2148        // Make sure `<=` and `<>` and `<->` still lex right when `<=>` is
2149        // around (greedy match takes the longest).
2150        assert_eq!(
2151            lex("a <= b"),
2152            vec![
2153                Token::Ident("a".into()),
2154                Token::LtEq,
2155                Token::Ident("b".into()),
2156                Token::Eof,
2157            ]
2158        );
2159    }
2160
2161    #[test]
2162    fn double_colon_cast_token() {
2163        assert_eq!(
2164            lex("x::INT"),
2165            vec![
2166                Token::Ident("x".into()),
2167                Token::DoubleColon,
2168                Token::Ident("int".into()),
2169                Token::Eof,
2170            ]
2171        );
2172    }
2173
2174    #[test]
2175    fn lone_single_colon_lexes_as_colon_token() {
2176        // v7.12.4 — single `:` is now a token (PL/pgSQL surface
2177        // + tsvector external-form literal both need it). The
2178        // pre-v7.12.4 "single colon = unknown char" behaviour
2179        // was incidental.
2180        let toks = tokenize(":x").expect("colon now lexes");
2181        assert_eq!(toks[0], Token::Colon);
2182    }
2183
2184    #[test]
2185    fn colon_eq_lexes_as_assignment() {
2186        // v7.12.4 — PL/pgSQL assignment operator.
2187        let toks = tokenize("x := 1").expect("colon-eq lexes");
2188        // Tokens: Ident("x"), ColonEq, NumberLiteral
2189        assert!(matches!(toks[1], Token::ColonEq));
2190    }
2191
2192    #[test]
2193    fn pg_escape_string_double_backslash_decodes_to_single() {
2194        // v7.18 — E'\\xdeadbeef' decodes to literal `\xdeadbeef`
2195        // (10 chars: backslash + xdeadbeef). The downstream
2196        // `::bytea` cast then reads that as the PG hex-form bytea
2197        // literal. mailrs D-pre #3.
2198        let toks = tokenize(r"E'\\xdeadbeef'").expect("E-string lexes");
2199        assert_eq!(toks, vec![Token::String(r"\xdeadbeef".into()), Token::Eof]);
2200    }
2201
2202    #[test]
2203    fn pg_escape_string_supports_basic_escapes() {
2204        // \n / \t / \' / \\ — the PG standard set.
2205        let toks = tokenize(r"E'a\nb\tc\'d\\e'").expect("E-string lexes");
2206        assert_eq!(toks, vec![Token::String("a\nb\tc'd\\e".into()), Token::Eof]);
2207    }
2208
2209    #[test]
2210    fn pg_escape_string_hex_byte() {
2211        // \xHH single byte. \x41 = 'A'.
2212        let toks = tokenize(r"E'\x41B\x42'").expect("E-string lexes");
2213        assert_eq!(toks, vec![Token::String("ABB".into()), Token::Eof]);
2214    }
2215
2216    #[test]
2217    fn pg_escape_string_lowercase_e_prefix() {
2218        let toks = tokenize(r"e'hi\n'").expect("e-string lexes");
2219        assert_eq!(toks, vec![Token::String("hi\n".into()), Token::Eof]);
2220    }
2221
2222    #[test]
2223    fn pg_escape_string_doubled_quote() {
2224        // Even in E-string the doubled '' is a literal '.
2225        let toks = tokenize(r"E'it''s ok'").expect("E-string lexes");
2226        assert_eq!(toks, vec![Token::String("it's ok".into()), Token::Eof]);
2227    }
2228}