import { BaseErrorListener, CharStream, CommonTokenStream, type ATNSimulator, type RecognitionException, type Recognizer, type Token, } from "antlr4ng"; import { QuixosLockLexer } from "./generated/QuixosLockLexer.js"; import { QuixosLockParser, type SourceBlockContext, } from "./generated/QuixosLockParser.js"; import type { GitSource, LockedResource, QuixosRepositoryLock, } from "./types.js"; export type QuixosLockDiagnostic = { phase: "syntax" | "validation"; code: string; message: string; fileName: string; line: number; column: number; path?: string; }; export type QuixosLockParseResult = | { ok: true; lock: QuixosRepositoryLock; 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 issue = ( diagnostics: QuixosLockDiagnostic[], fileName: string, code: string, message: string, path?: string, ) => diagnostics.push({ phase: "validation", code, message, fileName, line: 1, column: 0, path, }); 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`, ); } }; export const parseQuixosLock = ( source: string, fileName = "", ): QuixosLockParseResult => { 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 quixos = lowerSource(tree.quixosEntry().sourceBlock()); validateSource(quixos, "quixos", fileName, diagnostics); 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, lock: { formatVersion: 1, quixos, 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())};`, ]; export const formatQuixosLock = (lock: QuixosRepositoryLock): string => { const lines = [ "quixos-lock version 1 {", " quixos source {", ...sourceLines(lock.quixos, " "), " }", ]; for (const resource of lock.resources) { lines.push( "", ` ${resource.kind} ${resource.binding} source {`, ...sourceLines(resource.source, " "), " }", ); } lines.push("}", ""); return lines.join("\n"); };