UNPKG

pi-lens

Version:

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

192 lines (191 loc) • 7.69 kB
/** * Biome Client for pi-lens * * All-in-one: formatting + linting for JS/TS/JSX/TSX/CSS/JSON * Replaces Prettier with 15-50x faster Rust-based tool. * * Requires: npm install @biomejs/biome (or npx @biomejs/biome) * Docs: https://biomejs.dev/ */ import * as fs from "node:fs"; import * as path from "node:path"; import { isFileKind } from "./file-kinds.js"; import { getGlobalPiLensDir } from "./file-utils.js"; import { findGlobalBinary } from "./package-manager.js"; import { safeSpawnAsync } from "./safe-spawn.js"; // --- Client --- export class BiomeClient { biomeAvailable = null; // Per-cwd cache of the resolved biome binary. Keying by cwd matters in // monorepos where different sub-packages each ship their own biome // installation; sharing one slot across the whole client would cause // the first resolution to win and stale across other packages. localBinaryByCwd = new Map(); // The binary path written by `ensureTool("biome")` — genuinely global // (lives under ~/.pi-lens/tools), so it's stored separately from the // per-cwd cache and used as a final fallback before npx. autoInstalledBinaryPath = null; ensureInFlight = null; log; constructor(verbose = false) { this.log = verbose ? (msg) => console.error(`[biome] ${msg}`) : () => { }; } /** * Resolve the fastest available biome binary for `cwd`. * Prefers local node_modules/.bin/biome (skip npx overhead ~1s). * Falls back to ~/.pi-lens/tools, then npx. * * In monorepos, callers should pass the project / sub-package root for the * edited file (typically `path.dirname(absolutePath)`). Omitting `cwd` * falls back to `process.cwd()`, which is wrong when pi is invoked from * a different directory than the file being edited. */ async getBiomeBinary(cwd) { const resolveCwd = cwd ?? process.cwd(); const cached = this.localBinaryByCwd.get(resolveCwd); if (cached) return { cmd: cached, args: [] }; if (this.autoInstalledBinaryPath) { return { cmd: this.autoInstalledBinaryPath, args: [] }; } // Walk up from cwd looking for node_modules/.bin/biome. // Also check ~/.pi-lens/tools (where ensureTool("biome") auto-installs), // so we avoid the ~1.5s `npx @biomejs/biome --version` fallback when // the tool is already installed but not in the project's node_modules. // On Windows prefer .cmd (native batch) over the sh wrapper — 2x faster. const isWin = process.platform === "win32"; const piLensBin = path.join(getGlobalPiLensDir(), "tools", "node_modules", ".bin"); const candidates = isWin ? [ path.join(resolveCwd, "node_modules", ".bin", "biome.cmd"), path.join(resolveCwd, "node_modules", ".bin", "biome"), path.join(piLensBin, "biome.cmd"), path.join(piLensBin, "biome"), ] : [ path.join(resolveCwd, "node_modules", ".bin", "biome"), path.join(resolveCwd, "node_modules", ".bin", "biome.cmd"), path.join(piLensBin, "biome"), ]; for (const p of candidates) { if (fs.existsSync(p)) { this.localBinaryByCwd.set(resolveCwd, p); return { cmd: p, args: [] }; } } // Any package manager's global bin dir (npm/pnpm/yarn/bun) before npx — // catches `pnpm add -g @biomejs/biome` installs that PATH misses (#375). const global = await findGlobalBinary("biome"); if (global) { this.localBinaryByCwd.set(resolveCwd, global); return { cmd: global, args: [] }; } // Fallback: npx (slower but works anywhere) return { cmd: "npx", args: ["@biomejs/biome"] }; } async spawnBiomeAsync(args, timeout = 15000, cwd) { const { cmd, args: prefix } = await this.getBiomeBinary(cwd); return safeSpawnAsync(cmd, [...prefix, ...args], { timeout }); } /** * Ensure Biome is available, auto-installing if necessary. * Prefer this over isAvailable() for auto-install behavior. * * Re-entrancy safe: concurrent first-time callers share a single * `ensureInFlight` promise so probing/auto-install isn't duplicated. * Mirrors the dedupe pattern in `SgRunner` / `KnipClient` / * `DependencyChecker`. */ async ensureAvailable() { if (this.biomeAvailable !== null) return this.biomeAvailable; if (this.ensureInFlight) return this.ensureInFlight; this.ensureInFlight = this.doEnsureAvailable(); try { return await this.ensureInFlight; } finally { this.ensureInFlight = null; } } async doEnsureAvailable() { // Check if already available const result = await this.spawnBiomeAsync(["--version"], 10000); if (!result.error && result.status === 0) { this.biomeAvailable = true; return true; } // Auto-install via pi-lens installer this.log("Biome not found, attempting auto-install..."); const { ensureTool } = await import("./installer/index.js"); const installedPath = await ensureTool("biome"); if (installedPath) { this.log(`Biome auto-installed: ${installedPath}`); // Set the installed path as the global fallback so every cwd // reaches it after its own per-package lookup misses. this.autoInstalledBinaryPath = installedPath; this.biomeAvailable = true; return true; } this.log("Biome auto-install failed"); this.biomeAvailable = false; return false; } /** * Check if a file is supported by Biome */ isSupportedFile(filePath) { return isFileKind(filePath, ["jsts", "json", "css"]); } /** * Async auto-fix variant for pipeline use (non-blocking spawn). */ async fixFileAsync(filePath) { if (!(await this.ensureAvailable())) { return { success: false, changed: false, fixed: 0, error: "Biome not available", }; } const absolutePath = path.resolve(filePath); if (!fs.existsSync(absolutePath)) { return { success: false, changed: false, fixed: 0, error: "File not found", }; } try { const before = await fs.promises.readFile(absolutePath, "utf-8"); const result = await this.spawnBiomeAsync(["lint", "--write", absolutePath], 15000, path.dirname(absolutePath)); if (result.error) { return { success: false, changed: false, fixed: 0, error: result.error.message, }; } const after = await fs.promises.readFile(absolutePath, "utf-8"); const changed = before !== after; if (changed) { this.log(`Fixed issue(s) in ${path.basename(filePath)}`); } return { success: true, changed, fixed: changed ? 1 : 0 }; } catch (err) { return { success: false, changed: false, fixed: 0, error: err instanceof Error ? err.message : String(err), }; } } }