/** * @factory/database-engine/formula — tokenizer (TASK-0084 R1). * * Numbers (int/float/exponent `1e5`), strings (double-quoted with `\"` * escapes; single-quoted accepted for user-friendness — both produce the * same token, recorded), booleans, ISO date literals, identifiers * (case-sensitive; the spec's `[a-z][a-z0-9_]*` pattern is recorded as * insufficient — the fixed function set contains uppercase names like * `dateAdd`, so identifiers accept `[A-Za-z][A-Za-z0-9_]*`), operators, * and `//` line comments (stripped, recorded). Unknown char → PARSE error * with position (the editor's caret-annotated highlighting consumes `pos`). */ import { FormulaError } from "./types.js"; export type Token = | { kind: "number"; value: number; pos: number } | { kind: "string"; value: string; pos: number } | { kind: "date"; value: string; pos: number } | { kind: "bool"; value: boolean; pos: number } | { kind: "null"; pos: number } | { kind: "ident"; value: string; pos: number } | { kind: "op"; value: string; pos: number } | { kind: "eof"; pos: number }; // NOTE: `and` / `or` / `not` are FUNCTIONS in the formula spec (and(a,b), // not(x)) and are therefore NOT reserved keywords — they tokenize as // `ident` so the parser's ident→call dispatch handles them. `true`/`false`/ // `null` are handled explicitly below. (The spec's infix `or`/`and` operators // are unused; the closed function set is the canonical form. Recorded.) export const KEYWORDS = new Set([]); const TWO_CHAR_OPS = ["==", "!=", "<=", ">="]; const ONE_CHAR_OPS = ["+", "-", "*", "/", "%", "<", ">", "(", ")", ",", "?", ":", "[", "]"]; export function tokenize(src: string): Token[] { const tokens: Token[] = []; let i = 0; const n = src.length; while (i < n) { const c = src[i]!; if (c === " " || c === "\t" || c === "\n" || c === "\r") { i++; continue; } if (c === "/" && src[i + 1] === "/") { while (i < n && src[i] !== "\n") i++; continue; } // date literal — MUST come before the number rule: the year would // otherwise be lexed as a plain number and the dashes as operators // (YYYY-MM-DD starts with a digit, so the number rule shadows it). const dateMatch = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?/.exec(src.slice(i)); if (dateMatch) { const value = dateMatch[0]; tokens.push({ kind: "date", value, pos: i }); i += value.length; continue; } // number if (/[0-9]/.test(c) || (c === "." && i + 1 < n && /[0-9]/.test(src[i + 1]!))) { const start = i; while (i < n && /[0-9.]/.test(src[i]!)) i++; if (i < n && (src[i] === "e" || src[i] === "E")) { let j = i + 1; if (j < n && (src[j] === "+" || src[j] === "-")) j++; if (j < n && /[0-9]/.test(src[j]!)) { i = j; while (i < n && /[0-9]/.test(src[i]!)) i++; } } const raw = src.slice(start, i); const value = Number(raw); if (!Number.isFinite(value)) throw new FormulaError("PARSE", `invalid number "${raw}"`, start); tokens.push({ kind: "number", value, pos: start }); continue; } // string if (c === '"' || c === "'") { const quote = c; let j = i + 1; let out = ""; while (j < n) { const ch = src[j]!; if (ch === "\\" && j + 1 < n) { const next = src[j + 1]!; if (next === quote || next === "\\") { out += next; j += 2; continue; } throw new FormulaError("PARSE", `invalid escape "\${next}" in string`, j); } if (ch === quote) break; out += ch; j++; } if (j >= n) throw new FormulaError("PARSE", "unterminated string", i); tokens.push({ kind: "string", value: out, pos: i }); i = j + 1; continue; } // identifier / keyword if (/[A-Za-z_]/.test(c)) { const start = i; while (i < n && /[A-Za-z0-9_]/.test(src[i]!)) i++; const value = src.slice(start, i); if (value === "true") tokens.push({ kind: "bool", value: true, pos: start }); else if (value === "false") tokens.push({ kind: "bool", value: false, pos: start }); else if (value === "null") tokens.push({ kind: "null", pos: start }); else if (KEYWORDS.has(value)) tokens.push({ kind: "op", value, pos: start }); else tokens.push({ kind: "ident", value, pos: start }); continue; } // operators const two = src.slice(i, i + 2); if (TWO_CHAR_OPS.includes(two)) { tokens.push({ kind: "op", value: two, pos: i }); i += 2; continue; } if (ONE_CHAR_OPS.includes(c)) { tokens.push({ kind: "op", value: c, pos: i }); i++; continue; } throw new FormulaError("PARSE", `unexpected character "${c}"`, i); } tokens.push({ kind: "eof", pos: n }); return tokens; }