diff --git a/packages/cli/src/migrations/mode-functions.ts b/packages/cli/src/migrations/mode-functions.ts new file mode 100644 index 00000000..31f8c2e3 --- /dev/null +++ b/packages/cli/src/migrations/mode-functions.ts @@ -0,0 +1,274 @@ +/** + * Migration: move mode-scoped helpers out of legacy `ssr { functions { … } }` + * / `client { functions { … } }` blocks into the page-level `functions { }` + * block, tagged `shared`. + * + * IMPORTANT: this is a text-level transform, not a parse-and-rewrite, for the + * same reason as `apis-block.ts`: the `@wrnexus/syntax` parser deliberately + * THROWS a `ParseError` on `ssr { … }` / `client { … }` data blocks — that + * legacy syntax is exactly what this migration exists to read, so it cannot + * be reached by parsing first. Instead this scans the source with the same + * balanced-brace technique used throughout `update.ts` (`findMatching`), and + * only parses the RESULT, as a validity check. + */ + +import { parse } from "@wrnexus/syntax"; + +/** Balanced-brace scan: returns the index of the brace matching `open`, or -1. */ +function findMatching(source: string, open: number, openChar = "{", closeChar = "}"): number { + let depth = 0; + let quote = ""; + for (let index = open; index < source.length; index++) { + const char = source[index]!; + if (quote) { + if (char === "\\") index++; + else if (char === quote) quote = ""; + continue; + } + if (char === '"' || char === "'" || char === "`") quote = char; + else if (char === openChar) depth++; + else if (char === closeChar && --depth === 0) return index; + } + return -1; +} + +interface ModeBlock { + mode: "ssr" | "client"; + /** Start of the `ssr`/`client` keyword. */ + start: number; + /** One past the block's closing `}`. */ + end: number; + bodyStart: number; + bodyEnd: number; +} + +/** Find page-level `ssr { … }` / `client { … }` data blocks (not `state`/hydrate forms). */ +function findModeBlocks(source: string): ModeBlock[] { + const blocks: ModeBlock[] = []; + const header = /\b(ssr|client)\s*\{/g; + let match: RegExpExecArray | null; + while ((match = header.exec(source))) { + const open = match.index + match[0].length - 1; + const close = findMatching(source, open); + if (close < 0) { + throw new Error(`unterminated "${match[1]} {" block at offset ${match.index}`); + } + blocks.push({ + mode: match[1] as "ssr" | "client", + start: match.index, + end: close + 1, + bodyStart: open + 1, + bodyEnd: close, + }); + header.lastIndex = close + 1; + } + return blocks; +} + +interface FunctionsSubBlock { + /** Offsets relative to the mode block's body. */ + localStart: number; + localEnd: number; + bodyStart: number; + bodyEnd: number; +} + +/** Find `functions { … }` sub-blocks inside a mode-block body. */ +function findFunctionsSubBlocks(body: string): FunctionsSubBlock[] { + const blocks: FunctionsSubBlock[] = []; + const header = /\bfunctions\s*\{/g; + let match: RegExpExecArray | null; + while ((match = header.exec(body))) { + const open = match.index + match[0].length - 1; + const close = findMatching(body, open); + if (close < 0) { + throw new Error(`unterminated "functions {" block at offset ${match.index}`); + } + blocks.push({ + localStart: match.index, + localEnd: close + 1, + bodyStart: open + 1, + bodyEnd: close, + }); + header.lastIndex = close + 1; + } + return blocks; +} + +interface FunctionEntry { + name: string; + /** `function name(...) { ... }` text, with any leading runtime keyword stripped. */ + text: string; +} + +/** Find `[client|server|shared] function (...) { ... }` entries inside a functions-block body. */ +function findFunctionEntries(body: string): FunctionEntry[] { + const entries: FunctionEntry[] = []; + const header = /\b(?:(?:client|server|shared)\s+)?function\s+([A-Za-z_$][\w$]*)\s*\(/g; + let match: RegExpExecArray | null; + while ((match = header.exec(body))) { + const parenOpen = match.index + match[0].length - 1; + const parenClose = findMatching(body, parenOpen, "(", ")"); + if (parenClose < 0) { + throw new Error(`unterminated function parameter list at offset ${match.index}`); + } + const braceOpen = body.indexOf("{", parenClose + 1); + if (braceOpen < 0) { + throw new Error(`unterminated function body at offset ${match.index}`); + } + const braceClose = findMatching(body, braceOpen); + if (braceClose < 0) { + throw new Error(`unterminated function body at offset ${match.index}`); + } + const localStart = match.index; + const localEnd = braceClose + 1; + const text = body.slice(localStart, localEnd).replace(/^(client|server|shared)\s+/, ""); + entries.push({ name: match[1]!, text }); + header.lastIndex = localEnd; + } + return entries; +} + +interface Edit { + start: number; + end: number; + replacement: string; +} + +function applyEdits(source: string, edits: Edit[]): string { + const sorted = [...edits].sort((a, b) => a.start - b.start); + let output = ""; + let cursor = 0; + for (const edit of sorted) { + output += source.slice(cursor, edit.start) + edit.replacement; + cursor = edit.end; + } + output += source.slice(cursor); + return output; +} + +export function migrateModeFunctions( + source: string, +): { source: string; changed: boolean } | { skip: string } { + const blocks = findModeBlocks(source); + if (blocks.length === 0) return { source, changed: false }; + + const allEntries: FunctionEntry[] = []; + const nameMode = new Map(); + const perBlockFuncBlocks = new Map(); + + for (const block of blocks) { + const body = source.slice(block.bodyStart, block.bodyEnd); + const funcBlocks = findFunctionsSubBlocks(body); + if (funcBlocks.length === 0) continue; + + for (const funcBlock of funcBlocks) { + const funcBody = body.slice(funcBlock.bodyStart, funcBlock.bodyEnd); + for (const entry of findFunctionEntries(funcBody)) { + const existingMode = nameMode.get(entry.name); + if (existingMode && existingMode !== block.mode) { + return { + skip: `function '${entry.name}' is declared in both ssr and client — merge them by hand before this migration can run`, + }; + } + nameMode.set(entry.name, block.mode); + allEntries.push(entry); + } + } + perBlockFuncBlocks.set(block, funcBlocks); + } + + if (allEntries.length === 0) return { source, changed: false }; + + // Locate an existing page-level `functions { }` block, i.e. one that is not + // inside any ssr/client mode block. + const insideAMode = (index: number) => + blocks.some((block) => index >= block.start && index < block.end); + + let existingFunctions: { bodyStart: number; bodyEnd: number } | null = null; + { + const header = /\bfunctions\s*\{/g; + let match: RegExpExecArray | null; + while ((match = header.exec(source))) { + if (insideAMode(match.index)) continue; + const open = match.index + match[0].length - 1; + const close = findMatching(source, open); + if (close < 0) throw new Error(`unterminated "functions {" block at offset ${match.index}`); + existingFunctions = { bodyStart: open + 1, bodyEnd: close }; + break; + } + } + + if (existingFunctions) { + const existingBody = source.slice(existingFunctions.bodyStart, existingFunctions.bodyEnd); + for (const entry of allEntries) { + if (new RegExp(`\\bfunction\\s+${entry.name}\\b`).test(existingBody)) { + return { + skip: `'${entry.name}' already exists in the page-level functions block`, + }; + } + } + } + + const designatedBlock = blocks.find((block) => perBlockFuncBlocks.has(block)); + const edits: Edit[] = []; + // True when a mode block keeps non-function content (e.g. `api` entries) + // after its functions are extracted. The grammar rejects any `ssr {}` / + // `client {}` wrapper outright, regardless of what's inside, so such a + // leftover wrapper cannot parse until move-api-blocks removes it in the + // same `wrnexus update` run. Validating with `parse` here would therefore + // always fail on a case this migration is specifically meant to unblock. + let leavesModeWrapper = false; + + for (const block of blocks) { + const funcBlocks = perBlockFuncBlocks.get(block); + if (!funcBlocks) continue; // block had no functions sub-block; leave it untouched entirely + + const body = source.slice(block.bodyStart, block.bodyEnd); + let remainder = body; + for (const funcBlock of [...funcBlocks].reverse()) { + remainder = remainder.slice(0, funcBlock.localStart) + remainder.slice(funcBlock.localEnd); + } + const isEmpty = remainder.trim() === ""; + if (!isEmpty) leavesModeWrapper = true; + + if (block === designatedBlock && !existingFunctions) { + const funcsBody = allEntries.map((entry) => ` shared ${entry.text}`).join("\n\n"); + const funcsBlockText = `functions {\n${funcsBody}\n }`; + const replacement = isEmpty + ? funcsBlockText + : `${funcsBlockText}\n\n ${block.mode} {${remainder}}`; + edits.push({ start: block.start, end: block.end, replacement }); + } else { + const replacement = isEmpty ? "" : `${block.mode} {${remainder}}`; + edits.push({ start: block.start, end: block.end, replacement }); + } + } + + if (existingFunctions) { + const existingBody = source.slice(existingFunctions.bodyStart, existingFunctions.bodyEnd); + const additions = allEntries.map((entry) => ` shared ${entry.text}`).join("\n\n"); + const mergedBody = `${existingBody.replace(/\s*$/, "")}\n\n${additions}\n `; + edits.push({ + start: existingFunctions.bodyStart, + end: existingFunctions.bodyEnd, + replacement: mergedBody, + }); + } + + let after = applyEdits(source, edits); + after = after.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n"); + + if (after === source) return { source, changed: false }; + + if (!leavesModeWrapper) { + try { + parse(after); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`mode-functions migration produced source that failed to parse: ${message}`); + } + } + + return { source: after, changed: true }; +} diff --git a/packages/cli/src/update.ts b/packages/cli/src/update.ts index 342ba661..57aa5b23 100644 --- a/packages/cli/src/update.ts +++ b/packages/cli/src/update.ts @@ -35,6 +35,7 @@ import { formatWrn, parse } from "@wrnexus/syntax"; import { AI_GUIDE, CLAUDE_MD } from "./ai-guide.ts"; import { inspectProject, type DoctorCheck } from "./doctor.ts"; import { migrateApisBlock } from "./migrations/apis-block.ts"; +import { migrateModeFunctions } from "./migrations/mode-functions.ts"; import { generateApplicationTypes } from "./types.ts"; /** The version of the CLI currently running (its own package.json). */ @@ -869,6 +870,43 @@ const MIGRATIONS: Migration[] = [ if (!ctx.dryRun) writeFileSync(file, after, "utf8"); }, }, + { + version: "0.9.0", + id: "move-mode-functions", + description: + "Move mode-scoped helpers out of legacy ssr/client functions blocks into a page-level shared functions block", + apply(ctx) { + const appDirectory = join(ctx.appRoot, "app"); + if (!existsSync(appDirectory)) return; + + for (const file of walkProjectFiles(appDirectory, ".wrn")) { + const relativeFile = relative(ctx.appRoot, file).replace(/\\/g, "/"); + const before = readFileSync(file, "utf8"); + + let result: ReturnType; + try { + result = migrateModeFunctions(before); + } catch (error) { + const message = error instanceof Error ? error.message.split("\n", 1)[0] : String(error); + ctx.report.parseFailures.push(`${relativeFile}: ${message}`); + ctx.log(`! ${relativeFile}: left unchanged because the mode-functions migration failed`); + continue; + } + + if ("skip" in result) { + ctx.report.needsReview.push(`${relativeFile}: ${result.skip}`); + continue; + } + if (!result.changed) continue; + + ctx.report.changedAutomatically.push( + `${relativeFile}: moved mode-scoped helpers into functions { shared … }`, + ); + ctx.log(`~ ${relativeFile}: moved mode-scoped helpers into functions { shared … }`); + if (!ctx.dryRun) writeFileSync(file, result.source, "utf8"); + } + }, + }, { version: "0.9.0", id: "move-api-blocks", diff --git a/packages/cli/test/migrate-mode-functions.test.ts b/packages/cli/test/migrate-mode-functions.test.ts new file mode 100644 index 00000000..e15dc885 --- /dev/null +++ b/packages/cli/test/migrate-mode-functions.test.ts @@ -0,0 +1,132 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { migrateModeFunctions } from "../src/migrations/mode-functions.ts"; +import { updateApp } from "../src/update.ts"; + +const SOURCE = `page Hello { + ssr { + functions { + function userNames(users) { + return users.map((user) => user.name).join(", ") + } + } + } + + view {
x
} +} +`; + +test("a mode helper becomes a shared function", () => { + const result = migrateModeFunctions(SOURCE) as { source: string; changed: boolean }; + + expect(result.changed).toBe(true); + expect(result.source).toContain("shared function userNames"); + expect(result.source).not.toContain("ssr {"); +}); + +test("running it again changes nothing", () => { + const once = (migrateModeFunctions(SOURCE) as { source: string }).source; + const twice = migrateModeFunctions(once) as { changed: boolean; source: string }; + + expect(twice.changed).toBe(false); + expect(twice.source).toBe(once); +}); + +test("a name that already exists at page level is skipped with a reason", () => { + const clash = `page P { + functions { shared function userNames() { return "" } } + ssr { functions { function userNames(users) { return "" } } } + view {
x
} +} +`; + const result = migrateModeFunctions(clash) as { skip: string }; + + expect(result.skip).toContain("userNames"); +}); + +test("a helper body containing a brace inside a string literal survives", () => { + const source = `page Hello { + ssr { + functions { + function label(user) { + return user.name + " {tag}" + } + } + } + + view {
x
} +} +`; + const result = migrateModeFunctions(source) as { source: string; changed: boolean }; + + expect(result.changed).toBe(true); + expect(result.source).toContain("shared function label"); + expect(result.source).toContain('" {tag}"'); +}); + +test("both ssr and client declaring the same helper name is skipped with a reason", () => { + const clash = `page P { + ssr { functions { function helper() { return 1 } } } + client { functions { function helper() { return 2 } } } + view {
x
} +} +`; + const result = migrateModeFunctions(clash) as { skip: string }; + + expect(result.skip).toContain("helper"); +}); + +const roots: string[] = []; +afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function project(): string { + const root = mkdtempSync(join(tmpdir(), "wrnexus-migrate-mode-functions-")); + roots.push(root); + mkdirSync(join(root, "app"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + name: "mode-functions-migrate-app", + dependencies: { "@wrnexus/core": "^0.8.0" }, + wrnexus: { version: "0.8.0" }, + }), + ); + return root; +} + +test("one update run fully migrates a page mixing api entries and mode functions", () => { + const root = project(); + writeFileSync( + join(root, "app", "P.wrn"), + `page P { + ssr { + api getUsers GET /api/users { response { return data } } + functions { function label(u) { return u.name } } + } + view {
x
} +} +`, + ); + + const report = { + changedAutomatically: [] as string[], + needsReview: [] as string[], + unresolvedImports: [] as string[], + ambiguousFunctions: [] as string[], + legacyOutputPayloads: [] as string[], + parseFailures: [] as string[], + }; + + updateApp(root, "0.9.0", false, { report }); + + const migrated = readFileSync(join(root, "app", "P.wrn"), "utf8"); + + expect(migrated).toContain("apis {"); + expect(migrated).toContain("shared function label"); + expect(migrated).not.toContain("ssr {"); + expect(report.needsReview).toEqual([]); +});