UNPKG

pi-lens

Version:

Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo

408 lines (407 loc) • 16.1 kB
/** * Shared runner utilities for pi-lens dispatch system * * Extracted common patterns from multiple runners to reduce duplication: * - Venv-aware command finders * - Availability checkers with caching * - Config file finders */ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { getGlobalPiLensDir } from "../../../file-utils.js"; import { ensureTool } from "../../../installer/index.js"; import { getServersForFileWithConfig, isServerDisabled, } from "../../../lsp/config.js"; import { findGlobalBinary } from "../../../package-manager.js"; import { safeSpawnAsync } from "../../../safe-spawn.js"; import { getToolCommandSpec, shouldAutoInstallTool, } from "../../../tool-policy.js"; /** * True when the LSP runner will cover `ctx.filePath` via the given PRIMARY server * id. Used by CLI runners that duplicate a linter a warm LSP already wraps * (taplo↔`toml` LSP = `taplo lsp`; shellcheck↔`bash` LSP runs shellcheck * internally) so they SELF-SKIP and stop double-reporting the same findings (#233) * — the same dormant-when-LSP-covers pattern the ast-grep napi runner uses. * * Non-spawning and conservative: honors the `no-lsp` kill switch + per-server * disable/config, and only matches when this server is the SELECTED primary for * the file (first non-auxiliary candidate). The caller additionally gates on tool * availability, so coverage never regresses when the LSP is absent/disabled. */ export function lspPrimaryCoversFile(ctx, serverId) { if (ctx.pi?.getFlag?.("no-lsp")) return false; if (isServerDisabled(serverId, ctx.filePath)) return false; const primary = getServersForFileWithConfig(ctx.filePath).find((s) => s.role !== "auxiliary"); return primary?.id === serverId; } /** * Walk up from startDir until we find a directory containing node_modules/.bin. * Returns all such roots found up to the filesystem root — not just the nearest — * so callers can search them all for a specific binary. */ function findNodeBinRoots(startDir) { const roots = []; let current = startDir; const fsRoot = path.parse(current).root; while (current !== fsRoot) { if (fs.existsSync(path.join(current, "node_modules", ".bin"))) { roots.push(current); } const parent = path.dirname(current); if (parent === current) break; current = parent; } return roots; } let _thisDir = path.dirname(fileURLToPath(import.meta.url)); if (typeof __dirname !== "undefined") { _thisDir = __dirname; } // Managed tools directory (~/.pi-lens/tools) — where ensureTool() installs binaries const _managedToolsDir = path.join(getGlobalPiLensDir(), "tools"); /** * Find a command in venv first, then fall back to global. * Checks common venv locations (.venv, venv) before trying global. */ export function createVenvFinder(command, windowsExt = "", quoteWindows = false) { return (cwd) => { const venvPaths = [ `.venv/bin/${command}`, `venv/bin/${command}`, `.venv/Scripts/${command}${windowsExt}`, `venv/Scripts/${command}${windowsExt}`, ]; for (const venvPath of venvPaths) { const fullPath = path.join(cwd, venvPath); if (fs.existsSync(fullPath)) { return quoteWindows && windowsExt ? `"${fullPath}"` : fullPath; } } // Fall back to global return command; }; } /** * Create a cached availability checker for a command. * The checker will look for the command in venv first, then global. * * `versionArgs` defaults to `["--version"]` but some tools reject that flag and * expose version under a subcommand instead (e.g. `zig version`, not * `zig --version`). Passing the wrong probe makes the runner silently skip on * every machine, so toolchains with a non-standard version command must override * this. */ export function createAvailabilityChecker(command, windowsExt = "", versionArgs = ["--version"]) { const cacheByCwd = new Map(); const inFlightByCwd = new Map(); const findCommand = createVenvFinder(command, windowsExt, true); function getCache(cwd) { const key = path.resolve(cwd || process.cwd()); const existing = cacheByCwd.get(key); if (existing) return existing; const created = { available: null, command: null }; cacheByCwd.set(key, created); return created; } async function isAvailableAsync(cwd) { const resolvedCwd = cwd || process.cwd(); const cache = getCache(resolvedCwd); if (cache.available !== null) return cache.available; const key = path.resolve(resolvedCwd); const existing = inFlightByCwd.get(key); if (existing) return existing; const promise = (async () => { const cmd = findCommand(resolvedCwd); const result = await safeSpawnAsync(cmd, versionArgs, { timeout: 5000, }); cache.available = !result.error && result.status === 0; if (cache.available) { cache.command = cmd; } return cache.available; })().finally(() => { inFlightByCwd.delete(key); }); inFlightByCwd.set(key, promise); return promise; } function getCommand(cwd) { const cache = getCache(cwd || process.cwd()); return cache.command; } return { isAvailableAsync, getCommand }; } /** * Per-cwd cached availability probe for spawn signatures that don't fit * `createAvailabilityChecker` — multi-arg subcommands like `npx biome * --version`, `cargo clippy --version`, `mix credo --version`, or a * dynamically-resolved `<cmd> --version`. Each cwd is probed at most once; * concurrent first-time callers share the in-flight promise. * * The cache stores the boolean outcome forever (same shape as * `createAvailabilityChecker`): once known unavailable for a cwd, the * runner stays skipped for that cwd until the process restarts. Pass a * fresh probe for retry-after-install flows. */ export function createCwdCachedProbe(probe) { const cacheByCwd = new Map(); return (cwd) => { const key = path.resolve(cwd || process.cwd()); const existing = cacheByCwd.get(key); if (existing) return existing; const promise = probe(key).catch(() => false); cacheByCwd.set(key, promise); return promise; }; } export function resolveNodeToolCommand(cwd, toolName, windowsExt = ".cmd") { const isWin = process.platform === "win32"; const binName = isWin ? `${toolName}${windowsExt}` : toolName; const local = path.join(cwd, "node_modules", ".bin", binName); if (fs.existsSync(local)) return local; return toolName; } export function resolveToolCommand(cwd, toolId) { const spec = getToolCommandSpec(toolId); if (!spec) return null; return resolveNodeToolCommand(cwd, spec.command, spec.windowsExt ?? ".cmd"); } export function resolveVendorToolCommand(cwd, toolName, windowsExt = ".bat") { const isWin = process.platform === "win32"; const candidates = isWin ? [ path.join("vendor", "bin", `${toolName}${windowsExt}`), path.join("vendor", "bin", toolName), ] : [path.join("vendor", "bin", toolName)]; let dir = cwd; const root = path.parse(dir).root; while (true) { for (const candidate of candidates) { const full = path.join(dir, candidate); if (fs.existsSync(full)) return full; } if (dir === root) break; const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return null; } export async function resolveToolCommandWithInstallFallback(cwd, toolId, timeout = 5000) { const spec = getToolCommandSpec(toolId); if (!spec) return null; return resolveCommandWithInstallFallback(resolveToolCommand(cwd, toolId) ?? spec.command, spec.managedToolId ?? toolId, cwd, spec.versionArgs ?? ["--version"], timeout); } async function verifyOrInstallCommand(command, toolId, cwd, versionArgs = ["--version"], timeout = 5000) { const versionCheck = await safeSpawnAsync(command, versionArgs, { timeout, cwd, }); if (!versionCheck.error && versionCheck.status === 0) { return command; } if (!shouldAutoInstallTool(toolId)) { return null; } const installed = await ensureTool(toolId); if (!installed) return null; const installedCheck = await safeSpawnAsync(installed, versionArgs, { timeout, cwd, }); if (installedCheck.error || installedCheck.status !== 0) { return null; } return installed; } export async function resolveCommandArgsWithInstallFallback(command, toolId, cwd, versionArgs = ["--version"], timeout = 5000) { const versionCheck = await safeSpawnAsync(command.cmd, [...command.args, ...versionArgs], { timeout, cwd }); if (!versionCheck.error && versionCheck.status === 0) { return command; } const installed = await verifyOrInstallCommand(command.cmd, toolId, cwd, versionArgs, timeout); if (!installed) { return null; } if (installed === command.cmd) { return command; } return { cmd: installed, args: [] }; } export async function resolveCommandWithInstallFallback(command, toolId, cwd, versionArgs = ["--version"], timeout = 5000) { return verifyOrInstallCommand(command, toolId, cwd, versionArgs, timeout); } export async function resolveAvailableOrInstall(checker, toolId, cwd) { const available = await checker.isAvailableAsync(cwd); if (available) { return checker.getCommand(cwd); } if (!shouldAutoInstallTool(toolId)) { return null; } const installed = await ensureTool(toolId); return installed ?? null; } // ============================================================================= // SHARED AST-GREP AVAILABILITY // ============================================================================= // Shared ast-grep availability cache across all slop runners let sgAvailable = null; let sgCmd = null; let sgCmdArgs = []; function isAstGrepVersionOutput(output) { return /\bast[- ]grep\b/i.test(output); } async function probeAstGrepCommandAsync(cmd, argsPrefix = []) { const check = await safeSpawnAsync(cmd, [...argsPrefix, "--version"], { timeout: 5000, }); return (!check.error && check.status === 0 && isAstGrepVersionOutput(`${check.stdout}\n${check.stderr}`)); } /** Pre-filter local node_modules/.bin candidates that actually exist on disk. */ function buildSgLocalBins() { const isWin = process.platform === "win32"; const hasBash = !!(process.env.MSYSTEM || process.env.GIT_SHELL || process.env.BASH); const extensions = isWin ? hasBash ? ["", ".exe", ".cmd"] : [".cmd", ".exe", ""] : [""]; const binaryCandidates = ["ast-grep", "sg"].flatMap((base) => extensions.map((ext) => `${base}${ext}`)); const binRoots = [ ...findNodeBinRoots(_thisDir), ...findNodeBinRoots(process.cwd()), _managedToolsDir, ]; const bins = []; for (const root of binRoots) { for (const candidate of binaryCandidates) { const localBin = path.join(root, "node_modules", ".bin", candidate); if (fs.existsSync(localBin)) bins.push(localBin); } } return bins; } let sgAvailableInFlight = null; export async function isSgAvailableAsync() { if (sgAvailable !== null) return sgAvailable; if (sgAvailableInFlight) return sgAvailableInFlight; sgAvailableInFlight = (async () => { // 1. Local node_modules/.bin for (const localBin of buildSgLocalBins()) { if (await probeAstGrepCommandAsync(localBin)) { sgCmd = localBin; sgCmdArgs = []; sgAvailable = true; return true; } } // 2. Global PATH for (const cmd of ["ast-grep", "sg"]) { if (await probeAstGrepCommandAsync(cmd)) { sgCmd = cmd; sgCmdArgs = []; sgAvailable = true; return true; } } // 2b. Any package manager's global bin dir (npm/pnpm/yarn/bun) — catches // `pnpm add -g @ast-grep/cli` installs whose bin dir is off PATH (#375). for (const name of ["ast-grep", "sg"]) { const globalBin = await findGlobalBinary(name); if (globalBin && (await probeAstGrepCommandAsync(globalBin))) { sgCmd = globalBin; sgCmdArgs = []; sgAvailable = true; return true; } } // 3. npx --no (cache-only, no silent download). if (await probeAstGrepCommandAsync("npx", ["--no", "--", "ast-grep"])) { sgCmd = "npx"; sgCmdArgs = ["--no", "--", "ast-grep"]; sgAvailable = true; return true; } sgAvailable = false; return false; })().finally(() => { sgAvailableInFlight = null; }); return sgAvailableInFlight; } export function getSgCommand() { return { cmd: sgCmd ?? "npx", args: sgCmdArgs.length ? sgCmdArgs : ["--no", "--", "ast-grep"], }; } // ============================================================================= // LOCAL-FIRST BINARY RESOLUTION // ============================================================================= /** * Find a tool binary preferring local node_modules/.bin, then any installed * package manager's global bin dir (npm/pnpm/yarn/bun), then global PATH. Only * falls back to `npx --no` as a last resort — the universal cache-only exec * (npx ships with node and never silently downloads), so this stays * manager-agnostic without risking a surprise `dlx` fetch on pnpm/yarn/bun. * * Returns: { cmd, args } where args may include the `["--no", toolName]` npx * preamble. */ export async function resolveLocalFirstAsync(toolName, cwd, windowsExt = ".cmd") { const isWin = process.platform === "win32"; const binName = isWin ? `${toolName}${windowsExt}` : toolName; // 1. Local node_modules/.bin (project-installed) const local = path.join(cwd, "node_modules", ".bin", binName); if (fs.existsSync(local)) return { cmd: local, args: [] }; // 2. Global bin dir of ANY installed manager (npm/pnpm/yarn/bun) — direct // file lookup, so it finds tools installed via `pnpm add -g` / `bun add -g` // (whose bin dirs are often off PATH) and survives PATH staleness after an // `install -g`. No spawn. const globalBin = await findGlobalBinary(toolName, windowsExt); if (globalBin) return { cmd: globalBin, args: [] }; // 3. Global PATH (already installed system-wide, on PATH) const globalCheck = await safeSpawnAsync(toolName, ["--version"], { timeout: 3000, }); if (!globalCheck.error && globalCheck.status === 0) { return { cmd: toolName, args: [] }; } // 4. npx --no fallback — universal cache-only exec (no silent download) return { cmd: "npx", args: ["--no", toolName] }; } // ============================================================================= // PRE-BUILT CHECKERS FOR COMMON TOOLS // ============================================================================= export const pyright = createAvailabilityChecker("pyright", ".exe"); export const ruff = createAvailabilityChecker("ruff", ".exe"); export const biome = createAvailabilityChecker("biome"); export const sg = { isAvailableAsync: isSgAvailableAsync, getCommand: getSgCommand, };