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