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