pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
977 lines (976 loc) • 33.2 kB
JavaScript
/**
* 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);
}