// Tokenizer for the W1 SQL subset. Keywords are case-insensitive;
// identifiers (bare or double-quoted, with "" escape), numbers (int32 /
// int64 / double with exponent), single-quoted strings (with '' escape),
// DATE 'yyyy-mm-dd' literals and line comments.
///|
priv enum Tok {
Ident(String)
IntLit(Int32)
Int64Lit(Int64)
DoubleLit(Double)
StrLit(String)
BoolLit(Bool)
LParen
RParen
Comma
Dot
Star
Plus
Minus
Slash
Percent
Eq
Neq
PipePipe
Lt
Le
Gt
Ge
KwSelect
KwWith
KwFrom
KwWhere
KwAs
KwAnd
KwOr
KwNot
KwBetween
KwDate
KwGroup
KwBy
KwHaving
KwOrder
KwAsc
KwDesc
KwLimit
KwCase
KwWhen
KwThen
KwElse
KwEnd
KwLike
KwIn
KwDistinct
KwJoin
KwInner
KwLeft
KwOuter
KwCross
KwOn
KwIs
KwNull
KwExtract
KwYear
KwMonth
KwDay
KwInterval
KwOver
KwPartition
Semi
Eof
} derive(Eq)
///|
priv struct Lexer {
chars : Array[Char]
mut pos : Int32
}
///|
fn Lexer::peek(self : Lexer) -> Char {
self.chars[self.pos]
}
///|
fn lex_sql(sql : String) -> Array[Tok] raise @types.SqlError {
let chars : Array[Char] = []
for ch in sql {
chars.push(ch)
}
let lx : Lexer = { chars, pos: 0, }
let toks : Array[Tok] = []
let n = chars.length()
while lx.pos < n {
let ch = lx.peek()
if ch == ' ' || ch == '\t' || ch == '\n' || ch == '\r' {
lx.pos += 1
continue
}
if ch == '-' && lx.pos + 1 < n && chars[lx.pos + 1] == '-' {
while lx.pos < n && chars[lx.pos] != '\n' {
lx.pos += 1
}
continue
}
if is_letter(ch) || ch == '_' {
let word = lx.lex_word()
toks.push(keyword_or_ident(word))
continue
}
if ch.to_int() >= 48 && ch.to_int() <= 57 {
toks.push(lx.lex_number())
continue
}
if ch == '\'' {
toks.push(lx.lex_string())
continue
}
if ch == '"' {
toks.push(lx.lex_quoted_ident())
continue
}
match ch {
'(' => {
toks.push(LParen)
lx.pos += 1
}
')' => {
toks.push(RParen)
lx.pos += 1
}
',' => {
toks.push(Comma)
lx.pos += 1
}
';' => {
toks.push(Semi)
lx.pos += 1
}
'.' => {
toks.push(Dot)
lx.pos += 1
}
'*' => {
toks.push(Star)
lx.pos += 1
}
'+' => {
toks.push(Plus)
lx.pos += 1
}
'-' => {
toks.push(Minus)
lx.pos += 1
}
'/' => {
toks.push(Slash)
lx.pos += 1
}
'%' => {
toks.push(Percent)
lx.pos += 1
}
'<' => {
lx.pos += 1
if lx.pos < n && chars[lx.pos] == '>' {
toks.push(Neq)
lx.pos += 1
} else if lx.pos < n && chars[lx.pos] == '=' {
toks.push(Le)
lx.pos += 1
} else {
toks.push(Lt)
}
}
'>' => {
lx.pos += 1
if lx.pos < n && chars[lx.pos] == '=' {
toks.push(Ge)
lx.pos += 1
} else {
toks.push(Gt)
}
}
'=' => {
toks.push(Eq)
lx.pos += 1
}
'|' =>
if lx.pos + 1 < n && chars[lx.pos + 1] == '|' {
toks.push(PipePipe)
lx.pos += 2
} else {
raise @types.SqlError::Parse("unexpected '|' (expected ||)")
}
'!' => {
lx.pos += 1
if lx.pos < n && chars[lx.pos] == '=' {
toks.push(Neq)
lx.pos += 1
} else {
raise @types.SqlError::Parse("unexpected '!' (expected <>)")
}
}
_ =>
raise @types.SqlError::Parse(
"unexpected character '\{ch}' at position \{lx.pos}",
)
}
}
toks.push(Eof)
toks
}
///|
fn is_letter(ch : Char) -> Bool {
let v = ch.to_int()
(v >= 65 && v <= 90) || (v >= 97 && v <= 122)
}
///|
fn is_digit_ch(ch : Char) -> Bool {
let v = ch.to_int()
v >= 48 && v <= 57
}
///|
fn Lexer::lex_word(self : Lexer) -> String {
let sb = StringBuilder()
let n = self.chars.length()
while self.pos < n {
let ch = self.chars[self.pos]
if is_letter(ch) || ch == '_' || is_digit_ch(ch) {
sb.write_char(ch)
self.pos += 1
} else {
break
}
}
sb.to_string()
}
///|
fn ascii_lower(word : String) -> String {
let sb = StringBuilder()
for ch in word {
let v = ch.to_int()
if v >= 65 && v <= 90 {
sb.write_char((v + 32).to_char().unwrap())
} else {
sb.write_char(ch)
}
}
sb.to_string()
}
///|
fn keyword_or_ident(word : String) -> Tok {
match ascii_lower(word) {
"select" => KwSelect
"with" => KwWith
"from" => KwFrom
"where" => KwWhere
"as" => KwAs
"and" => KwAnd
"or" => KwOr
"not" => KwNot
"between" => KwBetween
"date" => KwDate
"group" => KwGroup
"by" => KwBy
"having" => KwHaving
"order" => KwOrder
"asc" => KwAsc
"desc" => KwDesc
"limit" => KwLimit
"case" => KwCase
"when" => KwWhen
"then" => KwThen
"else" => KwElse
"end" => KwEnd
"like" => KwLike
"in" => KwIn
"distinct" => KwDistinct
"true" => BoolLit(true)
"false" => BoolLit(false)
"join" => KwJoin
"inner" => KwInner
"left" => KwLeft
"outer" => KwOuter
"cross" => KwCross
"on" => KwOn
"is" => KwIs
"null" => KwNull
"extract" => KwExtract
"interval" => KwInterval
"year" => KwYear
"month" => KwMonth
"day" => KwDay
"over" => KwOver
"partition" => KwPartition
_ => Ident(word)
}
}
///|
fn Lexer::lex_number(self : Lexer) -> Tok raise @types.SqlError {
let sb = StringBuilder()
let n = self.chars.length()
let mut is_double = false
while self.pos < n {
let ch = self.chars[self.pos]
let v = ch.to_int()
if v >= 48 && v <= 57 {
sb.write_char(ch)
self.pos += 1
} else if ch == '.' {
is_double = true
sb.write_char(ch)
self.pos += 1
} else if ch == 'e' || ch == 'E' {
is_double = true
sb.write_char(ch)
self.pos += 1
if self.pos < n &&
(self.chars[self.pos] == '-' || self.chars[self.pos] == '+') {
sb.write_char(self.chars[self.pos])
self.pos += 1
}
} else {
break
}
}
let text = sb.to_string()
if is_double {
match @types.parse_f64(text) {
Some(v) => DoubleLit(v)
None => raise @types.SqlError::Parse("bad number literal \"\{text}\"")
}
} else {
// int32 first, then int64 so big integers keep exact values, then
// float64 as the last resort (DuckDB-style literal widening)
match @types.parse_i32(text) {
Some(v) => IntLit(v)
None =>
match @types.parse_i64(text) {
Some(v) => Int64Lit(v)
None =>
match @types.parse_f64(text) {
Some(v) => DoubleLit(v)
None =>
raise @types.SqlError::Parse("bad integer literal \"\{text}\"")
}
}
}
}
}
///|
fn Lexer::lex_quoted_ident(self : Lexer) -> Tok raise @types.SqlError {
// assumes chars[self.pos] == '"'; a quoted identifier never matches a
// keyword, so "day", "left", "select" all resolve as plain names
self.pos += 1
let sb = StringBuilder()
let n = self.chars.length()
let mut closed = false
while self.pos < n {
let ch = self.chars[self.pos]
if ch == '"' {
if self.pos + 1 < n && self.chars[self.pos + 1] == '"' {
sb.write_char('"') // "" escape
self.pos += 2
} else {
self.pos += 1
closed = true
break
}
} else {
sb.write_char(ch)
self.pos += 1
}
}
if !closed {
raise @types.SqlError::Parse("unterminated quoted identifier")
}
Ident(sb.to_string())
}
///|
fn Lexer::lex_string(self : Lexer) -> Tok raise @types.SqlError {
// assumes chars[self.pos] == '\''
self.pos += 1
let sb = StringBuilder()
let n = self.chars.length()
let mut closed = false
while self.pos < n {
let ch = self.chars[self.pos]
if ch == '\'' {
if self.pos + 1 < n && self.chars[self.pos + 1] == '\'' {
sb.write_char('\'') // '' escape
self.pos += 2
} else {
self.pos += 1
closed = true
break
}
} else {
sb.write_char(ch)
self.pos += 1
}
}
if !closed {
raise @types.SqlError::Parse("unterminated string literal")
}
StrLit(sb.to_string())
}