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