feat(lsp): configurable language servers + merge C/C++ into one clangd

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>' (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).
This commit is contained in:
kim
2026-08-21 13:43:05 +09:00
parent b7233afd77
commit f2ca1543d2
6 changed files with 155 additions and 11 deletions
+19 -4
View File
@@ -116,7 +116,7 @@ configCmd
configCmd
.command("set <key> <value>")
.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: { "<languageId>": { "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<string, { command: string; args?: string[]; extensions?: string[] }>;
} 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}".`);
+60
View File
@@ -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
});
});
+56 -7
View File
@@ -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<string, { command: string; args?: string[]; extensions?: string[] }>): 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 }));
}
+8
View File
@@ -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<string, { command: string; args?: string[]; extensions?: string[] }> {
const stored = loadStoredConfig();
return stored.lspServers ?? {};
}
+6
View File
@@ -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<string, { command: string; args?: string[]; extensions?: string[] }>;
}
const paths = envPaths("locode", { suffix: "" });
+6
View File
@@ -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<void> {
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