143 lines
4.6 KiB
TypeScript
143 lines
4.6 KiB
TypeScript
import { lstat, readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { parseQuixosLockDocument, type QuixosLockDiagnostic, type QuixosLockParseResult } from "./parser.js";
|
|
import type { LockedResource, QuixosLockDocument, QuixosRepositoryLock } from "./types.js";
|
|
|
|
export type QuixosLockSourceReader = (relativePath: string) => Promise<string>;
|
|
|
|
const diagnostic = (code: string, message: string, fileName: string, path?: string): QuixosLockDiagnostic => ({
|
|
phase: "resolution",
|
|
code,
|
|
message,
|
|
fileName,
|
|
line: 1,
|
|
column: 0,
|
|
path,
|
|
});
|
|
|
|
export const resolveQuixosLock = async (
|
|
rootSource: string,
|
|
readSource: QuixosLockSourceReader,
|
|
rootFileName = "quixos.lock",
|
|
): Promise<QuixosLockParseResult> => {
|
|
const root = parseQuixosLockDocument(rootSource, rootFileName);
|
|
if (!root.ok) return root;
|
|
if (root.document.kind !== "root") {
|
|
return {
|
|
ok: false,
|
|
diagnostics: [
|
|
diagnostic("expected-root-lock", "The entrypoint must be a root Quixos lock, not a fragment", rootFileName),
|
|
],
|
|
};
|
|
}
|
|
|
|
const diagnostics: QuixosLockDiagnostic[] = [];
|
|
const resources: LockedResource[] = [];
|
|
const sourceFiles = [rootFileName];
|
|
const visited = new Set<string>();
|
|
const active: string[] = [];
|
|
|
|
const addResources = (document: QuixosLockDocument, fileName: string) => {
|
|
for (const resource of document.resources) {
|
|
const duplicate = resources.find((entry) => entry.kind === resource.kind && entry.binding === resource.binding);
|
|
if (duplicate) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
"duplicate-resource-binding",
|
|
`Duplicate ${resource.kind} binding ${resource.binding} across imported lock files`,
|
|
fileName,
|
|
`${resource.kind}.${resource.binding}`,
|
|
),
|
|
);
|
|
} else {
|
|
resources.push(resource);
|
|
}
|
|
}
|
|
};
|
|
|
|
const visit = async (importPath: string) => {
|
|
if (active.includes(importPath)) {
|
|
diagnostics.push(
|
|
diagnostic("import-cycle", `Lock import cycle: ${[...active, importPath].join(" -> ")}`, importPath),
|
|
);
|
|
return;
|
|
}
|
|
if (visited.has(importPath)) return;
|
|
active.push(importPath);
|
|
let source: string;
|
|
try {
|
|
source = await readSource(importPath);
|
|
} catch (cause) {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
"import-read-failed",
|
|
`Could not read lock import ${importPath}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
importPath,
|
|
),
|
|
);
|
|
active.pop();
|
|
return;
|
|
}
|
|
const parsed = parseQuixosLockDocument(source, importPath);
|
|
if (!parsed.ok) {
|
|
diagnostics.push(...parsed.diagnostics);
|
|
active.pop();
|
|
return;
|
|
}
|
|
if (parsed.document.kind !== "fragment") {
|
|
diagnostics.push(
|
|
diagnostic(
|
|
"imported-root-lock",
|
|
`Imported file ${importPath} must begin with "quixos-lock fragment"`,
|
|
importPath,
|
|
),
|
|
);
|
|
active.pop();
|
|
return;
|
|
}
|
|
visited.add(importPath);
|
|
sourceFiles.push(importPath);
|
|
addResources(parsed.document, importPath);
|
|
for (const nested of parsed.document.imports) await visit(nested);
|
|
active.pop();
|
|
};
|
|
|
|
addResources(root.document, rootFileName);
|
|
for (const importPath of root.document.imports) await visit(importPath);
|
|
if (diagnostics.length) return { ok: false, diagnostics };
|
|
const lock: QuixosRepositoryLock = {
|
|
formatVersion: 1,
|
|
quixos: root.document.quixos,
|
|
resources,
|
|
sourceFiles,
|
|
};
|
|
return { ok: true, lock, diagnostics: [] };
|
|
};
|
|
|
|
export const loadQuixosLock = async (fileName: string): Promise<QuixosLockParseResult> => {
|
|
const absoluteRoot = path.resolve(fileName);
|
|
const repositoryRoot = path.dirname(absoluteRoot);
|
|
const rootName = path.basename(absoluteRoot);
|
|
const rootSource = await readFile(absoluteRoot, "utf8");
|
|
return await resolveQuixosLock(
|
|
rootSource,
|
|
async (relativePath) => {
|
|
let absoluteImport = repositoryRoot;
|
|
const segments = relativePath.split("/");
|
|
for (const [index, segment] of segments.entries()) {
|
|
absoluteImport = path.join(absoluteImport, segment);
|
|
const metadata = await lstat(absoluteImport);
|
|
if (metadata.isSymbolicLink()) {
|
|
throw new Error("imports must not traverse symbolic links");
|
|
}
|
|
const final = index === segments.length - 1;
|
|
if ((!final && !metadata.isDirectory()) || (final && !metadata.isFile())) {
|
|
throw new Error("imports must be ordinary files beneath ordinary directories");
|
|
}
|
|
}
|
|
return await readFile(absoluteImport, "utf8");
|
|
},
|
|
rootName,
|
|
);
|
|
};
|