Luigit
repositories / pi-ext

pi-ext

bugabingas pi extensions

owned by admin

extensions/runtime/index.ts

Raw
/**
 * Runtime detection footer segment producer.
 *
 * Detects project language runtime from config files in cwd or parents
 * under $HOME and emits a footer:segment with zone "workspace".
 */

import { execFile } from "node:child_process";
import { readdir, realpath } from "node:fs/promises";
import { dirname, isAbsolute, relative, resolve } from "node:path";
import { promisify } from "node:util";
import type {
	ExtensionAPI,
	ExtensionContext,
	ThemeColor,
} from "@earendil-works/pi-coding-agent";
import { closeDebug, dbg, span } from "./src/debug.ts";
import {
	type FooterSegment,
	offerFooterSegment,
	removeFooterSegment,
} from "./src/pi-ext-footer-segment.ts";
import {
	MAX_TIMER_MS,
	parseNumberSetting,
	resolveSetting,
	type SettingDeclaration,
} from "./src/pi-ext-settings.ts";
import { isStaleContextError } from "./src/pi-ext-stale-context.ts";

const execFileAsync = promisify(execFile);

const VERSION_TIMEOUT_SETTING: SettingDeclaration<number> = {
	key: "runtime.versionTimeoutMs",
	env: "PI_RUNTIME_VERSION_TIMEOUT_MS",
	default: 2500,
	parse(raw, source) {
		const value = parseNumberSetting(raw, source);
		if (value === undefined) return undefined;
		if (!Number.isInteger(value) || value <= 0 || value > MAX_TIMER_MS)
			throw new Error(`expected an integer from 1 to ${MAX_TIMER_MS}`);
		return value;
	},
};
// Resolved per session_start; shared by all version lookups.
let versionTimeoutMs = VERSION_TIMEOUT_SETTING.default;
const SEGMENT_ID = "runtime";
const STATUS_ID = "runtime";
const SEGMENT_ZONE = "workspace";
const SEGMENT_ORDER = 3;

const RUNTIME_FILES = {
	bun: ["bun.lock", "bun.lockb"],
	deno: ["deno.json", "deno.jsonc", "deno.lock"],
	lua: [
		"stylua.toml",
		".stylua.toml",
		".luarc.json",
		".luarc.jsonc",
		"init.lua",
	],
	nodejs: ["package.json", ".nvmrc", ".node-version"],
	python: [
		"pyproject.toml",
		"requirements.txt",
		"setup.py",
		"setup.cfg",
		"Pipfile",
		".python-version",
	],
	golang: ["go.mod"],
	rust: ["Cargo.toml"],
	java: ["pom.xml", "build.gradle", "build.gradle.kts"],
	ruby: ["Gemfile", ".ruby-version"],
	php: ["composer.json"],
} as const;

type RuntimeName = keyof typeof RUNTIME_FILES;
type DirEntries = { names: readonly string[]; set: ReadonlySet<string> };
type RuntimeInfo = { name: RuntimeName; icon: string; version?: string };
type RuntimeDef = {
	name: RuntimeName;
	icon: string;
	files: readonly string[];
	entries?: readonly string[];
	suffixes?: readonly string[];
	version: () => Promise<string | undefined>;
};
async function commandOutput(
	cmd: string,
	args: readonly string[] = [],
): Promise<string | undefined> {
	try {
		const { stdout, stderr } = await execFileAsync(cmd, [...args], {
			timeout: versionTimeoutMs,
		});
		const text = `${stdout}\n${stderr}`.trim();
		return text || undefined;
	} catch {
		return undefined;
	}
}

function prefixedVersion(version?: string): string | undefined {
	if (!version) return undefined;
	return version.startsWith("v") ? version : `v${version}`;
}

async function exactVersion(
	cmd: string,
	args: readonly string[] = [],
): Promise<string | undefined> {
	return prefixedVersion(await commandOutput(cmd, args));
}

