Format authored monorepo code with pinned language formatters
This commit is contained in:
@@ -12,23 +12,46 @@ export type StructuralEdit =
|
||||
| { 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 };
|
||||
| { 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"]);
|
||||
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");
|
||||
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");
|
||||
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) },
|
||||
@@ -36,25 +59,48 @@ export const instantiateWorkspaceIdentity = (source: string, workspaceId: string
|
||||
]);
|
||||
};
|
||||
|
||||
const select = (source: string, selector: StructuralSelector): {node: SyntaxNode; syntax: ReturnType<typeof parseQx>} => {
|
||||
const select = (
|
||||
source: string,
|
||||
selector: StructuralSelector,
|
||||
): { node: SyntaxNode; syntax: ReturnType<typeof parseQx> } => {
|
||||
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.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"));
|
||||
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};
|
||||
return { node: matches[0], syntax };
|
||||
};
|
||||
|
||||
/** Comment-preserving structural edits; every result is parsed before returning. */
|
||||
@@ -69,87 +115,148 @@ export const editStructure = (source: string, edit: StructuralEdit): string => {
|
||||
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)},
|
||||
{
|
||||
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("; ")}`);
|
||||
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");
|
||||
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;
|
||||
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`}]);
|
||||
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");
|
||||
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);
|
||||
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}` : "";
|
||||
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 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("; ")}`);
|
||||
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);
|
||||
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");
|
||||
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} `}]);
|
||||
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");
|
||||
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");
|
||||
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)}`}]);
|
||||
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`}]);
|
||||
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 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("; ")}`);
|
||||
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");
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user