import { BaseErrorListener, CharStream, CommonTokenStream, type ATNSimulator, type RecognitionException, type Recognizer, type Token, } from "antlr4ng"; import { QuixosLockLexer } from "./generated/QuixosLockLexer.js"; import { QuixosLockParser, type QuixosSourceBlockContext, type SourceBlockContext, } from "./generated/QuixosLockParser.js"; import type { GitSource, LockedResource, QuixosLockDocument, QuixosRepositoryLock, QuixosSource, } from "./types.js"; export type QuixosLockDiagnostic = { phase: "syntax" | "validation" | "resolution"; code: string; message: string; fileName: string; line: number; column: number; path?: string; }; export type QuixosLockParseResult = | { ok: true; lock: QuixosRepositoryLock; diagnostics: [] } | { ok: false; diagnostics: QuixosLockDiagnostic[] }; export type QuixosLockDocumentParseResult = | { ok: true; document: QuixosLockDocument; diagnostics: [] } | { ok: false; diagnostics: QuixosLockDiagnostic[] }; class SyntaxErrorListener extends BaseErrorListener { constructor( private readonly fileName: string, private readonly diagnostics: QuixosLockDiagnostic[], ) { super(); } override syntaxError( _recognizer: Recognizer, _offendingSymbol: S | null, line: number, column: number, message: string, _error: RecognitionException | null, ): void { this.diagnostics.push({ phase: "syntax", code: "syntax-error", message, fileName: this.fileName, line, column, }); } } const stringValue = (context: { getText(): string }): string => JSON.parse(context.getText()) as string; const lowerSource = (context: SourceBlockContext): GitSource => ({ resolver: "git", repository: stringValue(context.stringLiteral(0)!), commit: stringValue(context.stringLiteral(1)!).toLowerCase(), }); const lowerQuixosSource = (context: QuixosSourceBlockContext): QuixosSource => { const source = { resolver: "git" as const, repository: stringValue(context.stringLiteral(0)!), commit: stringValue(context.stringLiteral(context.stringLiteral().length - 1)!).toLowerCase(), }; const policyContext = context.sourcePolicy(); if (!policyContext) return source; const policy = policyContext.PINNED() ? "pinned" : policyContext.TRACK_RELEASE() ? "track-release" : "track-development"; return { ...source, policy, ref: stringValue(context.stringLiteral(1)!), }; }; const issue = ( diagnostics: QuixosLockDiagnostic[], fileName: string, code: string, message: string, path?: string, line = 1, column = 0, ) => diagnostics.push({ phase: "validation", code, message, fileName, line, column, path, }); const validateImport = ( importPath: string, index: number, fileName: string, diagnostics: QuixosLockDiagnostic[], line: number, column: number, ) => { const path = `imports[${index}]`; if ( !importPath || importPath.startsWith("/") || importPath.includes("\\") || importPath.split("/").some((segment) => !segment || segment === "." || segment === "..") || /^[A-Za-z][A-Za-z0-9+.-]*:/.test(importPath) ) { issue( diagnostics, fileName, "invalid-import-path", `${path} must be a normalized relative path beneath the workspace repository root`, path, line, column, ); } }; const validateSource = ( source: GitSource, path: string, fileName: string, diagnostics: QuixosLockDiagnostic[], ) => { if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(source.commit)) { issue( diagnostics, fileName, "invalid-git-commit", `${path}.commit must be a full 40- or 64-character Git object ID`, `${path}.commit`, ); } let repository: URL; try { repository = new URL(source.repository); } catch { issue( diagnostics, fileName, "invalid-git-repository", `${path}.repository must be an absolute Git URL`, `${path}.repository`, ); return; } if (!["https:", "ssh:"].includes(repository.protocol)) { issue( diagnostics, fileName, "unsupported-git-transport", `${path}.repository must use https:// or ssh://`, `${path}.repository`, ); } if (repository.search || repository.hash) { issue( diagnostics, fileName, "decorated-git-repository", `${path}.repository must be a base repository URL without a query or fragment`, `${path}.repository`, ); } if (repository.password || (repository.protocol === "https:" && repository.username)) { issue( diagnostics, fileName, "embedded-git-credential", `${path}.repository must not embed credentials`, `${path}.repository`, ); } }; const validateQuixosSource = ( source: QuixosSource, fileName: string, diagnostics: QuixosLockDiagnostic[], ) => { validateSource(source, "quixos", fileName, diagnostics); if (!source.policy) return; const forbiddenRefCharacters = new Set("~^:?*[\\"); const invalidRef = !source.ref || [...source.ref].some((character) => { const code = character.charCodeAt(0); return code <= 0x20 || code === 0x7f || forbiddenRefCharacters.has(character); }) || source.ref.startsWith("/") || source.ref.endsWith("/") || source.ref.endsWith(".") || source.ref.includes("..") || source.ref.includes("@{") || source.ref.includes("//"); if (invalidRef) { issue( diagnostics, fileName, "invalid-quixos-ref", "quixos.ref must be a non-empty Git ref without whitespace or Git ref metacharacters", "quixos.ref", ); } if (source.policy === "pinned") { if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/.test(source.ref)) { issue( diagnostics, fileName, "pinned-quixos-ref-not-commit", "A pinned Quixos source must use an exact commit as its ref", "quixos.ref", ); } else if (source.ref.toLowerCase() !== source.commit) { issue( diagnostics, fileName, "pinned-quixos-commit-mismatch", "A pinned Quixos source ref and resolved commit must be identical", "quixos.commit", ); } } }; export const parseQuixosLockDocument = ( source: string, fileName = "", ): QuixosLockDocumentParseResult => { const diagnostics: QuixosLockDiagnostic[] = []; const listener = new SyntaxErrorListener(fileName, diagnostics); const lexer = new QuixosLockLexer(CharStream.fromString(source)); lexer.removeErrorListeners(); lexer.addErrorListener(listener); const parser = new QuixosLockParser(new CommonTokenStream(lexer)); parser.removeErrorListeners(); parser.addErrorListener(listener); const tree = parser.document(); if (diagnostics.length > 0) return { ok: false, diagnostics }; const formatVersion = Number.parseInt(tree.INTEGER().getText(), 10); if (formatVersion !== 1) { issue( diagnostics, fileName, "unsupported-lock-version", `Unsupported Quixos lock format version ${formatVersion}`, "formatVersion", ); } const fragment = Boolean(tree.FRAGMENT()); const quixosEntries = tree.quixosEntry(); let quixos: QuixosSource | undefined; if (fragment && quixosEntries.length) { issue( diagnostics, fileName, "fragment-has-quixos-source", "A lock fragment inherits the root document's Quixos source and must not redeclare it", "quixos", ); } else if (!fragment && quixosEntries.length !== 1) { issue( diagnostics, fileName, "root-quixos-source-count", "A root lock document must contain exactly one Quixos source", "quixos", ); } else if (!fragment) { quixos = lowerQuixosSource(quixosEntries[0]!.quixosSourceBlock()); validateQuixosSource(quixos, fileName, diagnostics); } const imports = tree.importEntry().map((context, index) => { const importPath = stringValue(context.stringLiteral()); validateImport( importPath, index, fileName, diagnostics, context.start?.line ?? 1, context.start?.column ?? 0, ); return importPath; }); const repeatedImports = new Set(); imports.forEach((importPath, index) => { if (repeatedImports.has(importPath)) { issue( diagnostics, fileName, "duplicate-import", `Duplicate lock import ${importPath}`, `imports[${index}]`, ); } repeatedImports.add(importPath); }); const resources: LockedResource[] = []; const bindings = new Set(); for (const [index, context] of tree.resourceEntry().entries()) { const kind = context.resourceKind().INTERFACE() ? "interface" : "package"; const binding = context.identifier().getText(); const key = `${kind}\0${binding}`; if (bindings.has(key)) { issue( diagnostics, fileName, "duplicate-resource-binding", `Duplicate ${kind} binding ${binding}`, `resources[${index}].binding`, ); } bindings.add(key); const resource = { kind, binding, source: lowerSource(context.sourceBlock()) } as LockedResource; validateSource(resource.source, `resources[${index}].source`, fileName, diagnostics); resources.push(resource); } return diagnostics.length > 0 ? { ok: false, diagnostics } : { ok: true, document: fragment ? { kind: "fragment", formatVersion: 1, imports, resources } : { kind: "root", formatVersion: 1, quixos: quixos!, imports, resources }, diagnostics: [], }; }; export const parseQuixosLock = ( source: string, fileName = "", ): QuixosLockParseResult => { const parsed = parseQuixosLockDocument(source, fileName); if (!parsed.ok) return parsed; if (parsed.document.kind === "fragment") { return { ok: false, diagnostics: [{ phase: "validation", code: "expected-root-lock", message: "Expected a root Quixos lock, found a lock fragment", fileName, line: 1, column: 0, }], }; } if (parsed.document.imports.length) { return { ok: false, diagnostics: [{ phase: "resolution", code: "imports-require-file-resolution", message: "This lock has imports and must be loaded from its repository rather than parsed as an isolated string", fileName, line: 1, column: 0, }], }; } return { ok: true, lock: { formatVersion: 1, quixos: parsed.document.quixos, resources: parsed.document.resources, }, diagnostics: [], }; }; const quoted = (value: string) => JSON.stringify(value); const sourceLines = (source: GitSource, indentation: string): string[] => [ `${indentation}repository ${quoted(source.repository)};`, `${indentation}commit ${quoted(source.commit.toLowerCase())};`, ]; const quixosSourceLines = (source: QuixosSource, indentation: string): string[] => [ `${indentation}repository ${quoted(source.repository)};`, ...(source.policy ? [ `${indentation}policy ${source.policy};`, `${indentation}ref ${quoted(source.ref)};`, ] : []), `${indentation}commit ${quoted(source.commit.toLowerCase())};`, ]; export const formatQuixosLock = (lock: QuixosRepositoryLock): string => { const lines = [ "quixos-lock version 1 {", " quixos source {", ...quixosSourceLines(lock.quixos, " "), " }", ]; for (const resource of lock.resources) { lines.push( "", ` ${resource.kind} ${resource.binding} source {`, ...sourceLines(resource.source, " "), " }", ); } lines.push("}", ""); return lines.join("\n"); }; export const formatQuixosLockDocument = (document: QuixosLockDocument): string => { const lines = [ `quixos-lock${document.kind === "fragment" ? " fragment" : ""} version 1 {`, ]; if (document.kind === "root") { lines.push( " quixos source {", ...quixosSourceLines(document.quixos, " "), " }", ); } for (const importPath of document.imports) { if (lines.length > 1) lines.push(""); lines.push(` import ${quoted(importPath)};`); } for (const resource of document.resources) { if (lines.length > 1) lines.push(""); lines.push( ` ${resource.kind} ${resource.binding} source {`, ...sourceLines(resource.source, " "), " }", ); } lines.push("}", ""); return lines.join("\n"); };