async function matchedVersion(
	cmd: string,
	args: readonly string[],
	regex: RegExp,
): Promise<string | undefined> {
	return prefixedVersion((await commandOutput(cmd, args))?.match(regex)?.[1]);
}

async function firstMatchedVersion(
	commands: readonly [string, readonly string[], RegExp][],
): Promise<string | undefined> {
	for (const [cmd, args, regex] of commands) {
		const version = await matchedVersion(cmd, args, regex);
		if (version) return version;
	}
	return undefined;
}

async function canonicalPath(path: string): Promise<string> {
	try {
		return await realpath(path);
	} catch {
		return resolve(path);
	}
}

function isInside(path: string, root: string): boolean {
	const rel = relative(root, path);
	return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
}

async function searchDirs(cwd: string): Promise<string[]> {
	const start = await canonicalPath(cwd);
	const homeRaw = process.env.HOME;
	if (!homeRaw) return [start];

	const home = await canonicalPath(homeRaw);
	if (!isInside(start, home)) return [start];

	const dirs: string[] = [];
	let dir = start;

	while (true) {
		dirs.push(dir);
		if (dir === home) return dirs;

		const parent = dirname(dir);
		if (parent === dir || !isInside(parent, home)) return dirs;
		dir = parent;
	}
}

const runtimes: readonly RuntimeDef[] = [
	{
		name: "bun",
		icon: "",
		files: RUNTIME_FILES.bun,
		version: () => exactVersion("bun", ["--version"]),
	},
	{
		name: "deno",
		icon: "",
		files: RUNTIME_FILES.deno,
		version: () =>
			matchedVersion("deno", ["--version"], /deno\s+([0-9][^\s]*)/i),
	},
	{
		name: "lua",
		icon: "",
		files: RUNTIME_FILES.lua,
		entries: ["lua"],
		suffixes: [".lua"],
		version: () =>
			firstMatchedVersion([
				["lua", ["-v"], /Lua\s+([0-9][^\s]*)/i],
				["luajit", ["-v"], /LuaJIT\s+([0-9][^\s]*)/i],
			]),
	},
	{
		name: "nodejs",
		icon: "",
		files: RUNTIME_FILES.nodejs,
		version: () => Promise.resolve(process.version),
	},
	{
		name: "python",
		icon: "",
		files: RUNTIME_FILES.python,
		version: () =>
			firstMatchedVersion([
				["python3", ["--version"], /Python\s+([0-9][^\s]*)/i],
				["python", ["--version"], /Python\s+([0-9][^\s]*)/i],
			]),
	},
	{
		name: "golang",
		icon: "",
		files: RUNTIME_FILES.golang,
		version: () =>
			matchedVersion("go", ["version"], /go version go([0-9][^\s]*)/i),
	},
	{
		name: "rust",
		icon: "",
		files: RUNTIME_FILES.rust,
		version: () =>
			matchedVersion("rustc", ["--version"], /rustc\s+([0-9][^\s]*)/i),
	},
	{
		name: "java",
		icon: "",
		files: RUNTIME_FILES.java,
		version: () => matchedVersion("java", ["-version"], /"([0-9][^"]*)"/),
	},
	{
		name: "ruby",
		icon: "",
		files: RUNTIME_FILES.ruby,
		version: () =>
			matchedVersion("ruby", ["--version"], /ruby\s+([0-9][^\s]*)/i),
	},
	{
		name: "php",
		icon: "",
		files: RUNTIME_FILES.php,
		version: () => matchedVersion("php", ["--version"], /PHP\s+([0-9][^\s]*)/i),
	},
];

const RUNTIME_COLORS: Record<RuntimeName, ThemeColor> = {
	nodejs: "success",
	deno: "syntaxType",
	golang: "syntaxType",
	bun: "warning",
	python: "warning",
	java: "warning",
	rust: "error",
	ruby: "error",
	lua: "accent",
	php: "accent",
};

async function entriesFor(dir: string): Promise<DirEntries> {
	try {
		const names = await readdir(dir);
		return { names, set: new Set(names) };
	} catch {
		return { names: [], set: new Set() };
	}
}

