pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
250 lines (249 loc) • 9.2 kB
JavaScript
/**
* LSP Configuration for pi-lens
*
* Allows users to define custom LSP servers and override initialization options
* for built-in servers via configuration.
*
* Config file: .pi-lens/lsp.json (or .pi-lens.json, pi-lsp.json)
*
* Example — custom server:
* {
* "servers": {
* "my-server": {
* "name": "My Custom LSP",
* "extensions": [".myext"],
* "command": "my-lsp-server",
* "args": ["--stdio"],
* "rootMarkers": ["package.json"]
* }
* }
* }
*
* Example — override initializationOptions for a built-in server:
* {
* "serverOverrides": {
* "rust": {
* "initializationOptions": {
* "check": { "command": "clippy", "allTargets": true },
* "cargo": { "features": "all", "targetDir": true }
* }
* },
* "nix": {
* "initializationOptions": {
* "nixpkgs": { "expr": "import <nixpkgs> {}" },
* "options": {
* "home_manager": { "expr": "(builtins.getFlake (toString ./.)).homeConfigurations.me.options" }
* }
* }
* }
* }
* }
*
* The `initializationOptions` object is deep-merged onto the server's built-in
* defaults, so you only need to specify the keys you want to change or add.
* User-supplied values win on conflicts at every level of nesting.
*
* Server IDs match the `id` field of each built-in server definition in
* clients/lsp/server.ts (e.g. "rust", "nix", "bash", "python", "go", "ts").
*/
import fs from "node:fs/promises";
import path from "node:path";
import { launchLSP } from "./launch.js";
import { createRootDetector, LSP_SERVERS, } from "./server.js";
// --- Config Loading ---
const CONFIG_PATHS = [".pi-lens/lsp.json", ".pi-lens.json", "pi-lsp.json"];
/**
* Load LSP configuration from file
*/
export async function loadLSPConfig(cwd) {
let dir = path.resolve(cwd);
while (true) {
for (const configPath of CONFIG_PATHS) {
const fullPath = path.join(dir, configPath);
try {
const content = await fs.readFile(fullPath, "utf-8");
const config = JSON.parse(content);
return config;
}
catch {
// File doesn't exist or is invalid, try next
}
}
const parent = path.dirname(dir);
if (parent === dir)
break;
dir = parent;
}
return {};
}
// --- Custom Server Factory ---
/**
* Create LSPServerInfo from user configuration
*/
export function createCustomServer(config, id) {
return {
id,
name: config.name,
extensions: config.extensions,
root: config.rootMarkers
? createRootDetector(config.rootMarkers)
: async () => process.cwd(),
async spawn(root) {
const proc = await launchLSP(config.command, config.args ?? ["--stdio"], {
cwd: root,
env: config.env ? { ...process.env, ...config.env } : process.env,
});
return { process: proc };
},
};
}
// --- Registry Management ---
const EMPTY_CONFIG = {
customServers: [],
disabledServerIds: new Set(),
serverOverrides: new Map(),
};
const workspaceConfigs = new Map();
/** In-flight config initialization promises to prevent duplicate concurrent loads */
const configInFlight = new Map();
function normalizeWorkspacePath(cwd) {
return path.resolve(cwd);
}
function isSameOrChildPath(filePath, candidateRoot) {
if (filePath === candidateRoot)
return true;
return filePath.startsWith(`${candidateRoot}${path.sep}`);
}
function getConfigForFile(filePath) {
const resolvedFilePath = path.resolve(filePath);
let bestMatch;
for (const [root, config] of workspaceConfigs) {
if (!isSameOrChildPath(resolvedFilePath, root))
continue;
if (!bestMatch || root.length > bestMatch.root.length) {
bestMatch = { root, config };
}
}
return bestMatch?.config ?? EMPTY_CONFIG;
}
/**
* Initialize LSP configuration (call at session start)
* Deduplicates concurrent calls for the same workspace.
*/
export async function initLSPConfig(cwd) {
const normalizedCwd = normalizeWorkspacePath(cwd);
const existing = configInFlight.get(normalizedCwd);
if (existing)
return existing;
const promise = (async () => {
const config = await loadLSPConfig(cwd);
const customServers = [];
const disabledServerIds = new Set(config.disabledServers ?? []);
if (config.servers) {
for (const [id, serverConfig] of Object.entries(config.servers)) {
try {
const server = createCustomServer(serverConfig, id);
customServers.push(server);
}
catch {
// pi-lens-ignore: missing-error-propagation — per-server registration, skip bad entries
}
}
}
const serverOverrides = new Map();
if (config.serverOverrides) {
for (const [id, entry] of Object.entries(config.serverOverrides)) {
if (entry && typeof entry === "object" && !Array.isArray(entry)) {
const initOpts = entry.initializationOptions;
if (initOpts !== undefined &&
typeof initOpts === "object" &&
initOpts !== null &&
!Array.isArray(initOpts)) {
serverOverrides.set(id, {
initializationOptions: initOpts,
});
}
}
}
}
workspaceConfigs.set(normalizedCwd, {
customServers,
disabledServerIds,
serverOverrides,
});
})();
configInFlight.set(normalizedCwd, promise);
try {
await promise;
}
finally {
configInFlight.delete(normalizedCwd);
}
}
/**
* Get all available servers (built-in + custom, minus disabled)
*/
export function getAllServers(filePath) {
const config = filePath ? getConfigForFile(filePath) : EMPTY_CONFIG;
const all = [...LSP_SERVERS, ...config.customServers];
return all.filter((s) => !config.disabledServerIds.has(s.id));
}
/**
* Check if a server is disabled
*/
export function isServerDisabled(serverId, filePath) {
const config = filePath ? getConfigForFile(filePath) : EMPTY_CONFIG;
return config.disabledServerIds.has(serverId);
}
// --- Override getServersForFile to include custom servers
export function getServersForFileWithConfig(filePath) {
const ext = path.extname(filePath).toLowerCase();
const base = path.basename(filePath).toLowerCase();
return getAllServers(filePath).filter((server) => {
const extensions = server.extensions.map((value) => value.toLowerCase());
const extensionMatch = extensions.includes(ext) || extensions.includes(base);
if (!extensionMatch)
return false;
// #636: a server's extension match can be intentionally broader than what
// it can usefully act on (zizmor attaches to "yaml" but only ever reports
// on GitHub Actions workflow/action/dependabot paths). `pathFilter`, when
// present, is an ADDITIONAL narrowing gate — never a widening one.
return server.pathFilter ? server.pathFilter(filePath) : true;
});
}
/**
* The primary language server for a file (e.g. "typescript"), as opposed to a
* cross-cutting auxiliary scanner attached via clientScope "all"/
* "with-auxiliary" (ast-grep, opengrep, zizmor, typos, marksman, ...). `role`
* is only ever set to "auxiliary" on those auxiliary entries (see
* clients/lsp/server.ts) — undefined means a real language server. Used to
* split a file's diagnostics into "primary confirmation" vs "auxiliary
* findings" so a page of ast-grep/opengrep/marksman noise never buries
* whether the actual type checker/compiler confirmed the file clean.
*
* #646: extracted from `tools/lsp-diagnostics.ts` (where it originated) so
* `tools/lens-diagnostics.ts`'s `mode=full` sweep can share the exact same
* primary/auxiliary classification instead of hand-copying it — both tools
* now report the same primary-vs-auxiliary split for the same file.
*/
export function primaryServerId(filePath) {
return getServersForFileWithConfig(filePath).find((s) => s.role !== "auxiliary")
?.id;
}
/**
* Look up an initializationOptions override for a built-in server.
* Returns undefined when no config was loaded or no override was specified
* for this server ID.
*
* @param serverId Built-in server id (e.g. "rust", "nix", "bash")
* @param filePath Any file path within the project (used to locate the
* workspace config that was loaded for this directory tree)
*/
export function getServerInitOverride(serverId, filePath) {
return getConfigForFile(filePath).serverOverrides.get(serverId);
}
export function resetLSPConfigStateForTests() {
workspaceConfigs.clear();
}
// Re-export with config support
export { getAllServers as getServersForFile };