Skip to main content

spg_sql/
lexer.rs

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