UNPKG

pi-lens

Version:

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

977 lines (976 loc) • 33.2 kB
/** * Formatter Definitions for pi-lens * * Auto-detects formatters based on: * - Config files (biome.json, .prettierrc, etc.) * - Dependencies (package.json, requirements.txt, etc.) * - Binary availability (which/where) * * Inspired by OpenCode's formatter.ts pattern */ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { logLatency } from "./latency-logger.js"; import { findGlobalBinary } from "./package-manager.js"; import { safeSpawnAsync } from "./safe-spawn.js"; import { getAutoInstallToolIdForFormatter, getFormatterPolicyForFile, getSmartDefaultFormatterName, hasBiomeConfig, hasBlackConfig, hasClangFormatConfig, hasCljfmtConfig, hasCmakeFormatConfig, hasGoogleJavaFormatConfig, hasKtfmtConfig, hasNearestPackageJsonDependency, hasNearestPackageJsonField, hasOcamlformatConfig, hasOxfmtConfig, hasPhpCsFixerConfig, hasPrettierConfig, hasRubocopConfig, hasRuffConfig, hasSqlfluffConfig, hasStandardrbConfig, hasStyluaConfig, hasVitePlusConfig, } from "./tool-policy.js"; const _lazyInstallAttempts = new Set(); export async function tryLazyInstallFormatterTool(tool, cwd) { const attemptKey = `${tool}:${cwd}`; if (_lazyInstallAttempts.has(attemptKey)) return false; _lazyInstallAttempts.add(attemptKey); if (tool === "rubocop") { const res = await safeSpawnAsync("gem", ["install", "rubocop", "--no-document"], { timeout: 180000, cwd, ignoreAmbientSignal: true, }); const ok = !res.error && res.status === 0; if (!ok) { console.error(`[format] lazy-install rubocop failed: ${res.error?.message ?? res.stderr ?? "exit " + res.status}`); } return ok; } const res = await safeSpawnAsync("rustup", ["component", "add", "rustfmt"], { timeout: 180000, cwd, ignoreAmbientSignal: true, }); const ok = !res.error && res.status === 0; if (!ok) { console.error(`[format] lazy-install rustfmt failed: ${res.error?.message ?? res.stderr ?? "exit " + res.status}`); } return ok; } // --- Utility Functions --- async function fileExists(filePath) { try { await fs.access(filePath); return true; } catch { return false; } } async function findUp(targets, startDir, stopDir = path.parse(startDir).root) { const found = []; let currentDir = startDir; while (currentDir !== stopDir) { for (const target of targets) { const checkPath = path.join(currentDir, target); if (await fileExists(checkPath)) { found.push(checkPath); } } const parent = path.dirname(currentDir); if (parent === currentDir) break; currentDir = parent; } return found; } async function which(command) { const result = await safeSpawnAsync(process.platform === "win32" ? "where" : "which", [command], { timeout: 5000 }); if (result.error || result.status !== 0) return null; return result.stdout?.trim().split(/\r?\n/)[0] ?? null; } async function resolveGoFmtBinary() { const inPath = await which("gofmt"); if (inPath) return inPath; const goCheck = await safeSpawnAsync("go", ["env", "GOROOT"], { timeout: 5000, }); if (goCheck.error || goCheck.status !== 0) return null; const goroot = (goCheck.stdout ?? "").trim(); if (!goroot) return null; const binary = path.join(goroot, "bin", process.platform === "win32" ? "gofmt.exe" : "gofmt"); return (await fileExists(binary)) ? binary : null; } // --- Venv / Local Binary Helpers --- /** * Walk up from cwd looking for a binary in .venv or venv. * Returns the absolute path if found, null otherwise. */ async function findInVenv(binary, cwd) { const isWin = process.platform === "win32"; const candidates = isWin ? [ `.venv/Scripts/${binary}.exe`, `venv/Scripts/${binary}.exe`, `.venv/Scripts/${binary}`, `venv/Scripts/${binary}`, ] : [`.venv/bin/${binary}`, `venv/bin/${binary}`]; let dir = cwd; const root = path.parse(dir).root; while (dir !== root) { for (const candidate of candidates) { const full = path.join(dir, candidate); if (await fileExists(full)) return full; } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return null; } /** * Check vendor/bin for PHP Composer-managed tools. * Walks up from cwd to find vendor/bin/<binary>. */ async function findInVendorBin(binary, cwd) { const isWin = process.platform === "win32"; const names = isWin ? [`${binary}.bat`, binary] : [binary]; let dir = cwd; const root = path.parse(dir).root; while (dir !== root) { for (const name of names) { const full = path.join(dir, "vendor", "bin", name); if (await fileExists(full)) return full; } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return null; } /** * Check node_modules/.bin for locally installed Node tools. * Walks up from cwd to find node_modules/.bin/<binary>. */ async function findInNodeModules(binary, cwd) { const isWin = process.platform === "win32"; let dir = cwd; const root = path.parse(dir).root; while (dir !== root) { const candidates = isWin ? [ path.join(dir, "node_modules", ".bin", `${binary}.cmd`), path.join(dir, "node_modules", ".bin", binary), ] : [path.join(dir, "node_modules", ".bin", binary)]; for (const full of candidates) { if (await fileExists(full)) return full; } const parent = path.dirname(dir); if (parent === dir) break; dir = parent; } return null; } /** * Returns true if `bundle exec <gem>` should be used: * bundle binary is available AND Gemfile.lock exists in the tree. */ async function canUseBundleExec(cwd) { if ((await which("bundle")) === null) return false; const lockfiles = await findUp(["Gemfile.lock"], cwd); return lockfiles.length > 0; } async function resolveManagedSmartDefaultCommand(formatterName, filePath, args) { const toolId = getAutoInstallToolIdForFormatter(formatterName); if (!toolId) return null; const { ensureTool } = await import("./installer/index.js"); const installed = await ensureTool(toolId); if (!installed) return null; return [installed, ...args, filePath]; } function hasExplicitFormatterConfig(formatterName, cwd) { switch (formatterName) { case "biome": return hasBiomeConfig(cwd); case "prettier": return (hasPrettierConfig(cwd) || hasNearestPackageJsonField(cwd, "prettier")); case "oxfmt": return (hasOxfmtConfig(cwd) || hasVitePlusConfig(cwd) || // The published package is `oxfmt`; `@oxc-project/oxfmt` does not // exist on npm. Accept both (scoped kept for forward-compat). hasNearestPackageJsonDependency(cwd, "oxfmt") || hasNearestPackageJsonDependency(cwd, "@oxc-project/oxfmt")); case "ruff": return hasRuffConfig(cwd); case "black": return hasBlackConfig(cwd); case "sqlfluff": return hasSqlfluffConfig(cwd); case "rubocop": return hasRubocopConfig(cwd); case "standardrb": return hasStandardrbConfig(cwd); case "clang-format": return hasClangFormatConfig(cwd); case "php-cs-fixer": return hasPhpCsFixerConfig(cwd); case "stylua": return hasStyluaConfig(cwd); case "ocamlformat": return hasOcamlformatConfig(cwd); case "google-java-format": return hasGoogleJavaFormatConfig(cwd); case "ktfmt": return hasKtfmtConfig(cwd); case "cljfmt": return hasCljfmtConfig(cwd); case "cmake-format": return hasCmakeFormatConfig(cwd); default: return false; } } // --- Formatter Definitions --- async function hasEditorConfig(cwd) { try { await fs.access(path.join(cwd, ".editorconfig")); return true; } catch { return false; } } export const biomeFormatter = { name: "biome", command: ["npx", "@biomejs/biome", "format", "--write", "$FILE"], async resolveCommand(filePath, cwd) { const editorConfigFlag = (await hasEditorConfig(cwd)) ? ["--use-editorconfig=true"] : []; const local = await findInNodeModules("biome", cwd); if (local) return [local, "format", "--write", ...editorConfigFlag, filePath]; // Any package manager's global bin dir (npm/pnpm/yarn/bun) before we // auto-install — catches a `pnpm add -g @biomejs/biome` PATH misses (#375). const global = await findGlobalBinary("biome"); if (global) return [global, "format", "--write", ...editorConfigFlag, filePath]; const toolId = getAutoInstallToolIdForFormatter("biome"); if (!toolId) return null; const { ensureTool } = await import("./installer/index.js"); const installed = await ensureTool(toolId); if (installed) return [installed, "format", "--write", ...editorConfigFlag, filePath]; return null; }, extensions: [ ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".json", ".jsonc", ".css", ".scss", ".sass", ".vue", ".svelte", ".html", ".htm", ], async detect(cwd) { return (hasBiomeConfig(cwd) || hasNearestPackageJsonDependency(cwd, "@biomejs/biome")); }, }; export const prettierFormatter = { name: "prettier", command: ["npx", "prettier", "--write", "$FILE"], async resolveCommand(filePath, cwd) { const local = await findInNodeModules("prettier", cwd); if (local) return [local, "--write", filePath]; // Global bin of any manager (npm/pnpm/yarn/bun) before auto-install (#375). const global = await findGlobalBinary("prettier"); if (global) return [global, "--write", filePath]; return resolveManagedSmartDefaultCommand("prettier", filePath, ["--write"]); }, extensions: [ ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".json", ".jsonc", ".css", ".scss", ".sass", ".less", ".vue", ".svelte", ".html", ".htm", ".md", ".mdx", ".yaml", ".yml", ".graphql", ".gql", ], async detect(cwd) { return (hasPrettierConfig(cwd) || hasNearestPackageJsonDependency(cwd, "prettier") || hasNearestPackageJsonField(cwd, "prettier")); }, }; export const oxfmtFormatter = { name: "oxfmt", command: ["oxfmt", "$FILE"], async resolveCommand(filePath, cwd) { if (hasVitePlusConfig(cwd)) { const localVp = await findInNodeModules("vp", cwd); if (localVp) return [localVp, "fmt", filePath, "--write"]; const globalVp = await which("vp"); if (globalVp) return [globalVp, "fmt", filePath, "--write"]; } const local = await findInNodeModules("oxfmt", cwd); if (local) return [local, filePath]; const found = await which("oxfmt"); if (found) return [found, filePath]; return null; }, extensions: [ ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".vue", ".css", ".scss", ".less", ".html", ".htm", ".json", ".jsonc", ".yaml", ".yml", ".md", ".mdx", ".graphql", ".gql", ".toml", ], async detect(cwd) { return (hasOxfmtConfig(cwd) || hasVitePlusConfig(cwd) || // Published package is `oxfmt` (the scoped name does not exist on npm). hasNearestPackageJsonDependency(cwd, "oxfmt") || hasNearestPackageJsonDependency(cwd, "@oxc-project/oxfmt")); }, }; export const ruffFormatter = { name: "ruff", command: ["ruff", "format", "$FILE"], extensions: [".py", ".pyi"], async resolveCommand(filePath, cwd) { const venv = await findInVenv("ruff", cwd); if (venv) return [venv, "format", filePath]; const toolId = getAutoInstallToolIdForFormatter("ruff"); if (!toolId) return null; const { ensureTool } = await import("./installer/index.js"); const installed = await ensureTool(toolId); if (installed) return [installed, "format", filePath]; return null; }, async detect(cwd) { if (hasRuffConfig(cwd)) return true; // No-config fallback: if Ruff is already available, allow formatter usage. // This keeps Python default behavior consistent with startup defaults. const { getToolPath } = await import("./installer/index.js"); const installed = await getToolPath("ruff"); return Boolean(installed); }, }; export const blackFormatter = { name: "black", command: ["black", "$FILE"], extensions: [".py", ".pyi"], async resolveCommand(filePath, cwd) { const venv = await findInVenv("black", cwd); if (venv) return [venv, filePath]; return null; }, async detect(cwd) { return hasBlackConfig(cwd); }, }; export const sqlfluffFormatter = { name: "sqlfluff", command: ["sqlfluff", "fix", "--force", "$FILE"], extensions: [".sql"], async resolveCommand(filePath, cwd) { const venv = await findInVenv("sqlfluff", cwd); if (venv) return [venv, "fix", "--force", filePath]; return null; }, async detect(cwd) { return hasSqlfluffConfig(cwd); }, }; export const gofmtFormatter = { name: "gofmt", command: ["gofmt", "-w", "$FILE"], extensions: [".go"], async resolveCommand(filePath, _cwd) { const gofmtBinary = await resolveGoFmtBinary(); if (!gofmtBinary) return null; return [gofmtBinary, "-w", filePath]; }, async detect(_cwd) { return (await resolveGoFmtBinary()) !== null; }, }; export const rustfmtFormatter = { name: "rustfmt", command: ["rustfmt", "$FILE"], extensions: [".rs"], async detect(cwd) { if ((await which("rustfmt")) !== null) return true; // If we're in a Rust project, attempt one lazy install of rustfmt component. const rustProject = (await findUp(["Cargo.toml"], cwd)).length > 0; if (!rustProject) return false; if ((await which("rustup")) === null) return false; await tryLazyInstallFormatterTool("rustfmt", cwd); return (await which("rustfmt")) !== null; }, }; export const zigFormatter = { name: "zig", command: ["zig", "fmt", "$FILE"], extensions: [".zig", ".zon"], async detect(_cwd) { return (await which("zig")) !== null; }, }; export const dartFormatter = { name: "dart", command: ["dart", "format", "$FILE"], extensions: [".dart"], async detect(_cwd) { return (await which("dart")) !== null; }, }; export const shfmtFormatter = { name: "shfmt", command: ["shfmt", "-w", "$FILE"], extensions: [".sh", ".bash"], async resolveCommand(filePath, _cwd) { const inPath = await which("shfmt"); if (inPath) return [inPath, "-w", filePath]; return resolveManagedSmartDefaultCommand("shfmt", filePath, ["-w"]); }, async detect(_cwd) { if ((await which("shfmt")) !== null) return true; const { getToolPath } = await import("./installer/index.js"); return Boolean(await getToolPath("shfmt")); }, }; export const nixfmtFormatter = { name: "nixfmt", command: ["nixfmt", "$FILE"], extensions: [".nix"], async detect(_cwd) { return (await which("nixfmt")) !== null; }, }; export const mixFormatter = { name: "mix", command: ["mix", "format", "$FILE"], extensions: [".ex", ".exs", ".eex", ".heex", ".leex"], async detect(_cwd) { return (await which("mix")) !== null; }, }; export const ocamlformatFormatter = { name: "ocamlformat", command: ["ocamlformat", "-i", "$FILE"], extensions: [".ml", ".mli"], async detect(cwd) { const hasBinary = (await which("ocamlformat")) !== null; if (!hasBinary) return false; const configs = [".ocamlformat"]; const found = await findUp(configs, cwd); return found.length > 0; }, }; export const clangFormatFormatter = { name: "clang-format", command: ["clang-format", "-i", "$FILE"], extensions: [".c", ".cc", ".cpp", ".cxx", ".h", ".hpp", ".ino"], async detect(cwd) { const hasBinary = (await which("clang-format")) !== null; if (!hasBinary) return false; const configs = [".clang-format", "_clang-format"]; const found = await findUp(configs, cwd); return found.length > 0; }, }; export const ktlintFormatter = { name: "ktlint", command: ["ktlint", "-F", "$FILE"], extensions: [".kt", ".kts"], async resolveCommand(filePath, _cwd) { const inPath = await which("ktlint"); if (inPath) return [inPath, "-F", filePath]; return resolveManagedSmartDefaultCommand("ktlint", filePath, ["-F"]); }, async detect(_cwd) { if ((await which("ktlint")) !== null) return true; const { getToolPath } = await import("./installer/index.js"); return Boolean(await getToolPath("ktlint")); }, }; export const ktfmtFormatter = { name: "ktfmt", // ktfmt formats in place when given a file path (no flag needed). command: ["ktfmt", "$FILE"], extensions: [".kt", ".kts"], async resolveCommand(filePath, _cwd) { const inPath = await which("ktfmt"); if (inPath) return [inPath, filePath]; const { ensureTool } = await import("./installer/index.js"); const installed = await ensureTool("ktfmt"); return installed ? [installed, filePath] : null; }, async detect(cwd) { // Opt-in only: ktfmt becomes the formatter when the project elects it, // otherwise ktlint stays the Kotlin smart-default (#129). return hasKtfmtConfig(cwd); }, }; export const rubocopFormatter = { name: "rubocop", command: ["rubocop", "-a", "--no-color", "$FILE"], extensions: [".rb", ".rake", ".gemspec", ".ru"], async resolveCommand(filePath, cwd) { if (await canUseBundleExec(cwd)) return ["bundle", "exec", "rubocop", "-a", "--no-color", filePath]; return null; }, async detect(cwd) { if (!hasRubocopConfig(cwd)) return false; if ((await which("rubocop")) !== null) return true; await tryLazyInstallFormatterTool("rubocop", cwd); return (await which("rubocop")) !== null; }, }; export const standardrbFormatter = { name: "standardrb", command: ["standardrb", "--fix", "$FILE"], extensions: [".rb", ".rake"], async resolveCommand(filePath, cwd) { if (await canUseBundleExec(cwd)) return ["bundle", "exec", "standardrb", "--fix", filePath]; return null; }, async detect(cwd) { if (!hasStandardrbConfig(cwd)) return false; return (await which("standardrb")) !== null; }, }; export const gleamFormatter = { name: "gleam", command: ["gleam", "format", "$FILE"], extensions: [".gleam"], async detect(cwd) { // Present if gleam.toml exists (any Gleam project) const found = await findUp(["gleam.toml"], cwd); if (found.length > 0) return (await which("gleam")) !== null; return false; }, }; export const terraformFormatter = { name: "terraform", command: ["terraform", "fmt", "$FILE"], extensions: [".tf", ".tfvars"], async detect(_cwd) { return (await which("terraform")) !== null; }, }; export const phpCsFixerFormatter = { name: "php-cs-fixer", command: ["php-cs-fixer", "fix", "$FILE"], extensions: [".php"], async resolveCommand(filePath, cwd) { const vendor = await findInVendorBin("php-cs-fixer", cwd); if (vendor) return [vendor, "fix", filePath]; return null; }, async detect(cwd) { const vendorBin = await findInVendorBin("php-cs-fixer", cwd); const globalBin = await which("php-cs-fixer"); if (!vendorBin && !globalBin) return false; // Only run if project has explicit config const configs = [".php-cs-fixer.php", ".php-cs-fixer.dist.php"]; const found = await findUp(configs, cwd); return found.length > 0; }, }; export const csharpierFormatter = { name: "csharpier", // CSharpier ≥1.0 is a standalone `csharpier format <file>`; the `dotnet // csharpier <file>` form was removed (a bare `dotnet csharpier` now errors // "a dotnet-prefixed executable with this name could not be found"). Keep the // legacy form as a fallback for CSharpier 0.x via resolveCommand. command: ["csharpier", "format", "$FILE"], extensions: [".cs"], async resolveCommand(filePath, _cwd) { if ((await which("csharpier")) !== null) { return ["csharpier", "format", filePath]; } // CSharpier 0.x: invoked through the dotnet driver. if ((await which("dotnet")) !== null) { const legacy = await safeSpawnAsync("dotnet", ["csharpier", "--version"], { timeout: 5000, }); if (!legacy.error && legacy.status === 0) { return ["dotnet", "csharpier", filePath]; } } return null; }, async detect(_cwd) { // CSharpier ≥1.0 standalone binary … if ((await which("csharpier")) !== null) return true; // … or the legacy dotnet-driver form (CSharpier 0.x). if ((await which("dotnet")) === null) return false; const result = await safeSpawnAsync("dotnet", ["csharpier", "--version"], { timeout: 5000, }); return !result.error && result.status === 0; }, }; export const fantomasFormatter = { name: "fantomas", command: ["fantomas", "$FILE"], extensions: [".fs", ".fsi", ".fsx"], async detect(_cwd) { return (await which("fantomas")) !== null; }, }; export const swiftformatFormatter = { name: "swiftformat", command: ["swiftformat", "$FILE"], extensions: [".swift"], async detect(_cwd) { return (await which("swiftformat")) !== null; }, }; export const styluaFormatter = { name: "stylua", command: ["stylua", "$FILE"], extensions: [".lua"], async detect(cwd) { if ((await which("stylua")) === null) return false; // Prefer explicit config but also run if binary is present in a Lua project const configs = ["stylua.toml", ".stylua.toml"]; const found = await findUp(configs, cwd); return found.length > 0; }, }; export const ormoluFormatter = { name: "ormolu", command: ["ormolu", "--mode", "inplace", "$FILE"], extensions: [".hs", ".lhs"], async detect(_cwd) { return (await which("ormolu")) !== null; }, }; export const taploFormatter = { name: "taplo", command: ["taplo", "fmt", "$FILE"], extensions: [".toml"], async resolveCommand(filePath, _cwd) { const inPath = await which("taplo"); if (inPath) return [inPath, "fmt", filePath]; return resolveManagedSmartDefaultCommand("taplo", filePath, ["fmt"]); }, async detect(_cwd) { if ((await which("taplo")) !== null) return true; const { getToolPath } = await import("./installer/index.js"); return Boolean(await getToolPath("taplo")); }, }; export const googleJavaFormatFormatter = { name: "google-java-format", command: ["google-java-format", "--replace", "$FILE"], extensions: [".java"], async detect(cwd) { if ((await which("google-java-format")) === null) return false; return hasGoogleJavaFormatConfig(cwd); }, }; export const cljfmtFormatter = { name: "cljfmt", command: ["cljfmt", "fix", "$FILE"], extensions: [".clj", ".cljc", ".cljs"], async detect(cwd) { if ((await which("cljfmt")) === null) return false; return hasCljfmtConfig(cwd); }, }; export const cmakeFormatFormatter = { name: "cmake-format", command: ["cmake-format", "-i", "$FILE"], extensions: [".cmake"], async detect(cwd) { if ((await which("cmake-format")) === null) return false; return hasCmakeFormatConfig(cwd); }, }; export const psscriptanalyzerFormatFormatter = { name: "psscriptanalyzer-format", command: [ "pwsh", "-Command", "Invoke-Formatter -ScriptDefinition (Get-Content -Raw '$FILE') | Set-Content '$FILE'", ], extensions: [".ps1", ".psm1", ".psd1"], async resolveCommand(filePath, _cwd) { const pwsh = (await which("pwsh")) ?? (await which("powershell")); if (!pwsh) return null; return [ pwsh, "-NoProfile", "-Command", `$content = Get-Content -Raw '${filePath}'; $formatted = Invoke-Formatter -ScriptDefinition $content; Set-Content -Path '${filePath}' -Value $formatted`, ]; }, async detect(_cwd) { const pwsh = (await which("pwsh")) ?? (await which("powershell")); if (!pwsh) return false; // Check PSScriptAnalyzer module is available const result = await safeSpawnAsync(pwsh, [ "-NoProfile", "-Command", "Get-Module -ListAvailable PSScriptAnalyzer | Select-Object -First 1 -ExpandProperty Name", ], { timeout: 5_000 }); return (result.stdout ?? "").includes("PSScriptAnalyzer"); }, }; // --- Registry --- const ALL_FORMATTERS = [ biomeFormatter, prettierFormatter, oxfmtFormatter, ruffFormatter, blackFormatter, sqlfluffFormatter, gofmtFormatter, rustfmtFormatter, zigFormatter, dartFormatter, shfmtFormatter, nixfmtFormatter, mixFormatter, ocamlformatFormatter, clangFormatFormatter, ktlintFormatter, ktfmtFormatter, terraformFormatter, phpCsFixerFormatter, csharpierFormatter, fantomasFormatter, swiftformatFormatter, styluaFormatter, ormoluFormatter, rubocopFormatter, standardrbFormatter, gleamFormatter, taploFormatter, googleJavaFormatFormatter, cljfmtFormatter, cmakeFormatFormatter, psscriptanalyzerFormatFormatter, ]; // Cache for detection results - stores array of enabled formatter names per cwd+ext const detectionCache = new Map(); // --- Public API --- export async function getFormattersForFile(filePath, cwd) { const ext = path.extname(filePath).toLowerCase(); const cacheKey = `${cwd}:${ext}`; // Check cache let cached = detectionCache.get(cwd); if (!cached) { cached = new Map(); detectionCache.set(cwd, cached); } if (cached.has(cacheKey)) { const enabledNames = cached.get(cacheKey); if (!enabledNames || enabledNames.length === 0) return []; // Return cached formatters by name (preserves priority order) return ALL_FORMATTERS.filter((f) => enabledNames.includes(f.name)); } // Detect formatters for this extension const matching = ALL_FORMATTERS.filter((f) => f.extensions.includes(ext)); const formatterPolicy = getFormatterPolicyForFile(filePath); const smartDefaultFormatterName = getSmartDefaultFormatterName(filePath); const candidateFormatters = formatterPolicy?.formatterNames?.length ? matching.filter((f) => formatterPolicy.formatterNames.includes(f.name)) : matching; let selected; if (formatterPolicy) { const explicitlyConfigured = candidateFormatters.filter((formatter) => hasExplicitFormatterConfig(formatter.name, cwd)); if (explicitlyConfigured.length > 0) { // A formatter with explicit project config was found — use it. // Prefer the policy's defaultFormatter only if it has explicit config, // otherwise pick the first explicitly-configured formatter. selected = formatterPolicy.defaultFormatter ? (explicitlyConfigured.find((f) => f.name === formatterPolicy.defaultFormatter) ?? explicitlyConfigured[0]) : explicitlyConfigured[0]; } else if (smartDefaultFormatterName) { // Reached only when explicitlyConfigured is empty, so no candidate // has explicit config. Safe to activate the smart-default. const smartDefaultFormatter = candidateFormatters.find((f) => f.name === smartDefaultFormatterName); if (smartDefaultFormatter) { const autoInstallToolId = getAutoInstallToolIdForFormatter(smartDefaultFormatter.name); if (autoInstallToolId || (await smartDefaultFormatter.detect(cwd))) { selected = smartDefaultFormatter; } } } } else { for (const formatter of candidateFormatters) { try { if (await formatter.detect(cwd)) { selected = formatter; break; } } catch (err) { // pi-lens-ignore: missing-error-propagation — optional formatter detection, skip on failure console.error(`[format] Detection failed for ${formatter.name}:`, err); } } } const enabled = selected ? [selected] : []; let selectionReason; if (!selected) { selectionReason = "none"; } else if (!formatterPolicy) { selectionReason = "detect"; } else { selectionReason = candidateFormatters.some((f) => hasExplicitFormatterConfig(f.name, cwd)) ? "explicit-config" : "smart-default"; } logLatency({ type: "phase", phase: "formatter_selected", filePath: filePath, durationMs: 0, metadata: { formatter: selected?.name ?? null, reason: selectionReason, cwd, }, }); // Store the list of enabled formatter names in cache const enabledNames = enabled.map((f) => f.name); cached.set(cacheKey, enabledNames); return enabled; } export function clearFormatterCache() { detectionCache.clear(); } export function clearFormatterRuntimeState() { detectionCache.clear(); _lazyInstallAttempts.clear(); } export async function formatFile(filePath, formatter) { try { const absolutePath = path.resolve(filePath); const cwd = path.dirname(absolutePath); const contentBefore = await fs.readFile(absolutePath, "utf-8"); // Resolve command: prefer local (venv/vendor/node_modules) over global const resolved = formatter.resolveCommand ? await formatter.resolveCommand(absolutePath, cwd) : null; const cmd = resolved ?? formatter.command.map((c) => c.replace("$FILE", absolutePath)); // Run formatter without blocking the event loop. const result = await safeSpawnAsync(cmd[0], cmd.slice(1), { timeout: 15000, cwd, }); if (result.error) { return { success: false, changed: false, error: result.error.message, }; } // Check if content changed const contentAfter = await fs.readFile(absolutePath, "utf-8"); const changed = contentBefore !== contentAfter; return { success: true, changed, }; } catch (err) { return { success: false, changed: false, error: err instanceof Error ? err.message : String(err), }; } } export function listAllFormatters() { return ALL_FORMATTERS.map((f) => f.name); }