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:
+19
-4
@@ -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}".`);
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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 }));
|
||||
}
|
||||
@@ -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 ?? {};
|
||||
}
|
||||
|
||||
@@ -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: "" });
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user