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 String(String),
59
60 // Operators
61 Plus,
62 Minus,
63 Star,
64 Slash,
65 Eq,
66 NotEq,
67 Lt,
68 LtEq,
69 Gt,
70 GtEq,
71 /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR strict contained-in
72 /// `<<`. LHS is strictly inside RHS (no equality).
73 InetContainedBy,
74 /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR contained-in-or-equal
75 /// `<<=`. LHS network ⊆ RHS network.
76 InetContainedByEq,
77 /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR strict contains `>>`.
78 /// LHS strictly contains RHS.
79 InetContains,
80 /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR contains-or-equal `>>=`.
81 /// LHS network ⊇ RHS network.
82 InetContainsEq,
83 /// v7.17.0 Phase 3.P0-47 — PG INET / CIDR network overlap `&&`.
84 /// Either side contains any address of the other.
85 InetOverlap,
86
87 // Punctuation
88 LParen,
89 RParen,
90 LBracket,
91 RBracket,
92 Comma,
93 Semicolon,
94 Dot,
95 /// v7.17.0 Phase 2.6 — standalone `@` punctuation. Emitted when
96 /// `@` is NOT followed by an ident-start byte (i.e. the
97 /// `@VAR` / `@@VAR` SessionVar path doesn't match). Lets the
98 /// parser stitch the MySQL `'user'@'host'` DEFINER form back
99 /// together as String + At + String. Pre-2.6 this same shape
100 /// surfaced as a `LexErrorKind::UnknownChar('@')` and broke
101 /// every mysqldump CREATE VIEW with a DEFINER clause at lex
102 /// time.
103 At,
104 /// pgvector L2 distance operator `<->`. Lexed as one token so the
105 /// parser can give it its own precedence rung.
106 /// v4.14 `->` — JSON object/array element access, returns json.
107 JsonGet,
108 /// v4.14 `->>` — same access, returns text.
109 JsonGetText,
110 /// v6.4.5 `#>` — JSON path walk, returns json. Path is the
111 /// right-hand TEXT with PG `{a,b,0}` syntax.
112 JsonGetPath,
113 /// v6.4.5 `#>>` — same walk, returns text.
114 JsonGetPathText,
115 /// v6.4.5 `@>` — JSON containment. `j @> sub` returns true if
116 /// every key/value in `sub` is present in `j` with structural
117 /// containment for objects + arrays.
118 JsonContains,
119 /// v7.37.6-A `<@` — JSON contained-by. `a <@ b` ⇔ `b @> a`.
120 JsonContainedBy,
121 /// v7.37.6-A `?` — JSON key exists (object), or element-as-text
122 /// exists (array). `j ? 'key'` returns BOOL.
123 JsonKeyExists,
124 /// v7.37.6-A `?|` — JSON any-key-exists. `j ?| ARRAY['a','b']`
125 /// returns BOOL; true if any one of the listed keys exists in `j`.
126 JsonKeysAny,
127 /// v7.37.6-A `?&` — JSON all-keys-exist. `j ?& ARRAY['a','b']`
128 /// returns BOOL; true if every listed key exists in `j`.
129 JsonKeysAll,
130 /// v7.12.2 `@@` — tsvector / tsquery match. Either ordering
131 /// (`vec @@ q` or `q @@ vec`) parses; engine eval normalises
132 /// before matching.
133 TsMatch,
134 L2Distance,
135 /// pgvector inner-product operator `<#>` (returns negative dot product
136 /// so smaller still means more similar — same semantics as pgvector).
137 InnerProduct,
138 /// pgvector cosine distance operator `<=>`.
139 CosineDistance,
140 /// PG-style cast `expr::type` — single token because we want it to bind
141 /// at postfix precedence.
142 DoubleColon,
143 /// v7.12.4 — PL/pgSQL assignment operator `:=`.
144 /// Outside PL/pgSQL bodies this token has no SQL-side meaning.
145 ColonEq,
146 /// v7.12.4 — bare `:` separator. Used inside `tsvector` external-form
147 /// literals (`'cat:1 dog:2'::tsvector`) and as the fallback path for
148 /// the PL/pgSQL assignment lexer.
149 Colon,
150 /// Standard SQL string concatenation `||`.
151 Concat,
152 /// Bitwise OR `|` (single pipe — `||` lexes as Concat first).
153 Pipe,
154 /// Bitwise AND `&` (single amp — `&&` lexes as InetOverlap first).
155 Amp,
156 /// Bitwise NOT `~` (prefix).
157 Tilde,
158 /// `IS` keyword — postfix `IS NULL` / `IS NOT NULL` predicates.
159 Is,
160 Between,
161 In,
162 Like,
163 Group,
164 Distinct,
165 Union,
166 All,
167 Join,
168 Inner,
169 Left,
170 Cross,
171 Outer,
172 Default,
173 Savepoint,
174 Release,
175 To,
176 Having,
177 Show,
178 Extract,
179 Offset,
180 Asc,
181 Desc,
182 /// `INTERVAL` — followed by a string literal carrying the span text
183 /// (e.g. `INTERVAL '1 day 2 hours'`).
184 Interval,
185 /// v6.1.1 — `$N` parameter placeholder for the extended query
186 /// protocol. The number N is 1-based per PostgreSQL convention.
187 /// `0` and `$0` are not valid; the lexer rejects them.
188 Placeholder(u16),
189
190 /// v6.1.2 — `DROP` keyword. Used by `DROP PUBLICATION <name>`.
191 /// Reserved for future `DROP TABLE` / `DROP INDEX` / `DROP USER`
192 /// surface that currently goes through SHOW-shaped admin SQL.
193 Drop,
194 /// v6.1.2 — `FOR` keyword (publication scope).
195 For,
196 /// v6.1.2 — `TABLES` plural keyword (`FOR ALL TABLES`,
197 /// `FOR ALL TABLES EXCEPT …`). The existing `TABLE` keyword
198 /// stays a separate token so `CREATE TABLE`'s single-table
199 /// form keeps lexing as today.
200 Tables,
201 /// v6.1.3 (reserved at v6.1.2 to keep the AST shape stable) —
202 /// `EXCEPT` keyword for `FOR ALL TABLES EXCEPT t1, t2`.
203 Except,
204 /// v6.1.2 — `PUBLICATION` keyword.
205 Publication,
206 /// v6.1.4 (reserved at v6.1.2) — `SUBSCRIPTION` keyword.
207 Subscription,
208 /// v6.1.4 — `CONNECTION` keyword (for
209 /// `CREATE SUBSCRIPTION … CONNECTION '<conn_str>' …`).
210 Connection,
211 /// v7.37.6-B(sentori Epic 2 P0)— `PARTITION` keyword. Drives
212 /// both `CREATE TABLE p (…) PARTITION BY RANGE (key)` (declarative
213 /// parent) and `CREATE TABLE c PARTITION OF p FOR VALUES FROM
214 /// (a) TO (b) | DEFAULT` (child). `OF` / `MINVALUE` / `MAXVALUE`
215 /// stay PG-context-sensitive identifiers — the parser matches them
216 /// as case-insensitive `Token::Ident` strings off the back of this
217 /// reserved keyword, mirroring how `INSERT … RETURNING` handles
218 /// `RETURNING` without burning a global keyword slot.
219 Partition,
220
221 Eof,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub enum LexErrorKind {
226 UnknownChar(char),
227 UnterminatedString,
228 UnterminatedQuotedIdent,
229 UnterminatedBlockComment,
230 BadNumber(String),
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
234pub struct LexError {
235 pub kind: LexErrorKind,
236 pub pos: usize,
237}
238
239impl fmt::Display for LexError {
240 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
241 match &self.kind {
242 LexErrorKind::UnknownChar(c) => write!(f, "unknown char {c:?} at byte {}", self.pos),
243 LexErrorKind::UnterminatedString => {
244 write!(f, "unterminated string literal at byte {}", self.pos)
245 }
246 LexErrorKind::UnterminatedQuotedIdent => {
247 write!(f, "unterminated quoted identifier at byte {}", self.pos)
248 }
249 LexErrorKind::UnterminatedBlockComment => {
250 write!(f, "unterminated /* */ comment at byte {}", self.pos)
251 }
252 LexErrorKind::BadNumber(s) => {
253 write!(f, "invalid number literal {s:?} at byte {}", self.pos)
254 }
255 }
256 }
257}
258
259/// Tokenize `input` into a `Vec<Token>` ending in `Token::Eof`,
260/// with PG string semantics (backslash is a literal byte inside
261/// `'…'`; `''` is the only escape).
262pub fn tokenize(input: &str) -> Result<Vec<Token>, LexError> {
263 tokenize_with(input, false)
264}
265
266/// v7.22 (round-13 T3) — dialect-aware tokenizer entry. With
267/// `backslash_escapes = true`, plain `'…'` strings honour MySQL /
268/// pre-9.1-PG backslash escapes (`\'` `\\` `\n` …, the same decode
269/// the `E'…'` form uses). mysqldump ALWAYS emits `\'`-escaped data
270/// sections, and pg_dump ALWAYS announces PG semantics via
271/// `SET standard_conforming_strings = on` — the engine flips this
272/// flag off/on from those deterministic session signals.
273#[allow(clippy::too_many_lines)] // big match — splitting would obscure the dispatch table
274pub fn tokenize_with(input: &str, backslash_escapes: bool) -> Result<Vec<Token>, LexError> {
275 let bytes = input.as_bytes();
276 let mut i = 0usize;
277 let mut out = Vec::new();
278
279 while i < bytes.len() {
280 let b = bytes[i];
281 match b {
282 b' ' | b'\t' | b'\n' | b'\r' => {
283 i += 1;
284 }
285 b'-' if peek_eq(bytes, i + 1, b'-') => {
286 i += 2;
287 while i < bytes.len() && bytes[i] != b'\n' {
288 i += 1;
289 }
290 }
291 b'/' if peek_eq(bytes, i + 1, b'*') => {
292 let start = i;
293 // v7.14.0 — MySQL versioned conditional comment
294 // `/*!NNNNN <body> */`. The body is real SQL that
295 // MySQL/MariaDB executes when the runtime version
296 // matches the 5-digit code; PG strips the whole
297 // thing as a block comment. SPG sides with MySQL
298 // semantics for dump compatibility: skip the
299 // `/*!NNNNN ` prefix and continue lexing the body
300 // as ordinary tokens. The closing `*/` is later
301 // matched + skipped by the symmetric arm below.
302 if peek_eq(bytes, i + 2, b'!') {
303 let mut j = i + 3;
304 // skip the optional 5-digit version code +
305 // following single whitespace
306 while j < bytes.len() && bytes[j].is_ascii_digit() {
307 j += 1;
308 }
309 if j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
310 j += 1;
311 }
312 i = j;
313 continue;
314 }
315 i += 2;
316 let mut closed = false;
317 while i + 1 < bytes.len() {
318 if bytes[i] == b'*' && bytes[i + 1] == b'/' {
319 i += 2;
320 closed = true;
321 break;
322 }
323 i += 1;
324 }
325 if !closed {
326 return Err(LexError {
327 kind: LexErrorKind::UnterminatedBlockComment,
328 pos: start,
329 });
330 }
331 }
332 // v7.14.0 — bare `*/` (closing of the v7.14 MySQL
333 // versioned-comment opener that didn't consume the
334 // closer). We treat it as an inline comment terminator
335 // and skip 2 bytes.
336 b'*' if peek_eq(bytes, i + 1, b'/') => {
337 i += 2;
338 }
339 b'\'' => {
340 let (tok, consumed) = if backslash_escapes {
341 // MySQL-dialect session: plain strings decode
342 // backslash escapes — same machinery as E'…'.
343 lex_escape_string(input, i)?
344 } else {
345 lex_quoted(input, i, b'\'', false)?
346 };
347 out.push(tok);
348 i += consumed;
349 }
350 // v7.18 — PG escape-string literal `E'...'` / `e'...'`.
351 // Closes the mailrs D-pre #3 reverse-acceptance gap:
352 // `INSERT INTO oq VALUES (E'\\xdeadbeef'::bytea)` needs
353 // the `E` prefix so `\\` decodes to a single `\`. The
354 // produced Token::String carries the decoded body so
355 // downstream parser / cast paths treat it identically
356 // to a regular string literal.
357 b'E' | b'e' if peek_eq(bytes, i + 1, b'\'') => {
358 let (tok, consumed) = lex_escape_string(input, i + 1)?;
359 out.push(tok);
360 i += 1 + consumed;
361 }
362 b'"' => {
363 let (tok, consumed) = lex_quoted(input, i, b'"', true)?;
364 out.push(tok);
365 i += consumed;
366 }
367 // MySQL-flavoured backtick-quoted identifier. Same semantics
368 // as the standard `"..."` form, including embedded "``" as
369 // a literal backtick.
370 b'`' => {
371 let (tok, consumed) = lex_quoted(input, i, b'`', true)?;
372 out.push(tok);
373 i += consumed;
374 }
375 b if b.is_ascii_alphabetic() || b == b'_' => {
376 let start = i;
377 i += 1;
378 while i < bytes.len() {
379 let c = bytes[i];
380 if c.is_ascii_alphanumeric() || c == b'_' {
381 i += 1;
382 } else {
383 break;
384 }
385 }
386 let raw = &input[start..i];
387 // v3.0.5: try the keyword table case-insensitively
388 // without allocating; only the ident fall-through
389 // pays for a lowercase String.
390 out.push(keyword_or_ident_raw(raw));
391 }
392 b if b.is_ascii_digit() => {
393 let (tok, consumed) =
394 lex_number(&input[i..]).map_err(|kind| LexError { kind, pos: i })?;
395 out.push(tok);
396 i += consumed;
397 }
398 b'.' if peek_pred(bytes, i + 1, u8::is_ascii_digit) => {
399 let (tok, consumed) =
400 lex_number(&input[i..]).map_err(|kind| LexError { kind, pos: i })?;
401 out.push(tok);
402 i += consumed;
403 }
404 b'+' => single(&mut out, Token::Plus, &mut i),
405 // v7.37.6-A — PG JSONB `?` / `?|` / `?&`. Longest-match
406 // order matters: try `?|` and `?&` before bare `?`.
407 // SPG doesn't use `?` as a placeholder (uses `$N`
408 // instead), so the bare `?` slot is free for JSONB.
409 b'?' if peek_eq(bytes, i + 1, b'|') => {
410 out.push(Token::JsonKeysAny);
411 i += 2;
412 }
413 b'?' if peek_eq(bytes, i + 1, b'&') => {
414 out.push(Token::JsonKeysAll);
415 i += 2;
416 }
417 b'?' => single(&mut out, Token::JsonKeyExists, &mut i),
418 b'-' => {
419 // v4.14: `->>` and `->` for JSON path access. `->>`
420 // must be tried before `->` (longest match).
421 if peek_eq(bytes, i + 1, b'>') && peek_eq(bytes, i + 2, b'>') {
422 out.push(Token::JsonGetText);
423 i += 3;
424 } else if peek_eq(bytes, i + 1, b'>') {
425 out.push(Token::JsonGet);
426 i += 2;
427 } else {
428 single(&mut out, Token::Minus, &mut i);
429 }
430 }
431 // v6.4.5: `#>>` and `#>` JSON path walk.
432 b'#' => {
433 if peek_eq(bytes, i + 1, b'>') && peek_eq(bytes, i + 2, b'>') {
434 out.push(Token::JsonGetPathText);
435 i += 3;
436 } else if peek_eq(bytes, i + 1, b'>') {
437 out.push(Token::JsonGetPath);
438 i += 2;
439 } else {
440 return Err(LexError {
441 kind: LexErrorKind::UnknownChar('#'),
442 pos: i,
443 });
444 }
445 }
446 // v6.4.5: `@>` JSON containment.
447 // v7.12.2: `@@` tsvector / tsquery match.
448 // v7.14.0: `@@NAME` MySQL session variable ref +
449 // `@NAME` user variable ref. mysqldump preamble
450 // uses both heavily (`SET @OLD_FOREIGN_KEY_CHECKS
451 // = @@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0`).
452 // We lex both as a single SessionVar token so
453 // the parser can accept and ignore them.
454 b'@' => {
455 if peek_eq(bytes, i + 1, b'>') {
456 out.push(Token::JsonContains);
457 i += 2;
458 } else if peek_eq(bytes, i + 1, b'@')
459 && !is_session_var_ident_start(bytes.get(i + 2).copied())
460 {
461 // `@@` not followed by an ident-start byte is
462 // the tsquery `@@` operator.
463 out.push(Token::TsMatch);
464 i += 2;
465 } else {
466 // `@VAR` / `@@VAR` — MySQL user / session
467 // variable reference. Consume the ident-shaped
468 // tail and emit as Token::SessionVar so the
469 // SET parser can accept-and-ignore.
470 let prefix_end = if peek_eq(bytes, i + 1, b'@') {
471 i + 2
472 } else {
473 i + 1
474 };
475 let mut end = prefix_end;
476 while end < bytes.len() && is_session_var_ident_continue(bytes[end]) {
477 end += 1;
478 }
479 if end == prefix_end {
480 // v7.17.0 Phase 2.6 — `@` not followed by an
481 // ident-shaped tail. mysqldump's DEFINER
482 // form `'user'@'host'` lands here (next
483 // byte is `'`). Emit as Token::At so the
484 // parser can stitch the surrounding String
485 // tokens. Single `@@` already short-circuits
486 // to Token::TsMatch above, so this only
487 // fires for a true lone `@`.
488 out.push(Token::At);
489 i = prefix_end;
490 continue;
491 }
492 out.push(Token::SessionVar(input[i..end].to_string()));
493 i = end;
494 }
495 }
496 b'*' => single(&mut out, Token::Star, &mut i),
497 b'/' => single(&mut out, Token::Slash, &mut i),
498 b'(' => single(&mut out, Token::LParen, &mut i),
499 b')' => single(&mut out, Token::RParen, &mut i),
500 b'[' => single(&mut out, Token::LBracket, &mut i),
501 b']' => single(&mut out, Token::RBracket, &mut i),
502 b',' => single(&mut out, Token::Comma, &mut i),
503 b';' => single(&mut out, Token::Semicolon, &mut i),
504 b'.' => single(&mut out, Token::Dot, &mut i),
505 b'=' => single(&mut out, Token::Eq, &mut i),
506 b'<' => {
507 if peek_eq(bytes, i + 1, b'=') && peek_eq(bytes, i + 2, b'>') {
508 out.push(Token::CosineDistance);
509 i += 3;
510 } else if peek_eq(bytes, i + 1, b'#') && peek_eq(bytes, i + 2, b'>') {
511 out.push(Token::InnerProduct);
512 i += 3;
513 } else if peek_eq(bytes, i + 1, b'-') && peek_eq(bytes, i + 2, b'>') {
514 out.push(Token::L2Distance);
515 i += 3;
516 } else if peek_eq(bytes, i + 1, b'<') && peek_eq(bytes, i + 2, b'=') {
517 // v7.17.0 Phase 3.P0-47 — PG INET `<<=` contained-or-equal.
518 out.push(Token::InetContainedByEq);
519 i += 3;
520 } else if peek_eq(bytes, i + 1, b'<') {
521 // v7.17.0 Phase 3.P0-47 — PG INET `<<` strict contained.
522 out.push(Token::InetContainedBy);
523 i += 2;
524 } else if peek_eq(bytes, i + 1, b'@') {
525 // v7.37.6-A — PG JSONB `<@` contained-by.
526 out.push(Token::JsonContainedBy);
527 i += 2;
528 } else if peek_eq(bytes, i + 1, b'=') {
529 out.push(Token::LtEq);
530 i += 2;
531 } else if peek_eq(bytes, i + 1, b'>') {
532 out.push(Token::NotEq);
533 i += 2;
534 } else {
535 out.push(Token::Lt);
536 i += 1;
537 }
538 }
539 b':' if peek_eq(bytes, i + 1, b':') => {
540 out.push(Token::DoubleColon);
541 i += 2;
542 }
543 b':' if peek_eq(bytes, i + 1, b'=') => {
544 // v7.12.4 — PL/pgSQL assignment operator `:=`.
545 out.push(Token::ColonEq);
546 i += 2;
547 }
548 b':' => {
549 // v7.12.4 — bare `:`. Used inside `tsvector` external-form
550 // literals which the cast parser consumes in-token, and as a
551 // separator the PL/pgSQL assignment lexer can recover from.
552 out.push(Token::Colon);
553 i += 1;
554 }
555 b'|' if peek_eq(bytes, i + 1, b'|') => {
556 out.push(Token::Concat);
557 i += 2;
558 }
559 // Bitwise operators (PG integer ops; mailrs IMAP flag
560 // masks: `flags | $1`, `flags & ~$1`).
561 b'|' => {
562 single(&mut out, Token::Pipe, &mut i);
563 }
564 b'~' => {
565 single(&mut out, Token::Tilde, &mut i);
566 }
567 b'>' => {
568 if peek_eq(bytes, i + 1, b'>') && peek_eq(bytes, i + 2, b'=') {
569 // v7.17.0 Phase 3.P0-47 — PG INET `>>=` contains-or-equal.
570 out.push(Token::InetContainsEq);
571 i += 3;
572 } else if peek_eq(bytes, i + 1, b'>') {
573 // v7.17.0 Phase 3.P0-47 — PG INET `>>` strict contains.
574 out.push(Token::InetContains);
575 i += 2;
576 } else if peek_eq(bytes, i + 1, b'=') {
577 out.push(Token::GtEq);
578 i += 2;
579 } else {
580 out.push(Token::Gt);
581 i += 1;
582 }
583 }
584 b'&' if peek_eq(bytes, i + 1, b'&') => {
585 // v7.17.0 Phase 3.P0-47 — PG INET network overlap `&&`.
586 out.push(Token::InetOverlap);
587 i += 2;
588 }
589 b'&' => {
590 single(&mut out, Token::Amp, &mut i);
591 }
592 b'!' if peek_eq(bytes, i + 1, b'=') => {
593 out.push(Token::NotEq);
594 i += 2;
595 }
596 // v7.9.27 — PG dollar-quoted string `$$ … $$` (or
597 // `$tag$ … $tag$`). Used in `DO $$ … $$ LANGUAGE
598 // plpgsql;` blocks that pg_dump emits for idempotent
599 // migrations. SPG has no PL/pgSQL, so the lexer
600 // consumes the entire string as a single Token::String
601 // and the parser treats the surrounding `DO …;` as a
602 // no-op. mailrs follow-up H1.
603 b'$' if i + 1 < bytes.len() && bytes[i + 1] == b'$' => {
604 // Empty tag form: `$$ … $$`.
605 let end = find_dollar_tag_end(bytes, i + 2, b"$$");
606 let body = match end {
607 Some(e) => &input[i + 2..e],
608 None => {
609 return Err(LexError {
610 kind: LexErrorKind::UnterminatedString,
611 pos: i,
612 });
613 }
614 };
615 out.push(Token::String(body.to_string()));
616 i = end.unwrap() + 2;
617 }
618 b'$' if i + 1 < bytes.len()
619 && (bytes[i + 1].is_ascii_alphabetic() || bytes[i + 1] == b'_') =>
620 {
621 // Tagged form: `$foo$ … $foo$`. Scan the tag
622 // ident, find the closing copy.
623 let mut j = i + 1;
624 while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
625 j += 1;
626 }
627 if j >= bytes.len() || bytes[j] != b'$' {
628 // Not a dollar-quoted string — fall through
629 // to the generic-unknown-char path.
630 let ch = input[i..].chars().next().unwrap_or('?');
631 return Err(LexError {
632 kind: LexErrorKind::UnknownChar(ch),
633 pos: i,
634 });
635 }
636 let close: alloc::vec::Vec<u8> = bytes[i..=j].to_vec();
637 let end = find_dollar_tag_end(bytes, j + 1, &close);
638 let body = match end {
639 Some(e) => &input[j + 1..e],
640 None => {
641 return Err(LexError {
642 kind: LexErrorKind::UnterminatedString,
643 pos: i,
644 });
645 }
646 };
647 out.push(Token::String(body.to_string()));
648 i = end.unwrap() + close.len();
649 }
650 // v6.1.1: `$N` parameter placeholder for the extended
651 // query protocol. PG numbers them 1..=N; we reject $0
652 // and a bare `$` not followed by a digit.
653 b'$' if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() => {
654 let mut j = i + 1;
655 let mut n: u32 = 0;
656 while j < bytes.len() && bytes[j].is_ascii_digit() {
657 n = n
658 .saturating_mul(10)
659 .saturating_add(u32::from(bytes[j] - b'0'));
660 j += 1;
661 }
662 if n == 0 || n > u32::from(u16::MAX) {
663 return Err(LexError {
664 kind: LexErrorKind::BadNumber(input[i..j].to_string()),
665 pos: i,
666 });
667 }
668 #[allow(clippy::cast_possible_truncation)]
669 out.push(Token::Placeholder(n as u16));
670 i = j;
671 }
672 _ => {
673 let ch = input[i..].chars().next().unwrap_or('?');
674 return Err(LexError {
675 kind: LexErrorKind::UnknownChar(ch),
676 pos: i,
677 });
678 }
679 }
680 }
681 out.push(Token::Eof);
682 Ok(out)
683}
684
685fn peek_eq(bytes: &[u8], i: usize, target: u8) -> bool {
686 bytes.get(i) == Some(&target)
687}
688
689/// v7.14.0 — recognise the first byte of a MySQL session/user
690/// variable name (after `@` or `@@`). PG-strict idents are ASCII
691/// letter or underscore; MySQL also allows leading digits inside
692/// quoted names but unquoted vars match the same shape.
693fn is_session_var_ident_start(b: Option<u8>) -> bool {
694 matches!(b, Some(c) if c.is_ascii_alphabetic() || c == b'_')
695}
696
697/// Continuation byte for a `@VAR`/`@@VAR` ident (after the first
698/// alphabet/underscore byte). Letters, digits, underscore, dot
699/// (MySQL allows session-scope qualifiers like
700/// `@@global.sql_mode`) and `$` (some MySQL versions accept it).
701fn is_session_var_ident_continue(b: u8) -> bool {
702 b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'$'
703}
704
705/// v7.9.27 — find the start index of the next occurrence of `tag`
706/// (e.g. `b"$$"` or `b"$foo$"`) in `bytes` starting at `from`.
707fn find_dollar_tag_end(bytes: &[u8], from: usize, tag: &[u8]) -> Option<usize> {
708 if tag.is_empty() || from > bytes.len() {
709 return None;
710 }
711 let mut i = from;
712 while i + tag.len() <= bytes.len() {
713 if &bytes[i..i + tag.len()] == tag {
714 return Some(i);
715 }
716 i += 1;
717 }
718 None
719}
720
721fn peek_pred<F: Fn(&u8) -> bool>(bytes: &[u8], i: usize, pred: F) -> bool {
722 bytes.get(i).is_some_and(pred)
723}
724
725fn single(out: &mut Vec<Token>, tok: Token, i: &mut usize) {
726 out.push(tok);
727 *i += 1;
728}
729
730/// Length-first ASCII-CI keyword lookup. Avoids allocating a
731/// lowercase `String` when the input matches a keyword; only the ident
732/// fall-through path pays for the lowercase copy.
733///
734/// Grouped by length so the outer `match` becomes a small jump table.
735/// Within a length bucket every keyword has either a unique first
736/// byte (cheap dispatch) or a small set of disambiguating
737/// trailing-byte comparisons. All comparisons are ASCII-CI (XOR
738/// 0x20 on each byte before the compare).
739fn keyword_or_ident_raw(raw: &str) -> Token {
740 let b = raw.as_bytes();
741 let tok = match b.len() {
742 2 => kw_len2(b),
743 3 => kw_len3(b),
744 4 => kw_len4(b),
745 5 => kw_len5(b),
746 6 => kw_len6(b),
747 7 => kw_len7(b),
748 8 => kw_len8(b),
749 9 => kw_len9(b),
750 10 => kw_len10(b),
751 11 => kw_len11(b),
752 12 => kw_len12(b),
753 _ => None,
754 };
755 match tok {
756 Some(t) => t,
757 // Ident fall-through: this is the only path that allocates.
758 None => Token::Ident(raw.to_ascii_lowercase()),
759 }
760}
761
762/// ASCII-CI equality on a byte slice against a lowercase literal.
763/// Letters that differ only in case satisfy `(a ^ b) == 0x20`; other
764/// mismatches set bits outside the 0x20 mask. We compare each byte
765/// against its lowercase form via `to_ascii_lowercase` for clarity;
766/// the compiler folds the loop into a tight cmov chain.
767#[inline]
768fn eq_ci(input: &[u8], lower: &[u8]) -> bool {
769 if input.len() != lower.len() {
770 return false;
771 }
772 for i in 0..lower.len() {
773 if input[i].to_ascii_lowercase() != lower[i] {
774 return false;
775 }
776 }
777 true
778}
779
780#[inline]
781fn kw_len2(b: &[u8]) -> Option<Token> {
782 // 7 keywords: as, by, in, is, on, or, to
783 if eq_ci(b, b"as") {
784 return Some(Token::As);
785 }
786 if eq_ci(b, b"by") {
787 return Some(Token::By);
788 }
789 if eq_ci(b, b"in") {
790 return Some(Token::In);
791 }
792 if eq_ci(b, b"is") {
793 return Some(Token::Is);
794 }
795 if eq_ci(b, b"on") {
796 return Some(Token::On);
797 }
798 if eq_ci(b, b"or") {
799 return Some(Token::Or);
800 }
801 if eq_ci(b, b"to") {
802 return Some(Token::To);
803 }
804 None
805}
806
807#[inline]
808fn kw_len3(b: &[u8]) -> Option<Token> {
809 // 5 keywords: all, and, asc, not, for
810 if eq_ci(b, b"for") {
811 return Some(Token::For);
812 }
813 if eq_ci(b, b"all") {
814 return Some(Token::All);
815 }
816 if eq_ci(b, b"and") {
817 return Some(Token::And);
818 }
819 if eq_ci(b, b"asc") {
820 return Some(Token::Asc);
821 }
822 if eq_ci(b, b"not") {
823 return Some(Token::Not);
824 }
825 None
826}
827
828#[inline]
829fn kw_len4(b: &[u8]) -> Option<Token> {
830 // 10 keywords: from, null, true, into, like, join, left, show, desc, drop
831 if eq_ci(b, b"from") {
832 return Some(Token::From);
833 }
834 if eq_ci(b, b"drop") {
835 return Some(Token::Drop);
836 }
837 if eq_ci(b, b"null") {
838 return Some(Token::Null);
839 }
840 if eq_ci(b, b"true") {
841 return Some(Token::True);
842 }
843 if eq_ci(b, b"into") {
844 return Some(Token::Into);
845 }
846 if eq_ci(b, b"like") {
847 return Some(Token::Like);
848 }
849 if eq_ci(b, b"join") {
850 return Some(Token::Join);
851 }
852 if eq_ci(b, b"left") {
853 return Some(Token::Left);
854 }
855 if eq_ci(b, b"show") {
856 return Some(Token::Show);
857 }
858 if eq_ci(b, b"desc") {
859 return Some(Token::Desc);
860 }
861 None
862}
863
864#[inline]
865fn kw_len5(b: &[u8]) -> Option<Token> {
866 // 12 keywords: false, where, table, index, begin, order, limit,
867 // group, union, inner, cross, outer
868 if eq_ci(b, b"false") {
869 return Some(Token::False);
870 }
871 if eq_ci(b, b"where") {
872 return Some(Token::Where);
873 }
874 if eq_ci(b, b"table") {
875 return Some(Token::Table);
876 }
877 if eq_ci(b, b"index") {
878 return Some(Token::Index);
879 }
880 if eq_ci(b, b"begin") {
881 return Some(Token::Begin);
882 }
883 if eq_ci(b, b"order") {
884 return Some(Token::Order);
885 }
886 if eq_ci(b, b"limit") {
887 return Some(Token::Limit);
888 }
889 if eq_ci(b, b"group") {
890 return Some(Token::Group);
891 }
892 if eq_ci(b, b"union") {
893 return Some(Token::Union);
894 }
895 if eq_ci(b, b"inner") {
896 return Some(Token::Inner);
897 }
898 if eq_ci(b, b"cross") {
899 return Some(Token::Cross);
900 }
901 if eq_ci(b, b"outer") {
902 return Some(Token::Outer);
903 }
904 None
905}
906
907#[inline]
908fn kw_len6(b: &[u8]) -> Option<Token> {
909 // 9 keywords: select, create, insert, values, commit, having, offset, tables, except
910 if eq_ci(b, b"select") {
911 return Some(Token::Select);
912 }
913 if eq_ci(b, b"tables") {
914 return Some(Token::Tables);
915 }
916 if eq_ci(b, b"except") {
917 return Some(Token::Except);
918 }
919 if eq_ci(b, b"create") {
920 return Some(Token::Create);
921 }
922 if eq_ci(b, b"insert") {
923 return Some(Token::Insert);
924 }
925 if eq_ci(b, b"values") {
926 return Some(Token::Values);
927 }
928 if eq_ci(b, b"commit") {
929 return Some(Token::Commit);
930 }
931 if eq_ci(b, b"having") {
932 return Some(Token::Having);
933 }
934 if eq_ci(b, b"offset") {
935 return Some(Token::Offset);
936 }
937 None
938}
939
940#[inline]
941fn kw_len7(b: &[u8]) -> Option<Token> {
942 // 4 keywords: between, default, release, extract
943 if eq_ci(b, b"between") {
944 return Some(Token::Between);
945 }
946 if eq_ci(b, b"default") {
947 return Some(Token::Default);
948 }
949 if eq_ci(b, b"release") {
950 return Some(Token::Release);
951 }
952 if eq_ci(b, b"extract") {
953 return Some(Token::Extract);
954 }
955 None
956}
957
958#[inline]
959fn kw_len8(b: &[u8]) -> Option<Token> {
960 // 3 keywords: rollback, distinct, interval
961 if eq_ci(b, b"rollback") {
962 return Some(Token::Rollback);
963 }
964 if eq_ci(b, b"distinct") {
965 return Some(Token::Distinct);
966 }
967 if eq_ci(b, b"interval") {
968 return Some(Token::Interval);
969 }
970 None
971}
972
973#[inline]
974fn kw_len9(b: &[u8]) -> Option<Token> {
975 // 2 keywords: savepoint, partition
976 if eq_ci(b, b"savepoint") {
977 return Some(Token::Savepoint);
978 }
979 if eq_ci(b, b"partition") {
980 return Some(Token::Partition);
981 }
982 None
983}
984
985#[inline]
986fn kw_len10(b: &[u8]) -> Option<Token> {
987 // 1 keyword: connection
988 if eq_ci(b, b"connection") {
989 return Some(Token::Connection);
990 }
991 None
992}
993
994#[inline]
995fn kw_len11(b: &[u8]) -> Option<Token> {
996 // 1 keyword: publication
997 if eq_ci(b, b"publication") {
998 return Some(Token::Publication);
999 }
1000 None
1001}
1002
1003#[inline]
1004fn kw_len12(b: &[u8]) -> Option<Token> {
1005 // 1 keyword: subscription
1006 if eq_ci(b, b"subscription") {
1007 return Some(Token::Subscription);
1008 }
1009 None
1010}
1011
1012/// Lex a `'...'` string literal or `"..."` quoted identifier. The opening
1013/// quote sits at `input[start]`; `quote` is its byte value. `is_ident` selects
1014/// the resulting token shape.
1015///
1016/// PG-style doubling escapes the quote: `''` inside `'...'` is a literal `'`,
1017/// same for `""` inside `"..."`.
1018fn lex_quoted(
1019 input: &str,
1020 start: usize,
1021 quote: u8,
1022 is_ident: bool,
1023) -> Result<(Token, usize), LexError> {
1024 let bytes = input.as_bytes();
1025 let mut i = start + 1;
1026 let mut s = String::new();
1027 loop {
1028 if i >= bytes.len() {
1029 return Err(LexError {
1030 kind: if is_ident {
1031 LexErrorKind::UnterminatedQuotedIdent
1032 } else {
1033 LexErrorKind::UnterminatedString
1034 },
1035 pos: start,
1036 });
1037 }
1038 if bytes[i] == quote {
1039 if peek_eq(bytes, i + 1, quote) {
1040 s.push(quote as char);
1041 i += 2;
1042 } else {
1043 i += 1;
1044 break;
1045 }
1046 } else {
1047 let ch = input[i..].chars().next().expect("non-empty UTF-8 boundary");
1048 s.push(ch);
1049 i += ch.len_utf8();
1050 }
1051 }
1052 let tok = if is_ident {
1053 Token::QuotedIdent(s)
1054 } else {
1055 Token::String(s)
1056 };
1057 Ok((tok, i - start))
1058}
1059
1060/// v7.18 — Lex a PG escape-string literal `E'...'`. `start` points
1061/// at the opening single quote (the `E` was matched by the caller
1062/// and is NOT part of `start`'s offset semantics — the consumed
1063/// count returned excludes the `E`, which the caller adds).
1064///
1065/// Recognised escape sequences:
1066/// \\ \' \" — literal backslash / quote
1067/// \n \r \t \b \f — standard whitespace controls
1068/// \0 — NUL
1069/// \xHH — single hex byte (1–2 hex digits)
1070/// \NNN — octal byte (1–3 octal digits)
1071/// Any other `\X` decodes to the literal byte `X` (PG warns; SPG
1072/// follows the lenient behaviour pg_dump output relies on).
1073///
1074/// Doubled `''` is still a literal `'` (same as the non-E form).
1075fn lex_escape_string(input: &str, start: usize) -> Result<(Token, usize), LexError> {
1076 let bytes = input.as_bytes();
1077 debug_assert_eq!(bytes[start], b'\'');
1078 let mut i = start + 1;
1079 let mut s = String::new();
1080 loop {
1081 if i >= bytes.len() {
1082 return Err(LexError {
1083 kind: LexErrorKind::UnterminatedString,
1084 pos: start,
1085 });
1086 }
1087 let b = bytes[i];
1088 if b == b'\'' {
1089 if peek_eq(bytes, i + 1, b'\'') {
1090 s.push('\'');
1091 i += 2;
1092 continue;
1093 }
1094 i += 1;
1095 break;
1096 }
1097 if b == b'\\' && i + 1 < bytes.len() {
1098 let n = bytes[i + 1];
1099 match n {
1100 b'\\' => {
1101 s.push('\\');
1102 i += 2;
1103 }
1104 b'\'' => {
1105 s.push('\'');
1106 i += 2;
1107 }
1108 b'"' => {
1109 s.push('"');
1110 i += 2;
1111 }
1112 b'n' => {
1113 s.push('\n');
1114 i += 2;
1115 }
1116 b'r' => {
1117 s.push('\r');
1118 i += 2;
1119 }
1120 b't' => {
1121 s.push('\t');
1122 i += 2;
1123 }
1124 b'b' => {
1125 s.push('\u{0008}');
1126 i += 2;
1127 }
1128 b'f' => {
1129 s.push('\u{000C}');
1130 i += 2;
1131 }
1132 b'0' if i + 2 >= bytes.len() || !bytes[i + 2].is_ascii_digit() => {
1133 s.push('\0');
1134 i += 2;
1135 }
1136 b'x' => {
1137 // \xH or \xHH — single byte by hex.
1138 let h1 = bytes.get(i + 2).copied();
1139 let h2 = bytes.get(i + 3).copied();
1140 let n1 = h1.and_then(hex_digit_value);
1141 let n2 = h2.and_then(hex_digit_value);
1142 match (n1, n2) {
1143 (Some(a), Some(b2)) => {
1144 s.push((((a << 4) | b2) as u8) as char);
1145 i += 4;
1146 }
1147 (Some(a), _) => {
1148 s.push((a as u8) as char);
1149 i += 3;
1150 }
1151 _ => {
1152 // \x with no hex follows — literal x.
1153 s.push('x');
1154 i += 2;
1155 }
1156 }
1157 }
1158 d if d.is_ascii_digit() && d < b'8' => {
1159 // \NNN octal — up to 3 octal digits.
1160 let mut value: u32 = u32::from(d - b'0');
1161 let mut take = 2;
1162 while take < 4 {
1163 let next = bytes.get(i + take).copied();
1164 match next {
1165 Some(c) if c.is_ascii_digit() && c < b'8' => {
1166 value = (value << 3) | u32::from(c - b'0');
1167 take += 1;
1168 }
1169 _ => break,
1170 }
1171 }
1172 if let Some(c) = char::from_u32(value) {
1173 s.push(c);
1174 } else {
1175 // Invalid Unicode — preserve as raw byte char.
1176 s.push((value & 0xFF) as u8 as char);
1177 }
1178 i += take;
1179 }
1180 other => {
1181 // Lenient fallback — same as PG with
1182 // `standard_conforming_strings = off` warning:
1183 // decode `\X` to literal `X`.
1184 s.push(other as char);
1185 i += 2;
1186 }
1187 }
1188 } else {
1189 let ch = input[i..].chars().next().expect("non-empty UTF-8 boundary");
1190 s.push(ch);
1191 i += ch.len_utf8();
1192 }
1193 }
1194 Ok((Token::String(s), i - start))
1195}
1196
1197fn hex_digit_value(b: u8) -> Option<u32> {
1198 match b {
1199 b'0'..=b'9' => Some(u32::from(b - b'0')),
1200 b'a'..=b'f' => Some(u32::from(b - b'a' + 10)),
1201 b'A'..=b'F' => Some(u32::from(b - b'A' + 10)),
1202 _ => None,
1203 }
1204}
1205
1206fn lex_number(s: &str) -> Result<(Token, usize), LexErrorKind> {
1207 let bytes = s.as_bytes();
1208 let mut i = 0usize;
1209 let mut is_float = false;
1210
1211 while i < bytes.len() && bytes[i].is_ascii_digit() {
1212 i += 1;
1213 }
1214 if i < bytes.len() && bytes[i] == b'.' {
1215 is_float = true;
1216 i += 1;
1217 while i < bytes.len() && bytes[i].is_ascii_digit() {
1218 i += 1;
1219 }
1220 }
1221 if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
1222 is_float = true;
1223 i += 1;
1224 if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
1225 i += 1;
1226 }
1227 let exp_start = i;
1228 while i < bytes.len() && bytes[i].is_ascii_digit() {
1229 i += 1;
1230 }
1231 if exp_start == i {
1232 return Err(LexErrorKind::BadNumber(s[..i].to_string()));
1233 }
1234 }
1235
1236 let lit = &s[..i];
1237 if is_float {
1238 lit.parse::<f64>()
1239 .map(|v| (Token::Float(v), i))
1240 .map_err(|_| LexErrorKind::BadNumber(lit.to_string()))
1241 } else {
1242 lit.parse::<i64>()
1243 .map(|v| (Token::Integer(v), i))
1244 .map_err(|_| LexErrorKind::BadNumber(lit.to_string()))
1245 }
1246}
1247
1248#[cfg(test)]
1249mod tests {
1250 use super::*;
1251 use alloc::vec;
1252
1253 fn lex(s: &str) -> Vec<Token> {
1254 tokenize(s).expect("lex ok")
1255 }
1256
1257 #[test]
1258 fn empty_yields_only_eof() {
1259 assert_eq!(lex(""), vec![Token::Eof]);
1260 }
1261
1262 #[test]
1263 fn whitespace_only_yields_only_eof() {
1264 assert_eq!(lex(" \t\n "), vec![Token::Eof]);
1265 }
1266
1267 #[test]
1268 fn keywords_are_case_insensitive() {
1269 assert_eq!(
1270 lex("SELECT select Select"),
1271 vec![Token::Select, Token::Select, Token::Select, Token::Eof]
1272 );
1273 }
1274
1275 #[test]
1276 fn identifiers_lowercase_ascii() {
1277 assert_eq!(
1278 lex("hello WORLD _x x1"),
1279 vec![
1280 Token::Ident("hello".into()),
1281 Token::Ident("world".into()),
1282 Token::Ident("_x".into()),
1283 Token::Ident("x1".into()),
1284 Token::Eof,
1285 ]
1286 );
1287 }
1288
1289 #[test]
1290 fn quoted_identifier_keeps_case_and_handles_embedded_quote() {
1291 assert_eq!(
1292 lex(r#""User Name" "a""b""#),
1293 vec![
1294 Token::QuotedIdent("User Name".into()),
1295 Token::QuotedIdent("a\"b".into()),
1296 Token::Eof,
1297 ]
1298 );
1299 }
1300
1301 #[test]
1302 fn integer_and_float_literals() {
1303 assert_eq!(
1304 lex("0 42 1.5 .5 1e10 2.5e-3"),
1305 vec![
1306 Token::Integer(0),
1307 Token::Integer(42),
1308 Token::Float(1.5),
1309 Token::Float(0.5),
1310 Token::Float(1e10),
1311 Token::Float(2.5e-3),
1312 Token::Eof,
1313 ]
1314 );
1315 }
1316
1317 #[test]
1318 fn negative_number_is_minus_then_integer() {
1319 // PG follows this: unary minus is a separate token, parser folds it.
1320 assert_eq!(
1321 lex("-42"),
1322 vec![Token::Minus, Token::Integer(42), Token::Eof]
1323 );
1324 }
1325
1326 #[test]
1327 fn string_literal_doubled_quote_escape() {
1328 assert_eq!(
1329 lex("'hello' 'it''s'"),
1330 vec![
1331 Token::String("hello".into()),
1332 Token::String("it's".into()),
1333 Token::Eof,
1334 ]
1335 );
1336 }
1337
1338 #[test]
1339 fn all_comparison_and_arithmetic_operators() {
1340 assert_eq!(
1341 lex("= <> != < <= > >= + - * /"),
1342 vec![
1343 Token::Eq,
1344 Token::NotEq,
1345 Token::NotEq,
1346 Token::Lt,
1347 Token::LtEq,
1348 Token::Gt,
1349 Token::GtEq,
1350 Token::Plus,
1351 Token::Minus,
1352 Token::Star,
1353 Token::Slash,
1354 Token::Eof,
1355 ]
1356 );
1357 }
1358
1359 #[test]
1360 fn punctuation() {
1361 assert_eq!(
1362 lex("( ) , ; ."),
1363 vec![
1364 Token::LParen,
1365 Token::RParen,
1366 Token::Comma,
1367 Token::Semicolon,
1368 Token::Dot,
1369 Token::Eof,
1370 ]
1371 );
1372 }
1373
1374 #[test]
1375 fn line_comment_skipped() {
1376 assert_eq!(
1377 lex("SELECT -- trailing junk\nFROM"),
1378 vec![Token::Select, Token::From, Token::Eof]
1379 );
1380 }
1381
1382 #[test]
1383 fn block_comment_skipped() {
1384 assert_eq!(
1385 lex("SELECT /* skipped */ 1"),
1386 vec![Token::Select, Token::Integer(1), Token::Eof]
1387 );
1388 }
1389
1390 #[test]
1391 fn unterminated_string_errors() {
1392 let err = tokenize("'oops").unwrap_err();
1393 assert!(matches!(err.kind, LexErrorKind::UnterminatedString));
1394 assert_eq!(err.pos, 0);
1395 }
1396
1397 #[test]
1398 fn unterminated_block_comment_errors() {
1399 let err = tokenize("/* never closed").unwrap_err();
1400 assert!(matches!(err.kind, LexErrorKind::UnterminatedBlockComment));
1401 }
1402
1403 #[test]
1404 fn unknown_char_errors() {
1405 // v7.17.0 Phase 2.6 — `@` standalone now lexes as
1406 // Token::At (mysqldump `'user'@'host'` DEFINER stitching).
1407 // Use `?` for the unknown-char regression; PG `?` operator
1408 // family is parsed as JSON ops in the prefix `?` shape
1409 // would land in lex paths; bare `?` is unknown.
1410 let err = tokenize("\x07").unwrap_err();
1411 assert!(matches!(err.kind, LexErrorKind::UnknownChar(_)));
1412 }
1413
1414 #[test]
1415 fn at_alone_lexes_as_punctuation() {
1416 // v7.17.0 Phase 2.6 — the `'user'@'host'` MySQL DEFINER
1417 // form needs `@` to lex as a standalone token.
1418 assert_eq!(
1419 lex("'u'@'h'"),
1420 vec![
1421 Token::String("u".into()),
1422 Token::At,
1423 Token::String("h".into()),
1424 Token::Eof,
1425 ]
1426 );
1427 }
1428
1429 #[test]
1430 fn dot_in_qualified_column() {
1431 assert_eq!(
1432 lex("t.col"),
1433 vec![
1434 Token::Ident("t".into()),
1435 Token::Dot,
1436 Token::Ident("col".into()),
1437 Token::Eof,
1438 ]
1439 );
1440 }
1441
1442 // --- v0.11 brackets + distance op + vector keyword --------------------
1443
1444 #[test]
1445 fn brackets_are_distinct_tokens() {
1446 assert_eq!(
1447 lex("[ ]"),
1448 vec![Token::LBracket, Token::RBracket, Token::Eof]
1449 );
1450 }
1451
1452 #[test]
1453 fn l2_distance_is_three_char_token() {
1454 assert_eq!(
1455 lex("a <-> b"),
1456 vec![
1457 Token::Ident("a".into()),
1458 Token::L2Distance,
1459 Token::Ident("b".into()),
1460 Token::Eof,
1461 ]
1462 );
1463 // Bare `<-` should NOT match L2Distance.
1464 assert_eq!(
1465 lex("a <- b"),
1466 vec![
1467 Token::Ident("a".into()),
1468 Token::Lt,
1469 Token::Minus,
1470 Token::Ident("b".into()),
1471 Token::Eof,
1472 ]
1473 );
1474 }
1475
1476 #[test]
1477 fn order_by_limit_are_keywords() {
1478 assert_eq!(
1479 lex("ORDER BY LIMIT"),
1480 vec![Token::Order, Token::By, Token::Limit, Token::Eof]
1481 );
1482 }
1483
1484 // --- v1.2: pgvector distance ops + PG cast --------------------------
1485
1486 #[test]
1487 fn inner_product_operator_3char() {
1488 assert_eq!(
1489 lex("a <#> b"),
1490 vec![
1491 Token::Ident("a".into()),
1492 Token::InnerProduct,
1493 Token::Ident("b".into()),
1494 Token::Eof,
1495 ]
1496 );
1497 }
1498
1499 #[test]
1500 fn cosine_distance_operator_3char() {
1501 assert_eq!(
1502 lex("a <=> b"),
1503 vec![
1504 Token::Ident("a".into()),
1505 Token::CosineDistance,
1506 Token::Ident("b".into()),
1507 Token::Eof,
1508 ]
1509 );
1510 // Make sure `<=` and `<>` and `<->` still lex right when `<=>` is
1511 // around (greedy match takes the longest).
1512 assert_eq!(
1513 lex("a <= b"),
1514 vec![
1515 Token::Ident("a".into()),
1516 Token::LtEq,
1517 Token::Ident("b".into()),
1518 Token::Eof,
1519 ]
1520 );
1521 }
1522
1523 #[test]
1524 fn double_colon_cast_token() {
1525 assert_eq!(
1526 lex("x::INT"),
1527 vec![
1528 Token::Ident("x".into()),
1529 Token::DoubleColon,
1530 Token::Ident("int".into()),
1531 Token::Eof,
1532 ]
1533 );
1534 }
1535
1536 #[test]
1537 fn lone_single_colon_lexes_as_colon_token() {
1538 // v7.12.4 — single `:` is now a token (PL/pgSQL surface
1539 // + tsvector external-form literal both need it). The
1540 // pre-v7.12.4 "single colon = unknown char" behaviour
1541 // was incidental.
1542 let toks = tokenize(":x").expect("colon now lexes");
1543 assert_eq!(toks[0], Token::Colon);
1544 }
1545
1546 #[test]
1547 fn colon_eq_lexes_as_assignment() {
1548 // v7.12.4 — PL/pgSQL assignment operator.
1549 let toks = tokenize("x := 1").expect("colon-eq lexes");
1550 // Tokens: Ident("x"), ColonEq, NumberLiteral
1551 assert!(matches!(toks[1], Token::ColonEq));
1552 }
1553
1554 #[test]
1555 fn pg_escape_string_double_backslash_decodes_to_single() {
1556 // v7.18 — E'\\xdeadbeef' decodes to literal `\xdeadbeef`
1557 // (10 chars: backslash + xdeadbeef). The downstream
1558 // `::bytea` cast then reads that as the PG hex-form bytea
1559 // literal. mailrs D-pre #3.
1560 let toks = tokenize(r"E'\\xdeadbeef'").expect("E-string lexes");
1561 assert_eq!(toks, vec![Token::String(r"\xdeadbeef".into()), Token::Eof]);
1562 }
1563
1564 #[test]
1565 fn pg_escape_string_supports_basic_escapes() {
1566 // \n / \t / \' / \\ — the PG standard set.
1567 let toks = tokenize(r"E'a\nb\tc\'d\\e'").expect("E-string lexes");
1568 assert_eq!(toks, vec![Token::String("a\nb\tc'd\\e".into()), Token::Eof]);
1569 }
1570
1571 #[test]
1572 fn pg_escape_string_hex_byte() {
1573 // \xHH single byte. \x41 = 'A'.
1574 let toks = tokenize(r"E'\x41B\x42'").expect("E-string lexes");
1575 assert_eq!(toks, vec![Token::String("ABB".into()), Token::Eof]);
1576 }
1577
1578 #[test]
1579 fn pg_escape_string_lowercase_e_prefix() {
1580 let toks = tokenize(r"e'hi\n'").expect("e-string lexes");
1581 assert_eq!(toks, vec![Token::String("hi\n".into()), Token::Eof]);
1582 }
1583
1584 #[test]
1585 fn pg_escape_string_doubled_quote() {
1586 // Even in E-string the doubled '' is a literal '.
1587 let toks = tokenize(r"E'it''s ok'").expect("E-string lexes");
1588 assert_eq!(toks, vec![Token::String("it's ok".into()), Token::Eof]);
1589 }
1590}