Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

scripts/codegen.ts

Raw
#!/usr/bin/env node
import {
	lstatSync,
	mkdirSync,
	readdirSync,
	readFileSync,
	writeFileSync,
} from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { ROOT } from "./lib/common.ts";

export const CONFIG_PATH = "scripts/codegen/targets.json";
export const EXTENSION_SLUG = "{{extension-slug}}";
export const GENERATED_MARKER =
	"// Generated by scripts/codegen.ts. DO NOT EDIT.\n";

function generatedWarning(template: string) {
	return [
		"// ============================================================================",
		"// THIS FILE IS GENERATED. DO NOT EDIT IT DIRECTLY.",
		`// Instead, edit ${template}.`,
		"// Then run: mise run //:codegen",
		"// ============================================================================",
		"",
	].join("\n");
}

function pathError(label: string, message: string): never {
	throw new Error(`${label}: ${message}`);
}

function validatePlaceholders(value: string, label: string) {
	for (const token of value.match(/{{[^{}]*}}|{{|}}/g) ?? [])
		if (token !== EXTENSION_SLUG)
			pathError(label, `unsupported placeholder ${token}`);
}

function safeRelativePath(root: string, value: unknown, label: string) {
	if (typeof value !== "string" || !value) pathError(label, "must be a path");
	if (value.includes("\\") || value.includes("\0") || isAbsolute(value))
		pathError(label, "must be a relative slash-separated path");
	const parts = value.split("/");
	if (
		parts.some(
			(part) => !part || part === "." || part === ".." || part.includes(":"),
		)
	)
		pathError(
			label,
			"must not contain traversal or platform-specific segments",
		);
	const path = resolve(root, ...parts);
	const rel = relative(root, path);
	if (rel === "" || rel.startsWith("..") || isAbsolute(rel))
		pathError(label, "must stay inside repository root");
	return path;
}

function assertNoSymlink(root: string, path: string, label: string) {
	let current = root;
	for (const part of relative(root, path).split(/[\\/]/)) {
		current = join(current, part);
		try {
			if (lstatSync(current).isSymbolicLink())
				pathError(label, `must not cross symlink ${relative(root, current)}`);
		} catch (error) {
			if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
			break;
		}
	}
}

function readUtf8(path: string, label: string): string {
	let bytes: Buffer;
	try {
		bytes = readFileSync(path);
	} catch (error) {
		pathError(label, (error as Error).message);
	}
	try {
		return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
	} catch {
		pathError(label, "must be valid UTF-8 text");
	}
}

