pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
119 lines (118 loc) • 4.93 kB
JavaScript
/**
* Pyright runner for dispatch system
*
* Provides real Python type-checking (not just linting).
* Catches type errors like: result: str = add(1, 2) # Type "int" not assignable to "str"
*
* Requires: pyright (pip install pyright or npm install -g pyright)
*/
import { ensureTool } from "../../installer/index.js";
import { getLSPService } from "../../lsp/index.js";
import { safeSpawnAsync } from "../../safe-spawn.js";
import { PRIORITY } from "../priorities.js";
import { createAvailabilityChecker } from "./utils/runner-helpers.js";
const pyright = createAvailabilityChecker("pyright", ".exe");
const pyrightRunner = {
id: "pyright",
appliesTo: ["python"],
priority: PRIORITY.LSP_FALLBACK,
enabledByDefault: true,
timeoutMs: 75_000,
async run(ctx) {
// Always allow pyright CLI fallback even when LSP is enabled.
// LSP can be present but still fail transiently for a file; in that case,
// pyright provides a resilient second signal path.
// When LSP is enabled (not disabled via --no-lsp), connect to the LSP service for this file
if (!ctx.pi.getFlag("no-lsp")) {
const lspService = getLSPService();
await lspService.getClientForFile(ctx.filePath);
}
const cwd = ctx.cwd || process.cwd();
// Get pyright command - try multiple strategies
let cmd = null;
// Strategy 1: Check cached availability (fast path)
if (await (pyright.isAvailableAsync(cwd))) {
cmd = pyright.getCommand(cwd);
}
// Strategy 2: Try to find pyright via ensureTool (installs if needed)
if (!cmd) {
const installedPath = await ensureTool("pyright");
if (installedPath)
cmd = installedPath;
}
// Strategy 3: Direct PATH check (handles module cache staleness)
if (!cmd) {
const { findCommandAsync } = await import("../../safe-spawn.js");
const foundCmd = await findCommandAsync("pyright");
if (foundCmd)
cmd = foundCmd;
}
// If still no pyright, skip this runner
if (!cmd) {
return { status: "skipped", diagnostics: [], semantic: "none" };
}
// Run pyright with JSON output. Pass cwd so pyright resolves
// pyrightconfig.json / the project venv from the project root rather than
// falling back to process.cwd() (which mis-resolves in multi-root setups).
const result = await safeSpawnAsync(cmd, ["--outputjson", ctx.filePath], {
timeout: 60000,
cwd,
});
// Pyright returns non-zero when errors found, that's OK
if (result.error) {
return { status: "skipped", diagnostics: [], semantic: "none" };
}
const output = (result.stdout || "").trim();
if (!output) {
return { status: "succeeded", diagnostics: [], semantic: "none" };
}
try {
const data = JSON.parse(output);
const diagnostics = parsePyrightOutput(data, ctx.filePath);
if (diagnostics.length === 0) {
return { status: "succeeded", diagnostics: [], semantic: "none" };
}
const hasErrors = diagnostics.some((d) => d.severity === "error");
return {
status: hasErrors ? "failed" : "succeeded",
diagnostics,
semantic: hasErrors
? "blocking"
: diagnostics.length > 0
? "warning"
: "none",
};
}
catch {
console.error(`[runner:pyright] JSON parse failed for ${ctx.filePath} — raw output: ${output.slice(0, 200)}`);
return {
status: "failed",
diagnostics: [],
semantic: "none",
rawOutput: output.slice(0, 500),
};
}
},
};
function parsePyrightOutput(data, _filePath) {
const diagnostics = [];
// Pyright JSON output has generalDiagnostics array
const generalDiags = data.generalDiagnostics || [];
for (const diag of generalDiags) {
// Skip if not for this file (pyright may output diagnostics for imports)
// For now, include all - caller will filter if needed
diagnostics.push({
id: `pyright-${diag.rule || diag.start?.line || "unknown"}`,
message: diag.message || "Type error",
filePath: diag.file || _filePath,
line: diag.start?.line || 0,
column: diag.start?.column || 0,
severity: diag.severity === "error" ? "error" : "warning",
semantic: diag.severity === "error" ? "blocking" : "warning",
tool: "pyright",
rule: diag.rule,
});
}
return diagnostics;
}
export default pyrightRunner;