import { Lexer, LexError } from "./tokenizer.ts"; export type FunctionRuntime = "legacy" | "client" | "server" | "shared"; export type StateRuntime = "shared" | "client" | "server"; export type StoreKind = "global" | "page"; export interface FunctionParameterDecl { name: string; optional: boolean; valueType?: string; default?: string; } export interface RuntimeFunctionDecl { name: string; runtime: FunctionRuntime; async: boolean; parameters: FunctionParameterDecl[]; returnType?: string; body: string; source: string; } export interface OutputDecl { name: string; payload?: { name: string; valueType: string; optional: boolean; }; } export interface StructuredImportDecl { source: string; typeOnly: boolean; defaultImport?: string; namespaceImport?: string; namedImports: Array<{ imported: string; local: string; typeOnly: boolean }>; raw: string; } export interface PersistDecl { storage: "memory" | "session" | "local"; include: string[]; version: number; migrations?: string; validation?: string; } export interface StoreLifecycleDecl { serverInit?: string; clientInit?: string; hydrate?: string; dispose?: string; } function splitTopLevel(input: string, separator = ","): string[] { const parts: string[] = []; let start = 0; let quote: string | null = null; let angle = 0; let square = 0; let brace = 0; let paren = 0; for (let i = 0; i < input.length; i++) { const c = input[i]!; if (quote) { if (c === "\\") i++; else if (c === quote) quote = null; continue; } if (c === '"' || c === "'" || c === "`") { quote = c; continue; } if (c === "<") angle++; else if (c === ">" && angle > 0) angle--; else if (c === "[") square++; else if (c === "]" && square > 0) square--; else if (c === "{") brace++; else if (c === "}" && brace > 0) brace--; else if (c === "(") paren++; else if (c === ")" && paren > 0) paren--; else if (c === separator && angle === 0 && square === 0 && brace === 0 && paren === 0) { parts.push(input.slice(start, i).trim()); start = i + 1; } } const tail = input.slice(start).trim(); if (tail) parts.push(tail); return parts; } function findTopLevelChar(input: string, wanted: string): number { let quote: string | null = null; let angle = 0; let square = 0; let brace = 0; let paren = 0; for (let i = 0; i < input.length; i++) { const c = input[i]!; if (quote) { if (c === "\\") i++; else if (c === quote) quote = null; continue; } if (c === '"' || c === "'" || c === "`") { quote = c; continue; } if (c === "<") angle++; else if (c === ">" && angle > 0) angle--; else if (c === "[") square++; else if (c === "]" && square > 0) square--; else if (c === "{") brace++; else if (c === "}" && brace > 0) brace--; else if (c === "(") paren++; else if (c === ")" && paren > 0) paren--; if (c === wanted && angle === 0 && square === 0 && brace === 0 && paren === 0) return i; } return -1; } function parseParameters(source: string): FunctionParameterDecl[] { return splitTopLevel(source) .filter(Boolean) .map((entry) => { const eq = findTopLevelChar(entry, "="); const declaration = (eq >= 0 ? entry.slice(0, eq) : entry).trim(); const defaultValue = eq >= 0 ? entry.slice(eq + 1).trim() : undefined; const colon = findTopLevelChar(declaration, ":"); const rawName = (colon >= 0 ? declaration.slice(0, colon) : declaration).trim(); const optional = rawName.endsWith("?"); const name = optional ? rawName.slice(0, -1).trim() : rawName; const valueType = colon >= 0 ? declaration.slice(colon + 1).trim() : undefined; return { name, optional, ...(valueType ? { valueType } : {}), ...(defaultValue ? { default: defaultValue } : {}), }; }); } function skipTrivia(source: string, start: number): number { let i = start; while (i < source.length) { if (/\s/.test(source[i]!)) { i++; continue; } if (source.startsWith("//", i)) { const end = source.indexOf("\n", i + 2); i = end < 0 ? source.length : end + 1; continue; } if (source.startsWith("/*", i)) { const end = source.indexOf("*/", i + 2); i = end < 0 ? source.length : end + 2; continue; } break; } return i; } function readWord(source: string, start: number): { word: string; end: number } | null { const match = /^[A-Za-z_$][\w$]*/.exec(source.slice(start)); return match ? { word: match[0], end: start + match[0].length } : null; } function readBalanced( source: string, start: number, open: string, close: string, ): { inner: string; end: number } { if (source[start] !== open) throw new Error(`Expected '${open}' at offset ${start}`); let depth = 0; let quote: string | null = null; for (let i = start; i < source.length; i++) { const c = source[i]!; if (quote) { if (c === "\\") i++; else if (c === quote) quote = null; continue; } if (c === '"' || c === "'" || c === "`") { quote = c; continue; } if (c === open) depth++; else if (c === close && --depth === 0) return { inner: source.slice(start + 1, i), end: i + 1 }; } throw new Error(`Unbalanced '${open}${close}' starting at offset ${start}`); } export function parseRuntimeFunctions(source: string): RuntimeFunctionDecl[] { const declarations: RuntimeFunctionDecl[] = []; let i = 0; while (i < source.length) { i = skipTrivia(source, i); const start = i; let token = readWord(source, i); if (!token) { i++; continue; } let runtime: FunctionRuntime = "legacy"; if (["client", "server", "shared"].includes(token.word)) { runtime = token.word as FunctionRuntime; i = skipTrivia(source, token.end); token = readWord(source, i); if (!token) continue; } let isAsync = false; if (token.word === "async") { isAsync = true; i = skipTrivia(source, token.end); token = readWord(source, i); if (!token) continue; } if (token.word !== "function") { i = token.end; continue; } i = skipTrivia(source, token.end); const nameToken = readWord(source, i); if (!nameToken) throw new Error(`Expected function name at offset ${i}`); const name = nameToken.word; i = skipTrivia(source, nameToken.end); const params = readBalanced(source, i, "(", ")"); i = skipTrivia(source, params.end); let returnType: string | undefined; if (source[i] === ":") { i++; const typeStart = i; let quote: string | null = null; let angle = 0; let square = 0; let paren = 0; while (i < source.length) { const c = source[i]!; if (quote) { if (c === "\\") i++; else if (c === quote) quote = null; i++; continue; } if (c === '"' || c === "'" || c === "`") quote = c; else if (c === "<") angle++; else if (c === ">" && angle > 0) angle--; else if (c === "[") square++; else if (c === "]" && square > 0) square--; else if (c === "(") paren++; else if (c === ")" && paren > 0) paren--; else if (c === "{" && angle === 0 && square === 0 && paren === 0) break; i++; } returnType = source.slice(typeStart, i).trim(); } i = skipTrivia(source, i); const body = readBalanced(source, i, "{", "}"); i = body.end; declarations.push({ name, runtime, async: isAsync, parameters: parseParameters(params.inner), ...(returnType ? { returnType } : {}), body: body.inner, source: source.slice(start, body.end).trim(), }); } return declarations; } export function stripRuntimeFunctionModifiers(source: string, include: FunctionRuntime[]): string { const allowed = new Set(include); return parseRuntimeFunctions(source) .filter((entry) => allowed.has(entry.runtime)) .map((entry) => { const params = entry.parameters .map( (param) => `${param.name}${param.optional ? "?" : ""}${param.valueType ? `: ${param.valueType}` : ""}${param.default ? ` = ${param.default}` : ""}`, ) .join(", "); return `${entry.async ? "async " : ""}function ${entry.name}(${params})${entry.returnType ? `: ${entry.returnType}` : ""} {${entry.body}}`; }) .join("\n\n"); } export function parseOutputs(source: string): OutputDecl[] { const out: OutputDecl[] = []; let i = 0; while (i < source.length) { i = skipTrivia(source, i); if (i >= source.length) break; const nameToken = readWord(source, i); if (!nameToken) throw new Error(`Expected output name at offset ${i}`); i = skipTrivia(source, nameToken.end); const args = readBalanced(source, i, "(", ")"); i = args.end; const parameters = parseParameters(args.inner); if (parameters.length > 1) throw new Error(`Output '${nameToken.word}' accepts zero or one payload`); const payload = parameters[0]; if (payload && !payload.valueType) throw new Error(`Output '${nameToken.word}' payload requires a type`); out.push({ name: nameToken.word, ...(payload ? { payload: { name: payload.name, valueType: payload.valueType!, optional: payload.optional, }, } : {}), }); } return out; } export function parseStructuredImports(imports: string[]): StructuredImportDecl[] { return imports.map((raw) => { const sourceMatch = /\sfrom\s+["']([^"']+)["']|^import\s+["']([^"']+)["']/.exec(raw); const source = sourceMatch?.[1] ?? sourceMatch?.[2] ?? ""; const typeOnly = /^import\s+type\b/.test(raw); const clause = raw .replace(/^import\s+(?:type\s+)?/, "") .replace(/\s+from\s+["'][^"']+["']\s*;?$/, "") .trim(); const declaration: StructuredImportDecl = { source, typeOnly, namedImports: [], raw }; if (!clause || clause.startsWith('"') || clause.startsWith("'")) return declaration; if (clause.startsWith("*")) { declaration.namespaceImport = /\*\s+as\s+([A-Za-z_$][\w$]*)/.exec(clause)?.[1]; return declaration; } let rest = clause; if (!rest.startsWith("{")) { const comma = findTopLevelChar(rest, ","); declaration.defaultImport = (comma < 0 ? rest : rest.slice(0, comma)).trim(); rest = comma < 0 ? "" : rest.slice(comma + 1).trim(); } const named = /^\{([\s\S]*)\}$/.exec(rest)?.[1]; if (named !== undefined) { declaration.namedImports = splitTopLevel(named).map((item) => { const localTypeOnly = /^type\s+/.test(item); const cleaned = item.replace(/^type\s+/, "").trim(); const [imported, local] = cleaned.split(/\s+as\s+/); return { imported: imported!.trim(), local: (local ?? imported)!.trim(), typeOnly: typeOnly || localTypeOnly, }; }); } return declaration; }); } export function parseStateDeclarations( source: string, runtime: StateRuntime, ): Array<{ name: string; valueType?: string; expr: string; runtime: StateRuntime }> { const lx = new Lexer(source); const out: Array<{ name: string; valueType?: string; expr: string; runtime: StateRuntime }> = []; while (lx.peek().type !== "eof") { const nameToken = lx.next(); if (nameToken.type !== "ident") throw new LexError(`Expected a state name at offset ${nameToken.pos}`); let valueType: string | undefined; if (lx.peek().type === "colon") { lx.next(); const annotation = lx.readTypeAnnotation(); valueType = annotation.type; if (!annotation.hasDefault) throw new LexError(`State '${nameToken.value}' requires an initializer`); } else { const eq = lx.next(); if (eq.type !== "eq") throw new LexError(`Expected '=' after state '${nameToken.value}' at offset ${eq.pos}`); } out.push({ name: nameToken.value, ...(valueType ? { valueType } : {}), expr: lx.readPropInitializer(), runtime, }); } return out; } export function parseComputedDeclarations( source: string, ): Array<{ name: string; valueType?: string; expr: string }> { const lx = new Lexer(source); const out: Array<{ name: string; valueType?: string; expr: string }> = []; while (lx.peek().type !== "eof") { const nameToken = lx.next(); if (nameToken.type !== "ident") throw new LexError(`Expected a computed name at offset ${nameToken.pos}`); let valueType: string | undefined; if (lx.peek().type === "colon") { lx.next(); const annotation = lx.readTypeAnnotation(); valueType = annotation.type; if (!annotation.hasDefault) throw new LexError(`Computed '${nameToken.value}' requires an expression`); } else { const eq = lx.next(); if (eq.type !== "eq") throw new LexError(`Expected '=' after computed '${nameToken.value}' at offset ${eq.pos}`); } out.push({ name: nameToken.value, ...(valueType ? { valueType } : {}), expr: lx.readPropInitializer(), }); } return out; } function nestedBlock(source: string, name: string): string | undefined { const match = new RegExp(`\\b${name}\\s*\\{`).exec(source); if (!match) return undefined; const brace = source.indexOf("{", match.index); return readBalanced(source, brace, "{", "}").inner.trim() || undefined; } export function parsePersist(source: string): PersistDecl { const storage = /\bstorage\s*=\s*["'](memory|session|local)["']/.exec(source)?.[1] as PersistDecl["storage"] | undefined; const includeRaw = /\binclude\s*=\s*\[([\s\S]*?)\]/.exec(source)?.[1] ?? ""; const include = Array.from(includeRaw.matchAll(/["']([^"']+)["']/g), (match) => match[1]!); const version = Number(/\bversion\s*=\s*(\d+)/.exec(source)?.[1] ?? "1"); const migrations = nestedBlock(source, "migrations"); const validation = nestedBlock(source, "validate"); return { storage: storage ?? "memory", include, version, ...(migrations ? { migrations } : {}), ...(validation ? { validation } : {}), }; } export function parseStoreLifecycle(source: string): StoreLifecycleDecl { const out: StoreLifecycleDecl = {}; for (const hook of ["serverInit", "clientInit", "hydrate", "dispose"] as const) { const start = new RegExp(`\\b${hook}\\s*\\{`).exec(source); if (!start) continue; const brace = source.indexOf("{", start.index); out[hook] = readBalanced(source, brace, "{", "}").inner; } return out; }