464 lines
12 KiB
TypeScript
464 lines
12 KiB
TypeScript
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<S extends Token, T extends ATNSimulator>(
|
|
_recognizer: Recognizer<T>,
|
|
_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 = "<memory>",
|
|
): 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<string>();
|
|
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<string>();
|
|
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 = "<memory>",
|
|
): 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");
|
|
};
|