/** * LSP server configuration loader. * * Purely config-driven — no built-in servers and no language enabled by * default. Users define every server in their config files: * * ~/.pi/agent/extensions/lsp/config.json (global defaults) * .pi/lsp.json (project overrides) * * Project config merges on top of global. `disabled: true` disables a server. * `lsp: false` disables all LSP functionality. * * IO and command resolution are expressed as Effect programs against the * FileSystem / CommandResolver services. The `*Effect` functions are the real * implementations; the Promise-returning wrappers provide the live services and * exist for the imperative call sites (extension entry, commands, tests). */ import { Effect } from 'effect'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { CommandResolver } from './effects/command'; import { FileSystem } from './effects/filesystem'; import { makeRuntimeLayer } from './effects/runtime'; import type { ConfigWriteError } from './errors'; import type { LspConfigFile, LspServerUserConfig, ResolvedServerConfig } from './types'; // ── Paths ─────────────────────────────────────────────────────────────────── function globalConfigPath(): string { const home = process.platform === 'win32' ? homedir() : (process.env.HOME ?? homedir()); return join(home, '.pi', 'agent', 'extensions', 'lsp', 'config.json'); } function projectConfigPath(cwd: string): string { return join(cwd, '.pi', 'lsp.json'); } /** * Starter template scaffolded on first run. Every server is `disabled` so the * extension never auto-starts a language server the user did not opt into — * TypeScript is just one example among several, not a default. */ const STARTER_CONFIG = `{ "lsp": { "typescript": { "command": ["typescript-language-server", "--stdio"], "extensions": [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".mts", ".cts"], "disabled": true }, "pyright": { "command": ["pyright-langserver", "--stdio"], "extensions": [".py"], "disabled": true }, "rust": { "command": ["rust-analyzer"], "extensions": [".rs"], "disabled": true }, "gopls": { "command": ["gopls"], "extensions": [".go"], "disabled": true } } } `; // ── Effect programs ─────────────────────────────────────────────────────────── /** * Scaffold a starter global config if neither global nor project config exists. * Succeeds with `true` when a file was created, `false` otherwise. */ export function scaffoldGlobalConfigEffect( cwd: string, ): Effect.Effect { return Effect.gen(function* () { const fs = yield* FileSystem; const globalPath = globalConfigPath(); if (yield* fs.fileExists(globalPath)) return false; if (yield* fs.fileExists(projectConfigPath(cwd))) return false; yield* fs.writeTextFile(globalPath, STARTER_CONFIG); return true; }); } function readJsonFileEffect(path: string): Effect.Effect { return Effect.gen(function* () { const fs = yield* FileSystem; const raw = yield* fs.readTextFile(path).pipe(Effect.either); if (raw._tag === 'Left') return null; try { return JSON.parse(raw.right) as T; } catch { return null; } }); } function resolveServerEffect( name: string, config: LspServerUserConfig, cwd: string, ): Effect.Effect { return Effect.gen(function* () { if (config.disabled) return null; if (!config.command || config.command.length === 0) return null; if (!config.extensions || config.extensions.length === 0) return null; let finalCommand = config.command[0]; let finalArgs = config.command.slice(1); const resolver = yield* CommandResolver; const via = yield* resolver.resolve(finalCommand, cwd); if (!via) return null; if (via === 'npx') { finalArgs = ['--yes', finalCommand, ...finalArgs]; finalCommand = 'npx'; } return { name, command: finalCommand, args: finalArgs, extensions: config.extensions, env: config.env ?? {}, initializationOptions: config.initialization ?? {}, } satisfies ResolvedServerConfig; }); } export interface LoadedConfig { servers: ResolvedServerConfig[]; globalDisabled: boolean; errors: string[]; } export function loadConfigEffect( cwd: string, ): Effect.Effect { return Effect.gen(function* () { const errors: string[] = []; const globalConfig = yield* readJsonFileEffect(globalConfigPath()); const projectConfig = yield* readJsonFileEffect(projectConfigPath(cwd)); if (globalConfig?.lsp === false || projectConfig?.lsp === false) { return { servers: [], globalDisabled: true, errors }; } const globalServers = (typeof globalConfig?.lsp === 'object' ? globalConfig.lsp : {}) as Record< string, LspServerUserConfig >; const projectServers = ( typeof projectConfig?.lsp === 'object' ? projectConfig.lsp : {} ) as Record; const allNames = new Set([...Object.keys(globalServers), ...Object.keys(projectServers)]); const servers: ResolvedServerConfig[] = []; for (const name of allNames) { const userConfig: LspServerUserConfig = { ...globalServers[name], ...projectServers[name], }; // Merge env maps properly (project on top of global). if (globalServers[name]?.env || projectServers[name]?.env) { userConfig.env = { ...globalServers[name]?.env, ...projectServers[name]?.env }; } const resolved = yield* resolveServerEffect(name, userConfig, cwd); if (resolved) servers.push(resolved); } return { servers, globalDisabled: false, errors }; }); } // ── Promise wrappers (live services provided) ──────────────────────────────── export function scaffoldGlobalConfig(cwd: string): Promise { return Effect.runPromise( scaffoldGlobalConfigEffect(cwd).pipe(Effect.provide(makeRuntimeLayer())), ); } export function loadConfig(cwd: string): Promise { return Effect.runPromise(loadConfigEffect(cwd).pipe(Effect.provide(makeRuntimeLayer()))); } /** Find all servers that handle a given file extension. */ export function serversForExtension( servers: ResolvedServerConfig[], filePath: string, ): ResolvedServerConfig[] { const ext = filePath.slice(filePath.lastIndexOf('.')); return servers.filter((s) => s.extensions.includes(ext)); }