89 lines
4.1 KiB
TypeScript
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));
|