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