// // Lexer.swift // Metrika // // Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Copyright © 2026 Simon-Pierre Boucher. All rights reserved. // /// Tokenizer for a single ZQL command line. /// /// Numbers support decimal and exponent notation (`1.5e-3`). A `.` is lexed /// as an operator when not part of a number, which lets the grammar layer /// assemble factor-variable notation (`i.region`, `c.age#c.age`). public struct ZQLexer: Sendable { public init() {} public func tokenize(_ line: String) throws(ZQParseError) -> [ZQToken] { var tokens: [ZQToken] = [] let chars = Array(line) var i = 0 func column(_ index: Int) -> Int { index + 1 } while i < chars.count { let c = chars[i] if c.isWhitespace { i += 1; continue } // Comments: // and * (only at start of line, Stata-style) end the command. if c == "/", i + 1 < chars.count, chars[i + 1] == "/" { break } if c == "*", tokens.isEmpty { break } // String literal if c == "\"" { let start = i i += 1 var value = "" while i < chars.count, chars[i] != "\"" { value.append(chars[i]) i += 1 } guard i < chars.count else { throw ZQParseError( message: "unterminated string literal", column: column(start) ) } i += 1 tokens.append(ZQToken(kind: .string(value), column: column(start))) continue } // Number: digits, or leading dot followed by a digit if c.isNumber || (c == "." && i + 1 < chars.count && chars[i + 1].isNumber) { let start = i var text = "" while i < chars.count, chars[i].isNumber || chars[i] == "." { text.append(chars[i]) i += 1 } if i < chars.count, chars[i] == "e" || chars[i] == "E" { var j = i + 1 var exp = String(chars[i]) if j < chars.count, chars[j] == "+" || chars[j] == "-" { exp.append(chars[j]) j += 1 } if j < chars.count, chars[j].isNumber { while j < chars.count, chars[j].isNumber { exp.append(chars[j]) j += 1 } text.append(exp) i = j } } guard let value = Double(text) else { throw ZQParseError( message: "invalid numeric literal '\(text)'", column: column(start) ) } tokens.append(ZQToken(kind: .number(value), column: column(start))) continue } // Identifier: [A-Za-z_][A-Za-z0-9_]* if c.isLetter || c == "_" { let start = i var text = "" while i < chars.count, chars[i].isLetter || chars[i].isNumber || chars[i] == "_" { text.append(chars[i]) i += 1 } tokens.append(ZQToken(kind: .identifier(text), column: column(start))) continue } // Punctuation & operators let start = i func push(_ kind: ZQToken.Kind, advance: Int) { tokens.append(ZQToken(kind: kind, column: column(start))) i += advance } switch c { case ",": push(.comma, advance: 1) case ":": push(.colon, advance: 1) case "(": push(.lparen, advance: 1) case ")": push(.rparen, advance: 1) case "[": push(.lbracket, advance: 1) case "]": push(.rbracket, advance: 1) case "=": if i + 1 < chars.count, chars[i + 1] == "=" { push(.op("=="), advance: 2) } else { push(.op("="), advance: 1) } case "!": if i + 1 < chars.count, chars[i + 1] == "=" { push(.op("!="), advance: 2) } else { push(.op("!"), advance: 1) } case "~": if i + 1 < chars.count, chars[i + 1] == "=" { push(.op("!="), advance: 2) } else { throw ZQParseError(message: "unexpected character '~'", column: column(start)) } case "<": if i + 1 < chars.count, chars[i + 1] == "=" { push(.op("<="), advance: 2) } else { push(.op("<"), advance: 1) } case ">": if i + 1 < chars.count, chars[i + 1] == "=" { push(.op(">="), advance: 2) } else { push(.op(">"), advance: 1) } case "&": push(.op("&"), advance: 1) case "|": push(.op("|"), advance: 1) case "+": push(.op("+"), advance: 1) case "-": push(.op("-"), advance: 1) case "*": push(.op("*"), advance: 1) case "/": push(.op("/"), advance: 1) case "^": push(.op("^"), advance: 1) case "#": if i + 1 < chars.count, chars[i + 1] == "#" { push(.op("##"), advance: 2) } else { push(.op("#"), advance: 1) } case ".": push(.op("."), advance: 1) default: throw ZQParseError( message: "unexpected character '\(c)'", column: column(start) ) } } tokens.append(ZQToken(kind: .endOfLine, column: chars.count + 1)) return tokens } }