UNPKG

pi-lens

Version:

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

160 lines (159 loc) • 5.48 kB
/** * Ruff Client for pi-lens * * Fast Python linting and formatting via Ruff CLI. * Replaces flake8, pylint, isort, black, pyupgrade. * * Requires: pip install ruff * Docs: https://docs.astral.sh/ruff/ */ import * as fs from "node:fs"; import * as path from "node:path"; import { isFileKind } from "./file-kinds.js"; import { safeSpawnAsync } from "./safe-spawn.js"; // --- Client --- export class RuffClient { ruffAvailable = null; ensureInFlight = null; log; constructor(verbose = false) { this.log = verbose ? (msg) => console.error(`[ruff] ${msg}`) : () => { }; } /** * Check if ruff CLI is available, auto-install if not. * * 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.ruffAvailable !== null) return this.ruffAvailable; if (this.ensureInFlight) return this.ensureInFlight; this.ensureInFlight = this.doEnsureAvailable(); try { return await this.ensureInFlight; } finally { this.ensureInFlight = null; } } async doEnsureAvailable() { // Check if available in PATH const result = await safeSpawnAsync("ruff", ["--version"], { timeout: 5000, }); this.ruffAvailable = !result.error && result.status === 0; if (this.ruffAvailable) { this.log(`Ruff found: ${result.stdout.trim()}`); return true; } // Auto-install via pi-lens installer this.log("Ruff not found, attempting auto-install..."); const { ensureTool } = await import("./installer/index.js"); const installedPath = await ensureTool("ruff"); if (installedPath) { this.log(`Ruff auto-installed: ${installedPath}`); this.ruffAvailable = true; return true; } this.log("Ruff auto-install failed"); return false; } /** * Check if a file is a Python file */ isPythonFile(filePath) { return isFileKind(filePath, "python"); } /** * 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: "Ruff 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 pre = await safeSpawnAsync("ruff", [ "check", "--output-format", "json", "--target-version", "py310", absolutePath, ], { timeout: 10000 }); const beforeDiags = pre.stdout?.trim() ? this.parseOutput(pre.stdout, absolutePath) : []; const fixableCount = beforeDiags.filter((d) => d.fixable).length; const fix = await safeSpawnAsync("ruff", ["check", "--fix", absolutePath], { timeout: 15000 }); if (fix.error) { return { success: false, changed: false, fixed: 0, error: fix.error.message, }; } const after = await fs.promises.readFile(absolutePath, "utf-8"); const changed = before !== after; if (changed) { this.log(`Fixed ${fixableCount} issue(s) in ${path.basename(filePath)}`); } return { success: true, changed, fixed: fixableCount }; } catch (err) { return { success: false, changed: false, fixed: 0, error: err.message }; } } // --- Internal --- parseOutput(output, filterFile) { if (!output.trim()) return []; try { const items = JSON.parse(output); const diagnostics = []; for (const item of items) { // Filter to single file if requested if (filterFile && path.resolve(item.filename) !== filterFile) continue; diagnostics.push({ line: item.location.row - 1, // ruff is 1-indexed column: item.location.column - 1, endLine: item.end_location.row - 1, endColumn: item.end_location.column - 1, severity: item.code?.startsWith("E") ? "error" : "warning", message: item.message, rule: item.code || "unknown", file: item.filename, fixable: item.fix !== null, }); } return diagnostics; } catch (err) { void err; this.log("Failed to parse ruff JSON output"); return []; } } }