Skip to main content

spg_sql/
lexer.rs

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