import { CharStream, CommonTokenStream } from "antlr4ng"; import { QuixosLockLexer } from "../resource-lock/generated/QuixosLockLexer.js"; import { QuixosLockParser } from "../resource-lock/generated/QuixosLockParser.js"; import { parseQuixosLockDocument } from "../resource-lock/parser.js"; import { parseQx, walkSyntax, applySourceEdits, type SyntaxNode } from "./source.js"; export type StructuralSelector = { kind: string; id?: string; name?: string; names?: string[] }; export type StructuralEdit = | { operation: "append"; parent: StructuralSelector; source: string } | { operation: "replace"; target: StructuralSelector; source: string } | { operation: "remove"; target: StructuralSelector } | { operation: "import"; kind: "interface" | "package"; name: string } | { operation: "semantic-major"; target: StructuralSelector; major: number } | { operation: "conformance-id"; target: StructuralSelector; id: string } | { operation: "quixos-pin"; source: {repository: string; commit: string} } | { operation: "dependency"; kind: "interface" | "package"; name: string; source: {repository: string; commit: string} | null }; // Deliberately exclude valueType/identifier/stringLiteral: callers operate on // declaration structure, not arbitrary token offsets or lockfile text patches. const selectable = new Set(["workspaceDecl", "fragmentDecl", "interfaceResourceDecl", "packageResourceDecl", "atomDecl", "valueMember", "relationshipMember", "operationMember", "packageOperationExport", "packageFunctionExport", "packageConstructorExport", "conformanceDecl", "stateDecl", "edgeDecl", "constructorBindingDecl", "resourceImportDecl", "sourceImportDecl", "operationBindingDecl"]); /** Bind only the template root identity; schema/atom identities are reusable. * The revision's real identity is derived from its containing commit at compile time. */ export const instantiateWorkspaceIdentity = (source: string, workspaceId: string): string => { if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(workspaceId)) throw new Error("Workspace identity must be a UUID"); const syntax = parseQx(source); if (syntax.diagnostics.length) throw new Error("Cannot instantiate a malformed template workspace"); const declaration = syntax.root.children.find(node => node.kind === "workspaceDecl"); const literals = declaration?.children.filter(node => node.kind === "stringLiteral"); if (!literals || literals.length !== 3) throw new Error("Template must contain a workspace declaration"); return applySourceEdits(source, [ { ...literals[0], text: JSON.stringify(workspaceId) }, { ...literals[1], text: JSON.stringify(`workspace-revision:${workspaceId}:source`) }, ]); }; const select = (source: string, selector: StructuralSelector): {node: SyntaxNode; syntax: ReturnType} => { if (!selectable.has(selector.kind)) throw new Error(`Unsupported structural selector ${selector.kind}`); const syntax = parseQx(source); if (syntax.diagnostics.length) throw new Error("Cannot scaffold syntactically invalid QX"); const matches = [...walkSyntax(syntax.root)].filter((node) => { if (node.kind !== selector.kind) return false; if (selector.name && !node.children.some((child) => child.kind === "identifier" && source.slice(child.start, child.end) === selector.name)) return false; if (selector.names && JSON.stringify(node.children.filter((child) => child.kind === "identifier").map((child) => source.slice(child.start, child.end))) !== JSON.stringify(selector.names)) return false; if (selector.id) { // Only an explicit ID field counts, not a coincidentally equal revision, // default value, nested declaration, or comment. const tokens = syntax.tokens.filter((token) => !token.trivia && token.start >= node.start && token.end <= node.end); const literal = node.children.find((child) => child.kind === "stringLiteral" && tokens.some((token, index) => token.start === child.start && tokens[index - 1]?.kind === "ID")); if (!literal || JSON.parse(source.slice(literal.start, literal.end)) !== selector.id) return false; } return true; }); if (matches.length !== 1) throw new Error(`Structural selector must resolve exactly once (found ${matches.length})`); return {node: matches[0], syntax}; }; /** Comment-preserving structural edits; every result is parsed before returning. */ export const editStructure = (source: string, edit: StructuralEdit): string => { if (edit.operation === "quixos-pin") { const parsed = parseQuixosLockDocument(source); if (!parsed.ok || parsed.document.kind !== "root") throw new Error("Quixos pins belong in a valid root lockfile"); const parser = new QuixosLockParser(new CommonTokenStream(new QuixosLockLexer(CharStream.fromString(source)))); const entries = parser.document().quixosEntry(); if (entries.length !== 1) throw new Error("Expected one Quixos source declaration"); const literals = entries[0].quixosSourceBlock().stringLiteral(); const offsets = [0]; for (const character of source) offsets.push(offsets[offsets.length - 1] + character.length); const result = applySourceEdits(source, [ {start: offsets[literals[0].start!.start], end: offsets[literals[0].stop!.stop + 1], text: JSON.stringify(edit.source.repository)}, {start: offsets[literals[literals.length - 1].start!.start], end: offsets[literals[literals.length - 1].stop!.stop + 1], text: JSON.stringify(edit.source.commit)}, ]); const checked = parseQuixosLockDocument(result); if (!checked.ok) throw new Error(`Invalid Quixos pin: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`); return result; } if (edit.operation === "import") { if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name)) throw new Error("Invalid resource import"); const syntax = parseQx(source); if (syntax.diagnostics.length) throw new Error("Cannot scaffold invalid QX"); const text = `import ${edit.kind} ${edit.name};`; if ([...walkSyntax(syntax.root)].some((entry) => entry.kind === "resourceImportDecl" && source.slice(entry.start, entry.end).replace(/\s+/g, " ") === text)) return source; const root = syntax.root.children[0]; const position = ["workspaceDecl", "fragmentDecl"].includes(root.kind) ? syntax.tokens.find((token) => token.kind === "LBRACE")!.end : root.start; const result = applySourceEdits(source, [{start: position, end: position, text: `\n${text}\n`}]); if (parseQx(result).diagnostics.length) throw new Error("Invalid resource import position"); return result; } if (edit.operation === "dependency") { const parsed = parseQuixosLockDocument(source); if (!parsed.ok) throw new Error("Cannot scaffold an invalid lockfile"); if (!["interface", "package"].includes(edit.kind) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(edit.name)) throw new Error("Invalid dependency selector"); const parser = new QuixosLockParser(new CommonTokenStream(new QuixosLockLexer(CharStream.fromString(source)))); const tree = parser.document(); const offsets = [0]; for (const character of source) offsets.push(offsets[offsets.length - 1] + character.length); const entries = tree.resourceEntry().filter((entry) => entry.resourceKind().getText() === edit.kind && entry.identifier().getText() === edit.name); if (entries.length > 1) throw new Error("Ambiguous dependency selector"); const entry = entries[0]; const replacement = edit.source ? `${edit.kind} ${edit.name} source {\n repository ${JSON.stringify(edit.source.repository)};\n commit ${JSON.stringify(edit.source.commit)};\n}` : ""; if (!entry && !edit.source) throw new Error("Cannot remove an absent dependency"); const start = entry ? offsets[entry.start!.start] : offsets[tree.RBRACE().symbol.start]; const end = entry ? offsets[entry.stop!.stop + 1] : start; const result = applySourceEdits(source, [{start, end, text: entry ? replacement : `${replacement}\n`}]); const checked = parseQuixosLockDocument(result); if (!checked.ok) throw new Error(`Invalid dependency change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`); return result; } const {node, syntax} = select(source, edit.operation === "append" ? edit.parent : edit.target); let result: string; if (edit.operation === "semantic-major") { if (!["conformanceDecl", "packageResourceDecl"].includes(node.kind) || !Number.isSafeInteger(edit.major) || edit.major < 1) throw new Error("Semantic major requires a package/conformance and positive integer"); const tokens = syntax.tokens.filter((token) => !token.trivia && token.start >= node.start && token.end <= node.end); const marker = tokens.findIndex((token) => token.kind === "SEMANTIC_MAJOR"); const value = marker < 0 ? undefined : tokens[marker + 1]; const brace = tokens.find((token) => token.kind === "LBRACE")!; result = applySourceEdits(source, [{start: value?.start ?? brace.start, end: value?.end ?? brace.start, text: value ? String(edit.major) : `semantic-major ${edit.major} `}]); } else if (edit.operation === "conformance-id") { if (node.kind !== "conformanceDecl" || !edit.id) throw new Error("Identity enrollment requires a conformance and stable ID"); const existing = node.children.find((entry) => entry.kind === "stringLiteral"); if (existing) { if (JSON.parse(source.slice(existing.start, existing.end)) !== edit.id) throw new Error("Cannot change an enrolled conformance identity; create a new conformance explicitly"); return source; } const identifiers = node.children.filter((entry) => entry.kind === "identifier"); const position = identifiers[identifiers.length - 1].end; result = applySourceEdits(source, [{start: position, end: position, text: ` id ${JSON.stringify(edit.id)}`}]); } else if (edit.operation === "append") { const closing = syntax.tokens.find((token) => token.kind === "RBRACE" && token.end === node.end); if (!closing) throw new Error("Append requires a declaration with a body"); result = applySourceEdits(source, [{start: closing.start, end: closing.start, text: `\n${edit.source}\n`}]); } else { // A resource parser node includes imports/external declarations preceding // its header. Replacing the declaration must not delete that preamble. const identifier = node.children.find(child => child.kind === "identifier"); const header = ["packageResourceDecl", "interfaceResourceDecl"].includes(node.kind) && identifier ? syntax.tokens.filter(token => token.start >= node.start && token.end <= identifier.start && token.kind === (node.kind === "packageResourceDecl" ? "PACKAGE" : "INTERFACE")).at(-1)?.start : undefined; const wrapper = edit.operation === "remove" && ["stateDecl", "edgeDecl"].includes(node.kind) ? [...walkSyntax(syntax.root)].filter((entry) => ["conformanceItem", "sharedAttachmentDecl"].includes(entry.kind) && entry.start <= node.start && entry.end >= node.end).sort((a, b) => (a.end - a.start) - (b.end - b.start))[0] : undefined; result = applySourceEdits(source, [{start: wrapper?.start ?? header ?? node.start, end: wrapper?.end ?? node.end, text: edit.operation === "replace" ? edit.source : ""}]); } const checked = parseQx(result); if (checked.diagnostics.length) throw new Error(`Invalid structural change: ${checked.diagnostics.map((entry) => entry.message).join("; ")}`); return result; }; export const scaffoldResourceSource = (kind: "interface" | "package", name: string, id: string, revision: string) => { if (!/^[A-Z][A-Za-z0-9]*$/.test(name) || !id || !revision) throw new Error("Resource scaffold requires a PascalCase name and explicit identities"); const source = `${kind} ${name} id ${JSON.stringify(id)} revision ${JSON.stringify(revision)} {\n}\n`; if (parseQx(source).diagnostics.length) throw new Error("Invalid resource scaffold"); return source; };