Files
quixos-protocol/src/capability-language/source-loader.ts
T

89 lines
4.1 KiB
TypeScript

import { lstat, readFile } from "node:fs/promises";
import path from "node:path";
import { parseQx, validateQxImportPath, walkSyntax } from "./source.js";
export const readQxSource = async (root: string, name: string) => {
validateQxImportPath(name);
return readRepositorySource(root, name);
};
/** Refuse symlinks at every component, including the final source file. */
export const readRepositorySource = async (root: string, name: string) => {
if (!/^(?:[A-Za-z0-9_-][A-Za-z0-9_.-]*\/)*[A-Za-z0-9_-][A-Za-z0-9_.-]*$/.test(name))
throw new Error(`Invalid repository-relative source path: ${name}`);
let current = path.resolve(root);
const segments = name.split("/");
for (const [index, segment] of segments.entries()) {
current = path.join(current, segment);
const stat = await lstat(current);
if (stat.isSymbolicLink() || (index === segments.length - 1 ? !stat.isFile() : !stat.isDirectory()))
throw new Error(`Sources must be ordinary files beneath ordinary directories: ${name}`);
}
return readFile(current, "utf8");
};
/** Local imports share one workspace scope; paths are always repository-relative. */
export const resolveQxSources = async (read: (name: string) => Promise<string>, entry = "workspace.qx") => {
const files = new Map<string, string>();
const active: string[] = [];
const visited = new Set<string>();
const segments: { start: number; end: number; fileName: string; sourceStart: number }[] = [];
let source = "";
const append = (fileName: string, text: string, sourceStart: number) => {
segments.push({ start: source.length, end: source.length + text.length, fileName, sourceStart });
source += text;
};
const visit = async (name: string, root: boolean) => {
validateQxImportPath(name);
if (active.includes(name)) throw new Error(`QX import cycle: ${[...active, name].join(" -> ")}`);
if (visited.has(name)) return;
const text = await read(name);
const syntax = parseQx(text, name);
if (syntax.diagnostics.length)
throw new Error(syntax.diagnostics.map((d) => `${name}:${d.line}:${d.column + 1}: ${d.message}`).join("\n"));
const declaration = syntax.root.children[0]!;
if (declaration.kind !== (root ? "workspaceDecl" : "fragmentDecl"))
throw new Error(`${name}: expected ${root ? "workspace" : "fragment"} document`);
files.set(name, text);
active.push(name);
visited.add(name);
const braces = syntax.tokens.filter((token) => !token.trivia);
let cursor = root ? 0 : braces.find((token) => token.kind === "LBRACE")!.end;
const end = root ? text.length : braces.filter((token) => token.kind === "RBRACE").at(-1)!.start;
for (const node of walkSyntax(declaration)) {
if (node.kind !== "sourceImportDecl") continue;
append(name, text.slice(cursor, node.start), cursor);
const literal = node.children.find((child) => child.kind === "stringLiteral")!;
// Separators prevent adjacent tokens/comments joining across files.
append(name, "\n", node.start);
await visit(JSON.parse(text.slice(literal.start, literal.end)), false);
append(name, "\n", node.end);
cursor = node.end;
}
append(name, text.slice(cursor, end), cursor);
active.pop();
};
await visit(entry, true);
return {
source,
sourceFiles: [...files.keys()],
files,
originalPosition(line: number, column: number) {
const lines = source.split("\n");
const offset =
lines.slice(0, line - 1).reduce((sum, value) => sum + value.length + 1, 0) +
[...(lines[line - 1] ?? "")].slice(0, column).join("").length;
const segment = segments.find((entry) => entry.start <= offset && entry.end > offset);
if (!segment) return { fileName: entry, line, column };
const prefix = files.get(segment.fileName)!.slice(0, segment.sourceStart + offset - segment.start);
return {
fileName: segment.fileName,
line: prefix.split("\n").length,
column: prefix.length - prefix.lastIndexOf("\n") - 1,
};
},
};
};
export const loadQxSources = (root: string) => resolveQxSources((name) => readQxSource(root, name));