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