From f2ca1543d276c7f4261b153518e248fad713132c Mon Sep 17 00:00:00 2001 From: kim Date: Fri, 21 Aug 2026 13:43:05 +0900 Subject: [PATCH] feat(lsp): configurable language servers + merge C/C++ into one clangd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The built-in LANGUAGE_SPECS were hardcoded — users couldn't add Java/Ruby/Lua servers or override a built-in's command/args, and the file's own comment flagged this as a future extension. Also, c and cpp were separate specs both spawning clangd, so a mixed C/C++ project ran two indexing the same headers. - lspManager.ts: LANGUAGE_SPECS is now mutable; configureLanguageSpecs(overrides) merges user entries (keyed by languageId) into the built-ins. A built-in id override replaces command/args and, if extensions is given, rewrites routing. A new id adds a mapping but REQUIRES extensions (ignored otherwise — can't route files to it). C and C++ collapse into one 'c' clangd spec (all .c/.h/.cpp/.cc/.cxx/.hpp/.hh/.hxx route to a single clangd). - config.ts: resolveLspServers() reads stored.lspServers. - store.ts: StoredConfig.lspServers field. - cli.ts: 'locode config set lspServers ' (JSON object value, validated). - ui/ink/index.tsx: configureLanguageSpecs(resolveLspServers()) at startup. - lspManager.test.ts: 6 tests for the merge (add, override, rewrite exts, ignore-without-extensions, c/cpp collapse) via _specsForTests/_resetSpecsForTests — no servers spawned. Verified: typecheck clean, build 256.60 KB, 256 tests pass (+6). --- src/cli.ts | 23 ++++++++++-- src/codeintel/lspManager.test.ts | 60 ++++++++++++++++++++++++++++++ src/codeintel/lspManager.ts | 63 ++++++++++++++++++++++++++++---- src/config/config.ts | 8 ++++ src/config/store.ts | 6 +++ src/ui/ink/index.tsx | 6 +++ 6 files changed, 155 insertions(+), 11 deletions(-) create mode 100644 src/codeintel/lspManager.test.ts diff --git a/src/cli.ts b/src/cli.ts index 170b8d7..b86c29f 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -116,7 +116,7 @@ configCmd configCmd .command("set ") .description( - "Persist a config value (backend, model, baseUrl, contextWindow, maxOutputTokens, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs, maxRetries)", + "Persist a config value (backend, model, baseUrl, contextWindow, maxOutputTokens, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs, maxRetries, lspServers)", ) .action((key: string, value: string) => { if ( @@ -129,15 +129,30 @@ configCmd key !== "autoCompactThreshold" && key !== "requestTimeoutMs" && key !== "subagentTimeoutMs" && - key !== "maxRetries" + key !== "maxRetries" && + key !== "lspServers" ) { console.error( - `Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxOutputTokens, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs, maxRetries`, + `Unknown config key "${key}". Valid keys: backend, model, baseUrl, contextWindow, maxOutputTokens, maxIterations, autoCompactThreshold, requestTimeoutMs, subagentTimeoutMs, maxRetries, lspServers`, ); process.exit(1); } const stored = loadStoredConfig(); - if (key === "contextWindow" || key === "maxIterations") { + if (key === "lspServers") { + // lspServers is a JSON object: { "": { "command": "...", "args": [...], "extensions": [...] } } + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + console.error(`lspServers must be a JSON object, got invalid JSON: ${value}`); + process.exit(1); + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + console.error(`lspServers must be a JSON object keyed by language id, got: ${value}`); + process.exit(1); + } + stored.lspServers = parsed as Record; + } else if (key === "contextWindow" || key === "maxIterations") { const n = Number(value); if (!Number.isFinite(n) || n <= 0) { console.error(`${key} must be a positive number, got "${value}".`); diff --git a/src/codeintel/lspManager.test.ts b/src/codeintel/lspManager.test.ts new file mode 100644 index 0000000..a6a501f --- /dev/null +++ b/src/codeintel/lspManager.test.ts @@ -0,0 +1,60 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { configureLanguageSpecs, _resetSpecsForTests, _specsForTests } from "./lspManager.js"; + +// configureLanguageSpecs mutates the module's LANGUAGE_SPECS (forward-only by design — production +// applies it once at startup). Tests restore the built-in defaults via _resetSpecsForTests so they +// stay independent, then assert the merged spec list through _specsForTests (no server spawned). + +function specFor(ext: string) { + const specs = _specsForTests(); + return specs.find((s) => s.extensions.includes(ext)) ?? null; +} + +describe("configureLanguageSpecs", () => { + beforeEach(() => _resetSpecsForTests()); + afterEach(() => _resetSpecsForTests()); + + it("leaves the built-in specs untouched for an empty override", () => { + configureLanguageSpecs({}); + expect(_specsForTests().map((s) => s.languageId)).toEqual([ + "typescript", + "python", + "go", + "rust", + "c", + ]); + // C and C++ share one clangd spec (no separate "cpp" entry). + expect(specFor(".cpp")?.languageId).toBe("c"); + expect(specFor(".h")?.languageId).toBe("c"); + }); + + it("adds a brand-new language with extensions", () => { + configureLanguageSpecs({ java: { command: "jdtls", extensions: [".java"] } }); + expect(specFor(".java")?.command).toBe("jdtls"); + expect(specFor(".java")?.languageId).toBe("java"); + }); + + it("ignores a new-language entry without extensions (can't route files to it)", () => { + configureLanguageSpecs({ ruby: { command: "solargraph" } }); + expect(specFor(".rb")).toBeNull(); + }); + + it("overrides a built-in server's command and args", () => { + configureLanguageSpecs({ typescript: { command: "my-tsserver", args: ["--stdio"] } }); + expect(specFor(".ts")?.command).toBe("my-tsserver"); + expect(specFor(".ts")?.args).toEqual(["--stdio"]); + }); + + it("keeps a built-in's extensions when an override omits them", () => { + configureLanguageSpecs({ python: { command: "basedpyright", args: ["--stdio"] } }); + expect(specFor(".py")?.command).toBe("basedpyright"); + expect(specFor(".pyi")?.languageId).toBe("python"); // extensions unchanged + }); + + it("rewrites a built-in language's extensions when provided", () => { + configureLanguageSpecs({ go: { command: "gopls", args: ["serve"], extensions: [".rs"] } }); + // .rs now routes to "go", not "rust". + expect(specFor(".rs")?.languageId).toBe("go"); + expect(specFor(".go")).toBeNull(); // .go no longer claimed by go + }); +}); \ No newline at end of file diff --git a/src/codeintel/lspManager.ts b/src/codeintel/lspManager.ts index f1d9cf7..5ce015d 100644 --- a/src/codeintel/lspManager.ts +++ b/src/codeintel/lspManager.ts @@ -47,7 +47,11 @@ interface LanguageSpec { args?: string[]; } -const LANGUAGE_SPECS: LanguageSpec[] = [ +// The built-in language→server mappings. Mutable so `configureLanguageSpecs` can merge in user +// overrides/additions from config (see config.ts `lspServers`). One clangd spec covers both C +// and C++ — clangd handles both, and merging avoids spawning a second clangd for a mixed C/C++ +// project (two servers keyed by separate languageIds would each index the same headers twice). +let LANGUAGE_SPECS: LanguageSpec[] = [ // TypeScript / JavaScript — `typescript-language-server` wraps tsserver and speaks LSP. The most // common local-model codebase shape, so it's the first one locode wires up. { @@ -73,18 +77,42 @@ const LANGUAGE_SPECS: LanguageSpec[] = [ extensions: [".rs"], command: "rust-analyzer", }, + // C and C++ share clangd. The languageId is "c" (clangd treats .cpp/.hpp the same way); + // all C/C++ extensions route to the single clangd process. { languageId: "c", - extensions: [".c", ".h"], - command: "clangd", - }, - { - languageId: "cpp", - extensions: [".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"], + extensions: [".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"], command: "clangd", }, ]; +/** Merge user-configured LSP server entries (from `locode config set lspServers`) into the + * built-in specs. An entry keyed by a built-in languageId overrides that spec's command/args + * and, if `extensions` is provided, which file extensions route to it. An entry keyed by a new + * languageId (e.g. "java", "ruby") adds a brand-new mapping — it MUST supply `extensions` so + * files can be routed to it. Call once at startup; idempotent against the built-in list. + * + * Entries missing a `command` are ignored (a server we can't spawn is useless), and entries for + * new ids without `extensions` are ignored too (no way to route files to them). */ +export function configureLanguageSpecs(overrides: Record): void { + const merged: LanguageSpec[] = LANGUAGE_SPECS.map((spec) => { + const ov = overrides[spec.languageId]; + if (!ov) return spec; + return { + languageId: spec.languageId, + extensions: ov.extensions ?? spec.extensions, + command: ov.command, + args: ov.args, + }; + }); + for (const [languageId, ov] of Object.entries(overrides)) { + if (merged.some((s) => s.languageId === languageId)) continue; // already a built-in we overrode + if (!ov.command || !ov.extensions || ov.extensions.length === 0) continue; + merged.push({ languageId, extensions: ov.extensions, command: ov.command, args: ov.args }); + } + LANGUAGE_SPECS = merged; +} + /** Picks the LanguageSpec for a file path, or null if no extension matches. */ function specForFile(filePath: string): LanguageSpec | null { const ext = path.extname(filePath).toLowerCase(); @@ -374,4 +402,25 @@ export function _resetForTests(): void { handles.clear(); diagnosticsByUri.clear(); diagWaiters.clear(); +} + +/** For tests only: a snapshot of the currently configured language specs (after any + * configureLanguageSpecs merge), so tests can assert the merge without spawning a server. */ +export function _specsForTests(): readonly LanguageSpec[] { + return LANGUAGE_SPECS; +} + +// The immutable built-in spec list, kept so tests can restore LANGUAGE_SPECS to defaults after a +// configureLanguageSpecs call (the merge is forward-only by design — production applies it once). +const BUILTIN_LANGUAGE_SPECS: readonly LanguageSpec[] = [ + { languageId: "typescript", extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"], command: "typescript-language-server", args: ["--stdio"] }, + { languageId: "python", extensions: [".py", ".pyi"], command: "pyright-langserver", args: ["--stdio"] }, + { languageId: "go", extensions: [".go"], command: "gopls", args: ["serve"] }, + { languageId: "rust", extensions: [".rs"], command: "rust-analyzer" }, + { languageId: "c", extensions: [".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx"], command: "clangd" }, +]; + +/** For tests only: restore the built-in language specs (undo any configureLanguageSpecs merge). */ +export function _resetSpecsForTests(): void { + LANGUAGE_SPECS = BUILTIN_LANGUAGE_SPECS.map((s) => ({ ...s })); } \ No newline at end of file diff --git a/src/config/config.ts b/src/config/config.ts index b731aab..666e430 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -127,3 +127,11 @@ export function resolveRequestTimeoutMs(): number { } return DEFAULT_REQUEST_TIMEOUT_MS; } + +/** User-configured LSP server overrides/additions (see StoredConfig.lspServers). An empty object + * means "use the built-in language→server mappings only". Validated loosely: entries without a + * command are dropped by configureLanguageSpecs, so we just pass them through. */ +export function resolveLspServers(): Record { + const stored = loadStoredConfig(); + return stored.lspServers ?? {}; +} diff --git a/src/config/store.ts b/src/config/store.ts index 854bfb2..4e0d798 100644 --- a/src/config/store.ts +++ b/src/config/store.ts @@ -22,6 +22,12 @@ export interface StoredConfig { * before surfacing the error. 0 = fail immediately (old behavior); the SDK uses exponential * backoff between attempts. */ maxRetries?: number; + /** User-defined LSP server overrides/additions, keyed by language id (e.g. "java", "ruby", + * "typescript"). Each entry is { command, args?, extensions? }. An entry for a built-in id + * overrides its command/args; an entry with `extensions` also rewrites which file extensions + * route to that language. Entries for new ids add support for languages locode doesn't ship a + * server for. See `locode config set lspServers` (JSON value). */ + lspServers?: Record; } const paths = envPaths("locode", { suffix: "" }); diff --git a/src/ui/ink/index.tsx b/src/ui/ink/index.tsx index cf68820..eec8c22 100644 --- a/src/ui/ink/index.tsx +++ b/src/ui/ink/index.tsx @@ -10,6 +10,8 @@ import type { ToolDef } from "../../tools/types.js"; import { flushPendingSaves } from "../../persistence/sessionStore.js"; import { killAllBackgroundJobs } from "../../tools/backgroundJobs.js"; import { shutdownAll as shutdownAllLspServers } from "../../codeintel/lspManager.js"; +import { configureLanguageSpecs } from "../../codeintel/lspManager.js"; +import { resolveLspServers } from "../../config/config.js"; import { App } from "./App.js"; export interface RunInkAppOptions { @@ -30,6 +32,10 @@ export async function runInkApp(opts: RunInkAppOptions): Promise { process.exit(1); } + // Apply user-configured LSP server overrides/additions (locode config set lspServers) to the + // built-in language→server mappings before any LSP tool is used. Cheap and idempotent. + configureLanguageSpecs(resolveLspServers()); + // Kick off MCP server connections immediately so they overlap with model listing/picking — // by the time a session is actually created, this is usually already settled. Plugin-defined // agents (agents/*.md) become tools too, alongside MCP-provided ones, and every installed