/** * Biome CLI-based linter client. * Uses Biome's CLI with JSON output instead of LSP (which has stale diagnostics issues). */ import path from "node:path"; import type { Diagnostic, DiagnosticSeverity, LinterClient, ServerConfig } from "../../lsp/types"; // ============================================================================= // Biome JSON Output Types // ============================================================================= interface BiomeJsonOutput { diagnostics: BiomeDiagnostic[]; } interface BiomeDiagnostic { category: string; // e.g., "lint/correctness/noUnusedVariables" severity: "error" | "warning" | "info" | "hint"; description: string; location?: { path?: { file: string }; span?: [number, number]; // [startOffset, endOffset] in bytes sourceCode?: string; }; } // ============================================================================= // Helpers // ============================================================================= /** * Convert byte offset to line:column using source code. */ function offsetToPosition(source: string, offset: number): { line: number; column: number } { let line = 1; let column = 1; let byteIndex = 0; for (const ch of source) { const byteLen = Buffer.byteLength(ch); if (byteIndex + byteLen > offset) { break; } if (ch === "\n") { line++; column = 1; } else { column++; } byteIndex += byteLen; } return { line, column }; } /** * Parse Biome severity to LSP DiagnosticSeverity. */ function parseSeverity(severity: string): DiagnosticSeverity { switch (severity) { case "error": return 1; case "warning": return 2; case "info": return 3; case "hint": return 4; default: return 2; } } /** * Run a Biome CLI command. */ async function runBiome( args: string[], cwd: string, resolvedCommand?: string, ): Promise<{ stdout: string; stderr: string; success: boolean }> { const command = resolvedCommand ?? "biome"; try { const proc = Bun.spawn([command, ...args], { cwd, stdout: "pipe", stderr: "pipe", windowsHide: true, }); const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); const exitCode = await proc.exited; return { stdout, stderr, success: exitCode === 0 }; } catch (err) { return { stdout: "", stderr: String(err), success: false }; } } // ============================================================================= // Biome Client // ============================================================================= /** * Biome CLI-based linter client. * Parses Biome's --reporter=json output into LSP Diagnostic format. */ export class BiomeClient implements LinterClient { /** Factory method for creating BiomeClient instances */ static create(config: ServerConfig, cwd: string): LinterClient { return new BiomeClient(config, cwd); } constructor( private readonly config: ServerConfig, private readonly cwd: string, ) {} async format(filePath: string, content: string): Promise { // Write content to file first await Bun.write(filePath, content); // Run biome format --write const result = await runBiome(["format", "--write", filePath], this.cwd, this.config.resolvedCommand); if (result.success) { // Read back formatted content return await Bun.file(filePath).text(); } // Format failed, return original return content; } async lint(filePath: string): Promise { // Run biome lint with JSON reporter const result = await runBiome(["lint", "--reporter=json", filePath], this.cwd, this.config.resolvedCommand); return this.#parseJsonOutput(result.stdout, filePath); } /** * Parse Biome's JSON output into LSP Diagnostics. */ #parseJsonOutput(jsonOutput: string, targetFile: string): Diagnostic[] { const diagnostics: Diagnostic[] = []; try { const parsed: BiomeJsonOutput = JSON.parse(jsonOutput); for (const diag of parsed.diagnostics) { const location = diag.location; if (!location?.path?.file) continue; // Resolve file path const diagFile = path.isAbsolute(location.path.file) ? location.path.file : path.join(this.cwd, location.path.file); // Only include diagnostics for the target file if (path.resolve(diagFile) !== path.resolve(targetFile)) { continue; } // Convert byte offset to line:column let startLine = 1; let startColumn = 1; let endLine = 1; let endColumn = 1; if (location.span && location.sourceCode) { const startPos = offsetToPosition(location.sourceCode, location.span[0]); const endPos = offsetToPosition(location.sourceCode, location.span[1]); startLine = startPos.line; startColumn = startPos.column; endLine = endPos.line; endColumn = endPos.column; } diagnostics.push({ range: { start: { line: startLine - 1, character: startColumn - 1 }, end: { line: endLine - 1, character: endColumn - 1 }, }, severity: parseSeverity(diag.severity), message: diag.description, source: "biome", code: diag.category, }); } } catch { // JSON parse failed, return empty } return diagnostics; } dispose(): void { // Nothing to dispose for CLI client } }