pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
276 lines (275 loc) • 12.3 kB
JavaScript
/**
* Unified LSP Runner for pi-lens
*
* Handles type checking for ALL LSP-supported languages:
* - TypeScript/JavaScript (typescript-language-server)
* - Python (pyright/pylsp)
* - Go (gopls)
* - Rust (rust-analyzer)
* - Ruby, PHP, C#, Java, Kotlin, Swift, Dart, etc.
*
* Replaces language-specific runners (pyright, etc.) with a single
* unified runner that delegates to the LSP service.
*/
import { getLSPService } from "../../lsp/index.js";
import { RUNTIME_CONFIG } from "../../runtime-config.js";
import { PRIORITY } from "../priorities.js";
import { resolveRunnerPath } from "../runner-context.js";
import { convertLspDiagnostics } from "../utils/lsp-diagnostics.js";
import { enabledAuxiliaryLspServerIds, findAuxiliaryProfileForSource, isAuxiliaryDiagnosticSuppressed, } from "../auxiliary-lsp.js";
import { readFileContent } from "./utils.js";
const LSP_MAX_FILE_BYTES = RUNTIME_CONFIG.pipeline.lspMaxFileBytes;
const LSP_MAX_FILE_LINES = RUNTIME_CONFIG.pipeline.lspMaxFileLines;
const LSP_SPAWN_BUDGET_MS = RUNTIME_CONFIG.pipeline.lspSpawnBudgetMs;
// Diagnostics-wait cap for the dispatch lsp-runner. Bounded so a slow LSP
// (typescript-language-server on large monorepos has been observed >7 s)
// can't dominate the per-edit pipeline budget. Diagnostics that arrive
// after the cap still land in the client's cache and surface on the
// next edit. Overridable via PI_LENS_LSP_DIAGNOSTICS_MAX_WAIT_MS.
const LSP_DIAGNOSTICS_WAIT_MS = 2500;
const MAX_CODE_ACTION_LOOKUPS = 6;
const MAX_CODE_ACTION_TITLES = 3;
function normalizeActionTitle(title) {
return title.replace(/\s+/g, " ").trim();
}
function buildCodeActionSuggestion(actions) {
if (!actions.length)
return undefined;
const quickFixes = actions.filter((action) => action.kind?.startsWith("quickfix"));
if (!quickFixes.length)
return undefined;
const titles = Array.from(new Set(quickFixes
.map((action) => normalizeActionTitle(action.title))
.filter((title) => title.length > 0))).slice(0, MAX_CODE_ACTION_TITLES);
if (!titles.length)
return undefined;
return `LSP quick fixes: ${titles.join("; ")}`;
}
const lspRunner = {
id: "lsp",
appliesTo: [
"jsts",
"python",
"go",
"rust",
"ruby",
"cxx",
"cmake",
"shell",
"json",
"markdown",
"css",
"yaml",
"html",
"docker",
"php",
"powershell",
"prisma",
"csharp",
"fsharp",
"java",
"kotlin",
"swift",
"dart",
"lua",
"zig",
"haskell",
"elixir",
"gleam",
"ocaml",
"clojure",
"terraform",
"nix",
"toml",
],
priority: PRIORITY.LSP_PRIMARY,
enabledByDefault: true,
async run(ctx) {
const diagnosticPath = resolveRunnerPath(ctx.cwd, ctx.filePath);
// Only run if LSP is not disabled via --no-lsp
if (ctx.pi.getFlag("no-lsp")) {
return { status: "skipped", diagnostics: [], semantic: "none" };
}
const lspService = getLSPService();
// Fast capability check only — actual client creation happens when we
// open the file below.
if (!lspService.supportsLSP(ctx.filePath)) {
return { status: "skipped", diagnostics: [], semantic: "none" };
}
// Always sync current file content before reading diagnostics so dispatch
// does not operate on stale LSP snapshots.
let lspDiags = [];
let serverFailed = false;
// touchFile resolves to `undefined` when no LSP client was ready (a cold
// spawn that didn't complete in the budget, or LSP unavailable for this
// file) — distinct from `[]`, which means the server replied with zero.
let lspClientReady = true;
// True when touchFile ran but couldn't confirm its result within
// budget (notify write and/or diagnostics wait timed out on at least
// one spawned server) — an empty `lspDiags` in that case is NOT a
// confirmed clean result and must not be reported as one (#570).
let diagnosticsInconclusive = false;
let failureReason = "";
const content = readFileContent(ctx.filePath);
if (!content) {
return { status: "skipped", diagnostics: [], semantic: "none" };
}
const sizeBytes = Buffer.byteLength(content, "utf-8");
const lineCount = content.split("\n").length;
if (sizeBytes > LSP_MAX_FILE_BYTES || lineCount > LSP_MAX_FILE_LINES) {
return { status: "skipped", diagnostics: [], semantic: "none" };
}
// Cross-cutting auxiliary scanners (opengrep, …) attach alongside the
// primary language server when enabled — collected on the with-auxiliary
// path so their warm diagnostics merge into this same result.
const auxiliaryServerIds = enabledAuxiliaryLspServerIds((f) => ctx.pi.getFlag(f));
try {
const touched = await lspService.touchFile(ctx.filePath, content, {
diagnostics: "document",
collectDiagnostics: true,
clientScope: auxiliaryServerIds.length > 0 ? "with-auxiliary" : "primary",
auxiliaryServerIds,
maxClientWaitMs: LSP_SPAWN_BUDGET_MS,
maxDiagnosticsWaitMs: LSP_DIAGNOSTICS_WAIT_MS,
source: "dispatch-lsp-runner",
});
if (touched === undefined) {
lspClientReady = false;
}
else {
lspDiags = touched;
diagnosticsInconclusive = touched.inconclusive === true;
}
}
catch (err) {
serverFailed = true;
failureReason = err instanceof Error ? err.message : String(err);
if (failureReason.includes("spawn") ||
failureReason.includes("exited") ||
failureReason.includes("connection") ||
failureReason.includes("JSON RPC")) {
console.error(`[lsp-runner] LSP server failed for ${diagnosticPath}: ${failureReason}`);
}
}
if (serverFailed) {
return {
status: "failed",
failureKind: "server_error",
failureMessage: failureReason.slice(0, 200),
diagnostics: [
{
id: `lsp:server-error:0`,
message: `LSP server failed: ${failureReason}`,
filePath: diagnosticPath,
line: 1,
column: 1,
severity: "error",
semantic: "warning", // Don't block - fallback to other runners
tool: "lsp",
},
],
semantic: "warning",
};
}
if (!lspClientReady) {
// No answer from the LSP — reporting "succeeded with 0 diagnostics"
// would read as a clean bill of health when we simply didn't get a
// reply. Report "skipped" so the coverage notice can flag the gap and
// the next edit re-checks once the server has warmed; any diagnostics
// published late still land in the client cache and surface then.
return { status: "skipped", diagnostics: [], semantic: "none" };
}
if (diagnosticsInconclusive) {
// The touch ran and a client was ready, but the notify write and/or
// diagnostics wait hit their deadline before the server confirmed
// completion — `lspDiags` (even if non-empty) is not a trustworthy
// merged result. Same treatment as `!lspClientReady`: report
// "skipped" rather than "succeeded" with a possibly-incomplete
// diagnostics list, so the coverage notice flags the gap instead of
// the footer reading this as a confirmed clean/partial result (#570).
// Diagnostics that do arrive late still land in the client cache and
// surface on the next edit.
return { status: "skipped", diagnostics: [], semantic: "none" };
}
if (lspDiags.length === 0) {
return {
status: "succeeded",
diagnostics: [],
semantic: "none",
rawOutput: "no-diagnostics",
};
}
// Convert LSP diagnostics to our format
// Defensive: filter out malformed diagnostics that may lack range
const validLspDiags = lspDiags.filter((d) => d.range?.start?.line !== undefined);
const fixSuggestionByIndex = new Map();
const blockingDiagIndexes = validLspDiags
.map((d, idx) => ({ d, idx }))
.filter(({ d }) => d.severity === 1)
.slice(0, MAX_CODE_ACTION_LOOKUPS);
await Promise.all(blockingDiagIndexes.map(async ({ d, idx }) => {
try {
const start = d.range.start;
const end = d.range.end ?? d.range.start;
const actions = await lspService.codeAction(ctx.filePath, start.line, start.character, end.line, end.character);
const suggestion = buildCodeActionSuggestion(actions);
if (suggestion) {
fixSuggestionByIndex.set(idx, suggestion);
}
}
catch {
// Best-effort enrichment only; base diagnostics remain authoritative.
}
}));
const diagnostics = convertLspDiagnostics(validLspDiags, diagnosticPath, { fixSuggestionByIndex });
// convertLspDiagnostics maps validLspDiags 1:1, so re-tag any
// auxiliary-sourced diagnostics (opengrep emits source "Semgrep", …) with
// their tool id + semantic policy — language-server diagnostics keep "lsp".
// blockingAllowed is per-workspace (e.g. curated repo rules), computed once.
const blockingAllowedByProfile = new Map();
// Diagnostics dropped by the tool's NATIVE inline suppression (e.g. opengrep
// `# nosemgrep`, #441). Reuses `content` from the sync read above.
const suppressedIndices = new Set();
for (let i = 0; i < diagnostics.length; i++) {
const profile = findAuxiliaryProfileForSource(validLspDiags[i]?.source);
if (!profile)
continue;
if (isAuxiliaryDiagnosticSuppressed(validLspDiags[i], content)) {
suppressedIndices.add(i);
continue;
}
let blockingAllowed = blockingAllowedByProfile.get(profile);
if (blockingAllowed === undefined) {
blockingAllowed = profile.allowBlocking?.(ctx.cwd) ?? false;
blockingAllowedByProfile.set(profile, blockingAllowed);
}
const d = diagnostics[i];
d.tool = profile.tool;
d.semantic = profile.semantic(validLspDiags[i], { blockingAllowed });
if (d.semantic !== "blocking" && d.severity === "error") {
d.severity = "warning";
}
const defectClass = profile.defectClass?.(validLspDiags[i]);
if (defectClass)
d.defectClass = defectClass;
}
const keptDiagnostics = suppressedIndices.size
? diagnostics.filter((_, i) => !suppressedIndices.has(i))
: diagnostics;
const hasErrors = keptDiagnostics.some((d) => d.semantic === "blocking");
const resultSemantic = hasErrors
? "blocking"
: keptDiagnostics.length > 0
? "warning"
: "none";
return {
status: hasErrors ? "failed" : "succeeded",
// "failed" here means the file has blocking type errors — the check ran
// fine. Tag it so the smell analyzer doesn't read it as a runner crash.
failureKind: hasErrors ? "blocking_diagnostics" : undefined,
diagnostics: keptDiagnostics,
semantic: resultSemantic,
};
},
};
export default lspRunner;