461 lines
14 KiB
TypeScript
461 lines
14 KiB
TypeScript
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;
|
|
}
|