function extensionDirectories(root: string) {
	const extensions = safeRelativePath(root, "extensions", "extensions root");
	assertNoSymlink(root, extensions, "extensions root");
	try {
		return readdirSync(extensions, { withFileTypes: true })
			.filter((entry) => entry.isDirectory())
			.map((entry) => entry.name)
			.filter((slug) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
			.filter((slug) => {
				const pkg = join(extensions, slug, "package.json");
				assertNoSymlink(root, pkg, `extension ${slug}`);
				try {
					return lstatSync(pkg).isFile();
				} catch (error) {
					if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
					throw error;
				}
			})
			.sort();
	} catch (error) {
		if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
		throw error;
	}
}

function readTargets(root: string): unknown[] {
	const path = safeRelativePath(root, CONFIG_PATH, "codegen config");
	assertNoSymlink(root, path, "codegen config");
	try {
		if (!lstatSync(path).isFile())
			pathError("codegen config", "must be a file");
	} catch (error) {
		if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
		throw error;
	}
	let config: unknown;
	try {
		config = JSON.parse(readUtf8(path, "codegen config"));
	} catch (error) {
		pathError(
			"codegen config",
			`must contain valid JSON: ${(error as Error).message}`,
		);
	}
	if (!config || typeof config !== "object" || Array.isArray(config))
		pathError("codegen config", "must be an object with a targets array");
	const c = config as Record<string, unknown>;
	if (!Array.isArray(c["targets"]))
		pathError("codegen config", "targets must be an array");
	return c["targets"];
}

function targetExtensions(
	target: { extensions?: unknown },
	available: string[],
	label: string,
) {
	if (target.extensions === "all") return available;
	if (!Array.isArray(target.extensions) || !target.extensions.length)
		pathError(label, "extensions must be 'all' or a non-empty slug list");
	const selected = new Set<string>();
	for (const slug of target.extensions) {
		if (typeof slug !== "string" || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
			pathError(label, "extension selectors must be directory slugs");
		if (!available.includes(slug))
			pathError(label, `unknown extension ${slug}`);
		if (selected.has(slug)) pathError(label, `duplicate extension ${slug}`);
		selected.add(slug);
	}
	return [...selected].sort();
}

function validateTarget(
	root: string,
	target: unknown,
	index: number,
	extensions: string[],
) {
	const label = `target ${index + 1}`;
	if (!target || typeof target !== "object" || Array.isArray(target))
		pathError(label, "must be an object");
	const t = target as Record<string, unknown>;
	if (typeof t["name"] !== "string" || !t["name"])
		pathError(label, "name must be a non-empty string");
	const template = safeRelativePath(root, t["template"], `${label} template`);
	assertNoSymlink(root, template, `${label} template`);
	try {
		if (!lstatSync(template).isFile())
			pathError(`${label} template`, "must be a regular file");
	} catch (error) {
		if ((error as NodeJS.ErrnoException).code === "ENOENT")
			pathError(`${label} template`, "does not exist");
		throw error;
	}
	const output = t["output"];
	if (typeof output !== "string") pathError(label, "output must be a path");
	validatePlaceholders(output, `${label} output`);
	const matches = output.split(EXTENSION_SLUG).length - 1;
	if (matches !== 1 || !output.startsWith(`extensions/${EXTENSION_SLUG}/`))
		pathError(label, `output must be inside extensions/${EXTENSION_SLUG}/`);
	const selected = targetExtensions(t, extensions, label);
	return { label, template, output, selected };
}

export function expectedGenerated(
	root: string = ROOT,
): { slug: string; output: string; bytes: Buffer }[] {
	const resolvedRoot = resolve(root);
	const extensions = extensionDirectories(resolvedRoot);
	const expected: { slug: string; output: string; bytes: Buffer }[] = [];
	const outputs = new Set<string>();
	for (const [index, target] of readTargets(resolvedRoot).entries()) {
		const validated = validateTarget(resolvedRoot, target, index, extensions);
		const template = readUtf8(
			validated.template,
			`${validated.label} template`,
		);
		validatePlaceholders(template, `${validated.label} template`);
		for (const slug of validated.selected) {
			const output = safeRelativePath(
				resolvedRoot,
				validated.output.replace(EXTENSION_SLUG, slug),
				`${validated.label} output`,
			);
			assertNoSymlink(resolvedRoot, output, `${validated.label} output`);
			if (outputs.has(output))
				pathError(
					validated.label,
					`output collision at ${relative(resolvedRoot, output)}`,
				);
			outputs.add(output);
			expected.push({
				slug,
				output,
				bytes: Buffer.from(
					`${GENERATED_MARKER}${generatedWarning((target as { template: string }).template)}${template.replaceAll(EXTENSION_SLUG, slug)}`,
					"utf8",
				),
			});
		}
	}
	return expected;
}

function outputBytes(path: string, label: string): Buffer | undefined {
	try {
		const stat = lstatSync(path);
		if (stat.isSymbolicLink() || !stat.isFile())
			pathError(label, "must be a regular file");
		return readFileSync(path);
	} catch (error) {
		if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
		throw error;
	}
}

function extensionSlug(ext: string, root: string): string {
	const path = resolve(ext);
	const extensions = join(root, "extensions");
	if (resolve(path, "..") !== extensions)
		throw new Error("extension must be a direct child of extensions");
	const slug = path.split(/[\\/]/).at(-1);
	if (!slug || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug))
		throw new Error("extension must have a valid directory slug");
	return slug;
}

export function checkGenerated(ext: string, root: string = ROOT): string[] {
	let slug: string | undefined;
	try {
		const resolvedRoot = resolve(root);
		const expected = expectedGenerated(resolvedRoot);
		if (!expected.length) return [];
		slug = extensionSlug(ext, resolvedRoot);
		const errors: string[] = [];
		for (const generated of expected) {
			if (generated.slug !== slug) continue;
			const actual = outputBytes(generated.output, "generated output");
			if (!actual?.equals(generated.bytes))
				errors.push(
					`${slug}: stale generated ${relative(resolvedRoot, generated.output)}`,
				);
		}
		return errors;
	} catch (error) {
		return [`${slug ?? "codegen"}: ${(error as Error).message}`];
	}
}

export function generate(
	root: string = ROOT,
): { slug: string; output: string; bytes: Buffer }[] {
	const resolvedRoot = resolve(root);
	const expected = expectedGenerated(resolvedRoot);
	for (const generated of expected) {
		const actual = outputBytes(generated.output, "generated output");
		if (actual && !actual.toString("utf8").startsWith(GENERATED_MARKER))
			throw new Error(
				`refusing to overwrite unmarked file ${relative(resolvedRoot, generated.output)}`,
			);
	}
	for (const generated of expected) {
		if (
			outputBytes(generated.output, "generated output")?.equals(generated.bytes)
		)
			continue;
		mkdirSync(resolve(generated.output, ".."), { recursive: true });
		assertNoSymlink(resolvedRoot, generated.output, "generated output");
		writeFileSync(generated.output, generated.bytes);
		console.log(`OK generated ${relative(resolvedRoot, generated.output)}`);
	}
	return expected;
}

export function main() {
	generate();
	return 0;
}

const entryPoint = process.argv[1];
if (
	entryPoint !== undefined &&
	import.meta.url === pathToFileURL(resolve(entryPoint)).href
) {
	try {
		process.exitCode = main();
	} catch (error) {
		console.error((error as Error).message);
		process.exitCode = 1;
	}
}