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