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}".`);