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, entry = "workspace.qx") => { const files = new Map(); const active: string[] = []; const visited = new Set(); 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));