release: WRNexusJS 0.6.0
This commit is contained in:
@@ -0,0 +1,460 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user