Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

scripts/codegen.mjs

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.mjs";

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

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

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

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

function safeRelativePath(root, value, label) {
	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, path, label) {
	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.code !== "ENOENT") throw error;
			break;
		}
	}
}

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

function extensionDirectories(root) {
	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.code === "ENOENT") return false;
					throw error;
				}
			})
			.sort();
	} catch (error) {
		if (error.code === "ENOENT") return [];
		throw error;
	}
}

function readTargets(root) {
	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.code === "ENOENT") return [];
		throw error;
	}
	let config;
	try {
		config = JSON.parse(readUtf8(path, "codegen config"));
	} catch (error) {
		pathError("codegen config", `must contain valid JSON: ${error.message}`);
	}
	if (!config || typeof config !== "object" || Array.isArray(config))
		pathError("codegen config", "must be an object with a targets array");
	if (!Array.isArray(config.targets))
		pathError("codegen config", "targets must be an array");
	return config.targets;
}

function targetExtensions(target, available, label) {
	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();
	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, target, index, extensions) {
	const label = `target ${index + 1}`;
	if (!target || typeof target !== "object" || Array.isArray(target))
		pathError(label, "must be an object");
	if (typeof target.name !== "string" || !target.name)
		pathError(label, "name must be a non-empty string");
	const template = safeRelativePath(root, target.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.code === "ENOENT")
			pathError(`${label} template`, "does not exist");
		throw error;
	}
	if (typeof target.output !== "string")
		pathError(label, "output must be a path");
	validatePlaceholders(target.output, `${label} output`);
	const matches = target.output.split(EXTENSION_SLUG).length - 1;
	if (
		matches !== 1 ||
		!target.output.startsWith(`extensions/${EXTENSION_SLUG}/`)
	)
		pathError(label, `output must be inside extensions/${EXTENSION_SLUG}/`);
	const selected = targetExtensions(target, extensions, label);
	return { label, template, output: target.output, selected };
}

export function expectedGenerated(root = ROOT) {
	const resolvedRoot = resolve(root);
	const extensions = extensionDirectories(resolvedRoot);
	const expected = [];
	const outputs = new Set();
	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.template)}${template.replaceAll(EXTENSION_SLUG, slug)}`,
					"utf8",
				),
			});
		}
	}
	return expected;
}

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

function extensionSlug(ext, root) {
	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, root = ROOT) {
	let slug;
	try {
		const resolvedRoot = resolve(root);
		const expected = expectedGenerated(resolvedRoot);
		if (!expected.length) return [];
		slug = extensionSlug(ext, resolvedRoot);
		const errors = [];
		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.message}`];
	}
}

export function generate(root = ROOT) {
	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;
}

if (import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
	try {
		process.exitCode = main();
	} catch (error) {
		console.error(error.message);
		process.exitCode = 1;
	}
}