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