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