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