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