import fs from "node:fs/promises"; import path from "node:path"; import type { ExtensionContext } from "@earendil-works/pi-coding-agent"; import { parse as parseJsonc } from "jsonc-parser"; import { getPiToolsSuiteUserConfigPath } from "../../config"; import { findUp } from "./paths"; import { askProjectConfigTrust, sha256 } from "./trust"; import type { ConfigLayer, LoadedConfig, LspConfigFile, LspServerConfig, MatchableConfig } from "./types"; function getPiConfigDir(): string | undefined { const configured = process.env.PI_CONFIG_DIR; return configured && configured.trim() !== "" ? configured : undefined; } function findProjectSuiteConfig(startDir: string): string | undefined { return findUp(startDir, path.join(".pi", "pi-tools-suite.jsonc")); } function asObject(value: unknown): Record | undefined { return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : undefined; } function cleanStringArray(value: unknown): string[] | undefined { if (!Array.isArray(value)) return undefined; return value.filter((item): item is string => typeof item === "string"); } function cleanStringRecord(value: unknown): Record | undefined { const object = asObject(value); if (!object) return undefined; const out: Record = {}; for (const [key, recordValue] of Object.entries(object)) { if (typeof recordValue === "string") out[key] = recordValue; } return out; } function cleanNumber(value: unknown): number | undefined { return typeof value === "number" && Number.isFinite(value) ? value : undefined; } function cleanBoolean(value: unknown): boolean | undefined { return typeof value === "boolean" ? value : undefined; } function cleanMatchable(object: Record, extra: Omit): T | undefined { if (typeof object.id !== "string" || object.id.trim() === "") return undefined; return { id: object.id, enabled: cleanBoolean(object.enabled), include: cleanStringArray(object.include), exclude: cleanStringArray(object.exclude), rootMarkers: cleanStringArray(object.rootMarkers), maxFileSizeBytes: cleanNumber(object.maxFileSizeBytes), ...extra, } as T; } function parseLspItems(parsed: unknown): LspServerConfig[] { const root = asObject(parsed) as LspConfigFile | undefined; const servers = Array.isArray(root?.servers) ? root.servers : []; const out: LspServerConfig[] = []; for (const item of servers) { const object = asObject(item); if (!object) continue; const bin = typeof object.bin === "string" ? object.bin : ""; if (object.enabled !== false && bin.trim() === "") continue; const cleaned = cleanMatchable(object, { bin, args: cleanStringArray(object.args), cwd: typeof object.cwd === "string" ? object.cwd : undefined, env: cleanStringRecord(object.env), config: typeof object.config === "string" ? object.config : undefined, languageIdByExtension: cleanStringRecord(object.languageIdByExtension), startupTimeoutMs: cleanNumber(object.startupTimeoutMs), diagnosticsWaitMs: cleanNumber(object.diagnosticsWaitMs), pullDiagnostics: cleanBoolean(object.pullDiagnostics), waitForPublishDiagnostics: cleanBoolean(object.waitForPublishDiagnostics), initializationOptions: object.initializationOptions, settings: object.settings, }); if (cleaned) out.push(cleaned); } return out; } function extractLspConfig(parsed: unknown): unknown { const root = asObject(parsed); if (!root) return undefined; return root.lsp; } async function readJsoncLayer(options: { scope: "global" | "project"; filePath: string; selectConfig?: (parsed: unknown) => unknown; parseItems: (parsed: unknown) => TItem[]; }): Promise | undefined> { let raw: string; try { raw = await fs.readFile(options.filePath, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } const parsed = parseJsonc(raw) as unknown; const selected = options.selectConfig ? options.selectConfig(parsed) : parsed; if (selected === undefined) return undefined; const items = options.parseItems(selected); if (items.length === 0) return undefined; return { scope: options.scope, path: options.filePath, dir: path.dirname(options.filePath), raw, hash: sha256(raw), items, }; } function mergeLayers(layers: ConfigLayer[]): TItem[] { const byId = new Map(); for (const layer of layers) { for (const item of layer.items) { if (item.enabled === false) { byId.delete(item.id); continue; } byId.set(item.id, item); } } return [...byId.values()]; } function binariesForLsp(items: LspServerConfig[]): string[] { return items.map((item) => item.bin).filter(Boolean); } export async function loadLspConfig(ctx: ExtensionContext): Promise> { const warnings: string[] = []; const layers: ConfigLayer[] = []; const piConfigDir = getPiConfigDir(); const globalPaths = [ getPiToolsSuiteUserConfigPath(process.env.HOME), piConfigDir ? path.join(piConfigDir, "pi-tools-suite.jsonc") : undefined, ].filter((item): item is string => typeof item === "string"); for (const globalPath of globalPaths) { try { const globalLayer = await readJsoncLayer({ scope: "global", filePath: globalPath, selectConfig: extractLspConfig, parseItems: parseLspItems }); if (globalLayer) layers.push(globalLayer); } catch (error) { warnings.push(`Failed to load global lsp config ${globalPath}: ${(error as Error).message}`); } } const projectPath = findProjectSuiteConfig(ctx.cwd); if (projectPath) { try { const projectLayer = await readJsoncLayer({ scope: "project", filePath: projectPath, selectConfig: extractLspConfig, parseItems: parseLspItems }); if (projectLayer) { const decision = await askProjectConfigTrust({ ctx, kind: "lsp", configPath: projectLayer.path, hash: projectLayer.hash, binaries: binariesForLsp(projectLayer.items), }); if (decision.trusted) layers.push(projectLayer); else warnings.push(`${projectLayer.path}: ${decision.reason ?? "project-local config rejected"}`); } } catch (error) { warnings.push(`Failed to load project lsp config: ${(error as Error).message}`); } } return { items: mergeLayers(layers), layers, warnings }; }