function matchesRuntime(rt: RuntimeDef, entries: DirEntries): boolean {
	if (rt.files.some((file) => entries.set.has(file))) return true;
	if (rt.entries?.some((entry) => entries.set.has(entry))) return true;
	if (
		rt.suffixes?.some((suffix) =>
			entries.names.some((entry) => entry.endsWith(suffix)),
		)
	)
		return true;
	return false;
}

async function detectInDir(dir: string): Promise<RuntimeInfo | undefined> {
	const entries = await entriesFor(dir);
	for (const rt of runtimes) {
		if (!matchesRuntime(rt, entries)) continue;
		return { name: rt.name, icon: rt.icon, version: await rt.version() };
	}
	return undefined;
}

async function detect(cwd: string): Promise<RuntimeInfo | undefined> {
	for (const dir of await searchDirs(cwd)) {
		const rt = await detectInDir(dir);
		if (rt) return rt;
	}
	return undefined;
}

function ignoreStaleContext(fn: () => void) {
	try {
		fn();
	} catch (error) {
		if (!isStaleContextError(error)) throw error;
	}
}

export default function runtimeExtension(pi: ExtensionAPI) {
	let generation = 0;
	let hasUI = false;

	function clearSegment() {
		ignoreStaleContext(() => removeFooterSegment(pi, SEGMENT_ID));
	}

	function offerSegment(segment: FooterSegment): boolean {
		let accepted = false;
		ignoreStaleContext(() => {
			accepted = offerFooterSegment(pi, segment);
		});
		return accepted;
	}

	async function refresh(ctx: ExtensionContext) {
		const ticket = generation;
		let cwd: string;
		try {
			cwd = ctx.cwd;
		} catch (error) {
			if (isStaleContextError(error)) return;
			throw error;
		}
		if (!hasUI) return;

		const finish = span?.("runtime.detect");
		let rt: RuntimeInfo | undefined;
		try {
			rt = await detect(cwd);
		} catch (error) {
			finish?.("error");
			throw error;
		}
		if (ticket !== generation) {
			finish?.("finish", { kind: rt?.name ?? "none", outcome: "superseded" });
			return;
		}
		finish?.("finish", {
			kind: rt?.name ?? "none",
			outcome: rt ? "detected" : "none",
		});

		if (!rt) {
			clearSegment();
			ignoreStaleContext(() => ctx.ui.setStatus(STATUS_ID, undefined));
			return;
		}

		const text = rt.version ?? rt.name;
		const accepted =
			ctx.mode === "tui" &&
			offerSegment({
				id: SEGMENT_ID,
				icon: rt.icon,
				text,
				color: RUNTIME_COLORS[rt.name],
				zone: SEGMENT_ZONE,
				order: SEGMENT_ORDER,
			});

		if (!accepted) clearSegment();
		ignoreStaleContext(() =>
			ctx.ui.setStatus(STATUS_ID, accepted ? undefined : `${rt.icon} ${text}`),
		);
	}

	pi.on("session_start", async (_e, ctx) => {
		dbg?.("session.start");
		generation++;
		hasUI = ctx.hasUI;
		const setting = resolveSetting(pi, ctx, VERSION_TIMEOUT_SETTING);
		versionTimeoutMs = setting.ok
			? setting.value
			: VERSION_TIMEOUT_SETTING.default;
		if (!setting.ok && hasUI)
			ctx.ui.notify(
				`${setting.error}; using ${VERSION_TIMEOUT_SETTING.default} ms`,
				"warning",
			);
		void refresh(ctx);
	});

	pi.on("agent_end", async (_e, ctx) => {
		if (hasUI) void refresh(ctx);
	});
	pi.on("session_shutdown", async (_e, ctx) => {
		generation++;
		clearSegment();
		ignoreStaleContext(() => {
			if (ctx.hasUI) ctx.ui.setStatus(STATUS_ID, undefined);
		});
		dbg?.("session.shutdown");
		closeDebug();
	});
}