import { execFile } from "node:child_process"; import { platform as currentPlatform } from "node:os"; import { promisify } from "node:util"; import type { CodemodeSettings } from "../config/settings.ts"; const execFileAsync = promisify(execFile); const probeTimeoutMs = 3000; export type CodemodeLanguage = "py" | "js" | "rb" | "ts"; export interface InterpreterDetected { readonly ok: true; readonly path: string; readonly version: string; } export interface InterpreterUnavailable { readonly ok: false; } export type InterpreterDetection = InterpreterDetected | InterpreterUnavailable; export interface ExecFileProbeOptions { readonly timeoutMs: number; } export type ExecFileProbe = ( command: string, args: readonly string[], options: ExecFileProbeOptions ) => Promise<{ readonly stdout: string; readonly stderr: string }>; export interface CreateInterpreterDetectorOptions { readonly execFile?: ExecFileProbe; readonly nodeVersion?: string; readonly platform?: NodeJS.Platform; } export interface InterpreterDetector { detect: (language: CodemodeLanguage) => Promise; } export interface LanguageAvailability { readonly detected: InterpreterDetection; readonly enabled: boolean; } export type InterpreterAvailability = { readonly [Language in CodemodeLanguage]: LanguageAvailability; }; const unavailable: InterpreterUnavailable = { ok: false }; export function createInterpreterDetector( options: CreateInterpreterDetectorOptions = {} ): InterpreterDetector { const probe = options.execFile ?? defaultExecFileProbe; const hostPlatform = options.platform ?? currentPlatform(); const nodeVersion = options.nodeVersion ?? process.versions.node; const cache = new Map>(); return { detect(language) { const cached = cache.get(language); if (cached !== undefined) { return cached; } const pending = detectUncached( language, hostPlatform, probe, nodeVersion ); cache.set(language, pending); return pending; }, }; } export async function getInterpreterAvailability( settings: CodemodeSettings, detector: InterpreterDetector ): Promise { return { py: await availabilityFor("py", settings.languages.py, detector), js: await availabilityFor("js", settings.languages.js, detector), rb: await availabilityFor("rb", settings.languages.rb, detector), ts: await availabilityFor("ts", settings.languages.ts, detector), }; } async function availabilityFor( language: CodemodeLanguage, enabled: boolean, detector: InterpreterDetector ): Promise { return { enabled, detected: enabled ? await detector.detect(language) : unavailable, }; } async function detectUncached( language: CodemodeLanguage, hostPlatform: NodeJS.Platform, probe: ExecFileProbe, nodeVersion: string ): Promise { // ts needs no binary probe: it type-checks and transpiles through the // TypeScript compiler API in the extension process, then runs on Node, // exactly like the js kernel. if (language === "js" || language === "ts") { return { ok: true, path: "node", version: nodeVersion }; } for (const candidate of candidatesFor(language, hostPlatform)) { const result = await probeCandidate(candidate, probe); if (result.ok) { return result; } } return unavailable; } async function probeCandidate( candidate: string, probe: ExecFileProbe ): Promise { const invocation = candidateInvocation(candidate); try { const result = await probe( invocation.command, [...invocation.args, "--version"], { timeoutMs: probeTimeoutMs } ); const version = parseVersion(`${result.stdout}\n${result.stderr}`); return version === null ? unavailable : { ok: true, path: candidate, version }; } catch { return unavailable; } } function candidatesFor( language: CodemodeLanguage, hostPlatform: NodeJS.Platform ): readonly string[] { if (language === "py") { return hostPlatform === "win32" ? ["python", "py -3", "python3"] : ["python3", "python"]; } if (language === "rb") { return ["ruby"]; } return []; } function candidateInvocation(candidate: string): { readonly command: string; readonly args: readonly string[]; } { const [command, ...args] = candidate.split(" "); return { command: command ?? candidate, args }; } function parseVersion(output: string): string | null { const match = /(?:Python|ruby)\s+(?:version\s+)?v?(\d+(?:\.\d+){1,3})/i.exec( output.trim() ); return match?.[1] ?? null; } async function defaultExecFileProbe( command: string, args: readonly string[], options: ExecFileProbeOptions ): Promise<{ readonly stdout: string; readonly stderr: string }> { const result = await execFileAsync(command, [...args], { timeout: options.timeoutMs, }); return { stdout: String(result.stdout), stderr: String(result.stderr), }; }