diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b6b798b124..636713146c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -424,10 +424,14 @@ Source: [`packages/support/llm-replay/src/index.ts:306`](../packages/support/llm Requires: `lsp` ```ts config-catalog -/** Plugin configuration: one server command plus its extension mapping and host bounds. */ +/** Plugin configuration: provider id → local language-server configuration. */ export interface Config { - /** Stable provider id, reserved on `ctx.lsp` with the extensions. */ - providerId: string + /** Non-empty table of stable provider ids to independent local server configurations. */ + servers: Record +} + +/** One configured local language server and its host bounds. */ +export interface LspLocalServerConfig { /** Executable to spawn (absolute, or resolved on PATH at load). */ command: string /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ @@ -453,7 +457,7 @@ export interface Config { } ``` -Source: [`packages/lsp/lsp-local/src/index.ts:59`](../packages/lsp/lsp-local/src/index.ts) +Source: [`packages/lsp/lsp-local/src/index.ts:85`](../packages/lsp/lsp-local/src/index.ts) ## `@deepseek-ai/dsh-mcp-client` diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index 69c509bae6..f063dae8cf 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write -2026-07-15-lsp-capability-seam.md: c21d2f3926b5b465f4ccf48f1bf954c2ae604e12 -2026-07-15-lsp-capability-seam.zh.md: 9556ead6c2e0d6d49fffe54928d53f9418cf10b7 +2026-07-15-lsp-capability-seam.md: 500d861f60bcd238d31defaa90a3e2495a05e767 +2026-07-15-lsp-capability-seam.zh.md: b181987f707563e3115e64313250859a4238c4ee diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md index c21d2f3926..500d861f60 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md @@ -17,7 +17,7 @@ Many language servers behave best when the queried document is opened with curre Add LSP as a three-package capability seam with one read-only model tool and one generic local provider implementation: 1. `@deepseek-ai/dsh-lsp` at `packages/lsp/lsp` owns `ctx.lsp`, provider registration and selection, normalized requests/results, execution control, and structured LSP errors. -2. `@deepseek-ai/dsh-lsp-local` at `packages/lsp/lsp-local` adapts configured stdio language servers to the seam. Multiple plugin instances may register different server commands and extension-to-language-id mappings. +2. `@deepseek-ai/dsh-lsp-local` at `packages/lsp/lsp-local` adapts configured stdio language servers to the seam. One plugin instance accepts a named server table and registers one isolated provider for each command and extension-to-language-id mapping. 3. `@deepseek-ai/dsh-tool-lsp` at `packages/lsp/tool-lsp` owns the model-facing `lsp` schema, prompt guidance, argument validation, result limits and formatting, and ACP presentation. `dsh-lsp-local` is a generic host, not a language-server catalog or installer. Deployments explicitly configure commands and mappings; future presets belong in composition plugins or `cordis.yml` overlays. @@ -79,7 +79,7 @@ interface LspService { Mapping keys normalize to lowercase, leading-dot extensions selected from `filePath`'s final extension; language ids only synchronize documents. Seam positions and ranges are zero-based UTF-16. `references` always includes declarations: providers enforce this internally, the local mapping sets `context.includeDeclaration: true`, and callers get no flag. Closed result unions normalize navigation to locations and hover to content or `null`; navigation results carry the provider's resolved workspace root so consumers relativize file URIs in the same canonical namespace. The seam exposes no protocol types, process or document controls, or generic request escape hatch. -`dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider. +`dsh-lsp-local` owns host files, server configuration, JSON-RPC, process and transient-document state, and protocol translation; it depends on `dsh-lsp` and Node APIs, not `dsh-fs`. The server-table key is its provider id. The plugin resolves every server-local setting before registration, rolls back earlier registrations if a later mapping is invalid or conflicts, and retains an independent process pool per provider. `dsh-tool-lsp` runtime-injects only `tools`, `lsp`, and `systemPrompt`, obtains the workspace from `exec.agent?.session.header.cwd` through a package-local `sessionCwd(exec)` helper matching the filesystem tools' lookup, and imports no provider. ## Model-facing contract diff --git a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index 9556ead6c2..b181987f70 100644 --- a/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -17,7 +17,7 @@ harness 已具备文本搜索与文件读取能力,但二者都无法识别程 将 LSP 建成由三个包(package)组成的能力服务边界,其中包含一个只读模型工具和一个通用本地提供方实现: 1. `packages/lsp/lsp` 下的 `@deepseek-ai/dsh-lsp` 负责 `ctx.lsp`、提供方注册与选择、标准化请求与结果、执行控制,以及结构化 LSP 错误。 -2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。多个插件实例可注册不同的服务器命令和扩展名到语言 id 的映射。 +2. `packages/lsp/lsp-local` 下的 `@deepseek-ai/dsh-lsp-local` 将配置的 stdio 语言服务器适配到该服务边界。一个插件实例接收具名服务器表,并为每组命令及扩展名到语言 id 的映射注册一个隔离的提供方。 3. `packages/lsp/tool-lsp` 下的 `@deepseek-ai/dsh-tool-lsp` 负责面向模型的 `lsp` schema、提示词指导、参数校验、结果限制与格式化,以及 ACP(Agent Client Protocol)展示。 `dsh-lsp-local` 是通用 host,不是语言服务器目录或安装器。部署显式配置命令与映射;未来 preset 属于组合插件或 `cordis.yml` overlay。 @@ -79,7 +79,7 @@ interface LspService { 映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。服务边界中的位置和范围从零开始按 UTF-16 计数。`references` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方解析后的工作区根目录,使消费方依据同一规范化根目录相对化文件 URI。服务边界不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 -`dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 +`dsh-lsp-local` 负责主机文件、服务器配置、JSON-RPC、进程与临时文档状态和协议转换;它依赖 `dsh-lsp` 与 Node API,不依赖 `dsh-fs`。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 ## 面向模型的契约 diff --git a/packages/lsp/README.md b/packages/lsp/README.md index 57a1f6b8d9..f57eed8ae0 100644 --- a/packages/lsp/README.md +++ b/packages/lsp/README.md @@ -5,7 +5,7 @@ The language-server capability seam: an abstract LSP interface, a generic stdio | Package | Role | ctx key | |---|---|---| | `lsp/` | Abstract LSP seam (provider registry by branded id + extension mapping, per-query selection, vocabulary, `LspError`) | `ctx.lsp` | -| `lsp-local/` | Generic stdio language-server provider (spawn, JSON-RPC, transient-open queries) | (registers on `ctx.lsp`) | +| `lsp-local/` | Generic multi-server local backend (spawn, JSON-RPC, transient-open queries) | (registers providers on `ctx.lsp`) | | `tool-lsp/` | Model-facing `lsp` tool (four operations, one-based UTF-16 cursor coordinates) | (registers on `ctx.tools`) | The interface lives at `lsp/lsp/`. The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so a provider swap does not change how the model asks for navigation and no protocol payload or unreviewed mutation reaches the model contract. Providers register **capabilities**, not tools; `tool-lsp` is the only owner of the model-facing name, schema, prompt guidance, and presentation. diff --git a/packages/lsp/lsp-local/README.md b/packages/lsp/lsp-local/README.md index 1424f27835..75b0595626 100644 --- a/packages/lsp/lsp-local/README.md +++ b/packages/lsp/lsp-local/README.md @@ -1,21 +1,23 @@ # @deepseek-ai/dsh-lsp-local -A **generic stdio language-server provider** for `ctx.lsp`. One plugin instance configures one server command and its extension-to-language-id map; load multiple instances for multiple servers. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in composition plugins or `cordis.yml` overlays. +A **generic local stdio language-server backend** for `ctx.lsp`. One plugin instance accepts a named server table and registers one isolated provider per entry. This is a generic host, not a language-server catalog or installer — deployments configure commands and mappings explicitly; presets belong in `cordis.yml` overlays. Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). ## What it does -- Lazily single-flights one server process per `(provider id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. +- Resolves every server-local setting before registration; an invalid mapping or registration conflict rolls back earlier entries, so a failed load leaves no provider routes. +- Lazily single-flights one server process per `(server id, canonical workspace realpath)`. A crash fails the active query without replay; a later query may replace the process. - Uses a compatibility-first **transient-open** sequence per query: canonicalize and read the source with Node APIs, `textDocument/didOpen` (version 1, full text), the requested request, then `textDocument/didClose` in `finally`. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU. - Serializes queries through one abortable per-instance queue so a cancellation that fails to stop the server can terminate it without killing unrelated work; distinct instances run in parallel. - Reads sources through Node filesystem APIs in the subprocess's host namespace — NOT `ctx.fs`, and emits no `fs/observed`: only the LSP result is model-visible, so a query does not satisfy read-before-write policy. ## Configuration -| Key | Default | Meaning | +The `servers` record key is the stable provider id reserved on `ctx.lsp`; each value has this shape: + +| Server key | Default | Meaning | |---|---|---| -| `providerId` | (required) | Stable provider id reserved on `ctx.lsp` with the extensions. | | `command` | (required) | Executable to spawn — absolute, or resolved on the child PATH at load. Launch uses no shell. | | `args` | `[]` | Arguments passed to the executable. | | `env` | `{}` | Extra env merged on top of the credential-scrubbed ambient env (vars matching `KEY`/`SECRET`/`TOKEN` are not forwarded). | @@ -28,7 +30,7 @@ Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). | `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. | | `killGraceMs` | `2000` | SIGTERM→SIGKILL grace after graceful shutdown fails. | -The executable is resolved at load (after credential scrubbing); a missing command fails before registration. The process itself launches lazily on the first matching query. +`servers` must contain at least one entry, and every id must be non-empty. All executables resolve at load after credential scrubbing; a bad later entry prevents every provider from registering. Processes launch lazily on the first matching query. ## Protocol behavior @@ -46,4 +48,4 @@ Indirectly, through `dsh-tool-lsp`, which surfaces this provider's normalized re - **Trusted host-local only** — no sandbox confinement, no private cache/temp write contract; supporting untrusted binaries or restricted/remote/virtual workspaces requires a later process/filesystem contract and a different provider ([seam RFC](../../../docs/rfc/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW` (final-component symlink guard) and a bounded read; a concurrent mutator that swaps an *ancestor* directory for a symlink between the resolve and the open is an accepted residual TOCTOU under this trusted-deployment model, not closed with non-portable `openat` segment walks. - **Transient-open compatibility floor** — servers whose synchronization omits open/close (or advertise `None`) are unsupported even if closed-document queries would work; the pinned TypeScript e2e establishes one compatibility floor, not a cross-language claim. -- **Per-instance serialization latency** — parallel agents sharing a workspace queue behind one process; long-lived workspace processes consume memory until disposal. +- **Per-server/workspace serialization latency** — parallel agents sharing one server and workspace queue behind one process; long-lived workspace processes consume memory until disposal. diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index f1d5408f5c..07e1cbed5a 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -1,10 +1,10 @@ /** - * Generic stdio language-server provider for `ctx.lsp`. One plugin instance configures one server - * command and its extension→language-id map; load multiple instances for multiple servers. The - * provider lazily single-flights one server process per `(provider id, canonical workspace - * realpath)`, serves transient-open queries through it, and evicts a crashed process so a later - * query can replace it. It reads sources through Node APIs in the host namespace (not `ctx.fs`) and - * trusts its configured server — no sandbox confinement. + * Generic stdio language-server backend for `ctx.lsp`. One plugin instance configures a named table + * of server commands and registers one isolated provider for each entry. Every provider lazily + * single-flights one server process per canonical workspace realpath, serves transient-open queries + * through it, and evicts a crashed process so a later query can replace it. Providers read sources + * through Node APIs in the host namespace (not `ctx.fs`) and trust their configured servers — no + * sandbox confinement. * * Namespace plugin (named exports, no default export). Lifecycle is effect-scoped: disposal * unregisters from `ctx.lsp` and tears down every live server. @@ -55,10 +55,8 @@ const DEFAULT_MAX_DOCUMENT_BYTES = 4_000_000 const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000 const DEFAULT_KILL_GRACE_MS = 2_000 -/** Plugin configuration: one server command plus its extension mapping and host bounds. */ -export interface Config { - /** Stable provider id, reserved on `ctx.lsp` with the extensions. */ - providerId: string +/** One configured local language server and its host bounds. */ +export interface LspLocalServerConfig { /** Executable to spawn (absolute, or resolved on PATH at load). */ command: string /** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */ @@ -83,11 +81,16 @@ export interface Config { killGraceMs?: number } -/** The resolved config after schemastery fills every default; the provider reads this shape. */ -type ResolvedConfig = Required +/** Plugin configuration: provider id → local language-server configuration. */ +export interface Config { + /** Non-empty table of stable provider ids to independent local server configurations. */ + servers: Record +} -export const Config: z = z.object({ - providerId: z.string().required(), +/** One server config after schemastery fills every default. */ +type ResolvedServerConfig = Required + +const LspLocalServerConfig: z = z.object({ command: z.string().required(), args: z.array(String).default([]), env: z.dict(String).default({}), @@ -101,43 +104,66 @@ export const Config: z = z.object({ killGraceMs: z.number().default(DEFAULT_KILL_GRACE_MS), }) +export const Config: z = z.object({ + servers: z.dict(LspLocalServerConfig).required(), +}) + /** - * Register a generic stdio LSP provider. Resolves the executable at load (after credential - * scrubbing) and fails before registration when it is unavailable; the process itself launches - * lazily on the first matching query. + * Register the configured stdio LSP providers. Resolves every executable at load (after credential + * scrubbing) before publishing any provider; each process launches lazily on its first matching + * query. * @param ctx - the plugin context (must inject `lsp`). * @param config - the resolved plugin configuration (schemastery has filled every default). */ export function apply(ctx: Context, config: Config): void { - const resolved = config as ResolvedConfig + const entries = Object.entries(config.servers) + if (entries.length === 0) throw new Error('lsp-local: servers must contain at least one server') + + // Resolve every server-local setting before registration so a bad later command or bound cannot + // publish an earlier provider. Registry-level mapping conflicts are rolled back below. + const providers = entries.map(([providerId, rawConfig]) => { + if (providerId.trim() === '') throw new Error('lsp-local: server ids must be non-empty strings') + const resolved = rawConfig as ResolvedServerConfig + validateServerConfig(providerId, resolved) + const childEnv = buildChildEnv(resolved.env) + const executable = resolveExecutable(resolved.command, childEnv) + return new LocalLspProvider(providerId, resolved, childEnv, executable) + }) + + ctx.effect(() => { + const disposers: Array<() => void> = [] + try { + for (const provider of providers) disposers.push(ctx.lsp.registerProvider(provider)) + } catch (error) { + for (const dispose of disposers.reverse()) dispose() + throw error + } + return async () => { + // Remove every route before process teardown so no new query can enter a draining provider. + for (const dispose of disposers.reverse()) dispose() + await Promise.all(providers.map(provider => provider.disposeAll())) + } + }, 'lsp-local.registerProviders') +} + +/** Validate one resolved server entry before any provider in the table is registered. */ +function validateServerConfig(providerId: string, resolved: ResolvedServerConfig): void { // Teardown budgets feed `deadline()`, whose `<= 0` is the internal no-timeout sentinel; a // nonpositive value would let a server that ignores shutdown hang disposal forever. Fail at load. - assertPositiveInteger('shutdownTimeoutMs', resolved.shutdownTimeoutMs) - assertPositiveInteger('killGraceMs', resolved.killGraceMs) + assertPositiveInteger(providerId, 'shutdownTimeoutMs', resolved.shutdownTimeoutMs) + assertPositiveInteger(providerId, 'killGraceMs', resolved.killGraceMs) // Byte caps must be positive: a nonpositive stderr cap defeats the retained-tail bound // (`slice(-0)` keeps everything), `maxMessageBytes: 0` makes every response fatal, and a bad // document cap fails later in the read path instead of at load. - assertPositiveInteger('maxStderrBytes', resolved.maxStderrBytes) - assertPositiveInteger('maxMessageBytes', resolved.maxMessageBytes) - assertPositiveInteger('maxDocumentBytes', resolved.maxDocumentBytes) - const childEnv = buildChildEnv(resolved.env) - // Resolve the executable eagerly so a misconfigured command fails at load, not on first query. - const executable = resolveExecutable(resolved.command, childEnv) - - const provider = new LocalLspProvider(resolved, childEnv, executable) - ctx.effect(() => { - const dispose = ctx.lsp.registerProvider(provider) - return async () => { - dispose() - await provider.disposeAll() - } - }, 'lsp-local.registerProvider') + assertPositiveInteger(providerId, 'maxStderrBytes', resolved.maxStderrBytes) + assertPositiveInteger(providerId, 'maxMessageBytes', resolved.maxMessageBytes) + assertPositiveInteger(providerId, 'maxDocumentBytes', resolved.maxDocumentBytes) } /** Reject a nonpositive or non-integer config value at load, so misconfiguration fails loud. */ -function assertPositiveInteger(name: string, value: number): void { +function assertPositiveInteger(providerId: string, name: string, value: number): void { if (!Number.isInteger(value) || value < 1) { - throw new Error(`lsp-local: ${name} must be a positive integer`) + throw new Error(`lsp-local: servers.${providerId}.${name} must be a positive integer`) } } @@ -150,11 +176,12 @@ class LocalLspProvider implements LspProvider { private disposed = false constructor( - private readonly config: ResolvedConfig, + providerId: string, + private readonly config: ResolvedServerConfig, private readonly childEnv: Record, private readonly executable: string, ) { - this.id = LspProviderId(config.providerId) + this.id = LspProviderId(providerId) this.extensionToLanguage = config.extensionToLanguage } diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index cea1f0d189..799069c0fd 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -46,11 +46,14 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { const ctx = new Context() await ctx.plugin(Lsp) await ctx.plugin(LspLocal, { - providerId: 'fake', - command: ${JSON.stringify(process.execPath)}, - args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}], - env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} }, - extensionToLanguage: { '.ts': 'typescript' }, + servers: { + fake: { + command: ${JSON.stringify(process.execPath)}, + args: ['--import', ${JSON.stringify(tsxLoader)}, ${JSON.stringify(fixtureServer)}], + env: { TSX_TSCONFIG_PATH: ${JSON.stringify(repoTsconfig)}, LSP_FAKE_DEF: ${JSON.stringify(location)} }, + extensionToLanguage: { '.ts': 'typescript' }, + }, + }, }) const result = await ctx.lsp.query({ operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} }) console.log(JSON.stringify(result)) diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 78f1a8e419..3d06b7576b 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -8,7 +8,7 @@ import { Context } from 'cordis' import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import { deadline } from '@deepseek-ai/dsh-timeout' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' -import type { Config } from '@deepseek-ai/dsh-lsp-local' +import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' const tsxLoader = fileURLToPath(import.meta.resolve('tsx')) const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url)) @@ -28,17 +28,23 @@ afterEach(async () => { await rm(root, { recursive: true, force: true }) }) -/** Mount the real seam + lsp-local plugin driving the fake server with the given env. */ -async function mount(fakeEnv: Record = {}, overrides: Partial = {}): Promise { - const ctx = new Context() - await ctx.plugin(Lsp) - await ctx.plugin(LspLocal, { - providerId: 'fake', +/** One fake stdio server entry with optional behavior and host-bound overrides. */ +function fakeServer(fakeEnv: Record = {}, overrides: Partial = {}): LspLocalServerConfig { + return { command: process.execPath, args: ['--import', tsxLoader, fixtureServer], env: { TSX_TSCONFIG_PATH: repoTsconfig, ...fakeEnv }, extensionToLanguage: { '.ts': 'typescript' }, ...overrides, + } +} + +/** Mount the real seam + lsp-local plugin driving one fake server. */ +async function mount(fakeEnv: Record = {}, overrides: Partial = {}): Promise { + const ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + servers: { fake: fakeServer(fakeEnv, overrides) }, }) return ctx } @@ -53,6 +59,24 @@ function locationJson(line: number): unknown { } describe('lsp-local end to end over a fake server', () => { + it('routes different extensions to independent configured servers', async () => { + await writeFile(join(ws, 'a.py'), 'x = 1\n') + const ctx = new Context() + await ctx.plugin(Lsp) + await ctx.plugin(LspLocal, { + servers: { + typescript: fakeServer({ LSP_FAKE_HOVER: JSON.stringify({ contents: 'ts' }) }), + python: fakeServer( + { LSP_FAKE_HOVER: JSON.stringify({ contents: 'py' }) }, + { extensionToLanguage: { '.py': 'python' } }, + ), + }, + }) + expect(await ctx.lsp.query(query('hover', 'a.ts'))).toEqual({ kind: 'hover', hover: { contents: 'ts' } }) + expect(await ctx.lsp.query(query('hover', 'a.py'))).toEqual({ kind: 'hover', hover: { contents: 'py' } }) + await ctx.fiber.dispose() + }) + it('resolves definition to normalized locations', async () => { const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) }) const result = await ctx.lsp.query(query('definition')) @@ -245,10 +269,13 @@ describe('lsp-local end to end over a fake server', () => { const ctx = new Context() await ctx.plugin(Lsp) await expect(ctx.plugin(LspLocal, { - providerId: 'missing', - command: 'definitely-not-a-real-lsp-binary-xyz', - args: [], - extensionToLanguage: { '.ts': 'typescript' }, + servers: { + missing: { + command: 'definitely-not-a-real-lsp-binary-xyz', + args: [], + extensionToLanguage: { '.ts': 'typescript' }, + }, + }, })).rejects.toThrow(/was not found on PATH/) await ctx.fiber.dispose() }) diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 53f9a88369..65a22bfb5b 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -5,6 +5,7 @@ import { join } from 'node:path' import { Context } from 'cordis' import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' import * as LspLocal from '@deepseek-ai/dsh-lsp-local' +import type { Config, LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local' let root: string let ws: string @@ -24,6 +25,11 @@ function query(): LspQueryRequest { return { operation: 'definition', filePath: 'a.ts', position: { line: 0, character: 0 }, workspaceRoot: ws } } +/** Wrap one server entry in the plugin's named server table. */ +function config(providerId: string, server: LspLocalServerConfig): Config { + return { servers: { [providerId]: server } } +} + describe('lsp-local provider resolution', () => { it('resolves a bare command on the child PATH and registers the provider', async () => { // A tiny executable script placed on a custom PATH dir: the load-time resolver must find it. @@ -35,26 +41,24 @@ describe('lsp-local provider resolution', () => { const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'onpath', + await expect(ctx.plugin(LspLocal, config('onpath', { command: 'fake-lsp', args: [], env: { PATH: bin }, extensionToLanguage: { '.ts': 'typescript' }, - })).resolves.toBeDefined() + }))).resolves.toBeDefined() await ctx.fiber.dispose() }) it('skips empty PATH segments and fails when the command is absent', async () => { const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'nope', + await expect(ctx.plugin(LspLocal, config('nope', { command: 'fake-lsp', args: [], env: { PATH: `::${join(root, 'empty')}` }, extensionToLanguage: { '.ts': 'typescript' }, - })).rejects.toThrow(/was not found on PATH/) + }))).rejects.toThrow(/was not found on PATH/) await ctx.fiber.dispose() }) @@ -64,12 +68,11 @@ describe('lsp-local provider resolution', () => { await ctx.plugin(Lsp) // Grab the provider instance by registering, then dispose the whole plugin fiber. const lsp = ctx.lsp - const fiber = await ctx.plugin(LspLocal, { - providerId: 'disp', + const fiber = await ctx.plugin(LspLocal, config('disp', { command: process.execPath, args: ['-e', 'setInterval(()=>{},1000)'], extensionToLanguage: { '.ts': 'typescript' }, - }) + })) await fiber.dispose() // After disposal the provider unregistered from the seam, so selection fails as unavailable. await expect(lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) @@ -79,13 +82,12 @@ describe('lsp-local provider resolution', () => { it('rejects a nonpositive teardown budget at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'bad-budget', + await expect(ctx.plugin(LspLocal, config('bad-budget', { command: process.execPath, args: ['-e', ''], extensionToLanguage: { '.ts': 'typescript' }, killGraceMs: 0, - })).rejects.toThrow(/killGraceMs must be a positive integer/) + }))).rejects.toThrow(/servers\.bad-budget\.killGraceMs must be a positive integer/) await ctx.fiber.dispose() }) @@ -94,24 +96,65 @@ describe('lsp-local provider resolution', () => { await writeFile(notExe, 'plain text, not executable') const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'abs-bad', + await expect(ctx.plugin(LspLocal, config('abs-bad', { command: notExe, args: [], extensionToLanguage: { '.ts': 'typescript' }, - })).rejects.toThrow(/is not an executable file/) + }))).rejects.toThrow(/is not an executable file/) await ctx.fiber.dispose() }) it('rejects an executable directory as a command at load', async () => { const ctx = new Context() await ctx.plugin(Lsp) - await expect(ctx.plugin(LspLocal, { - providerId: 'abs-directory', + await expect(ctx.plugin(LspLocal, config('abs-directory', { command: ws, args: [], extensionToLanguage: { '.ts': 'typescript' }, - })).rejects.toThrow(/is not an executable file/) + }))).rejects.toThrow(/is not an executable file/) + await ctx.fiber.dispose() + }) + + it('rejects an empty server table at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { servers: {} })).rejects.toThrow(/servers must contain at least one server/) + await ctx.fiber.dispose() + }) + + it('rejects an empty server id at load', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, config('', { + command: process.execPath, + extensionToLanguage: { '.ts': 'typescript' }, + }))).rejects.toThrow(/server ids must be non-empty strings/) + await ctx.fiber.dispose() + }) + + it('resolves every executable before publishing any provider', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + servers: { + valid: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, + missing: { command: 'definitely-not-a-real-lsp-binary-xyz', extensionToLanguage: { '.py': 'python' } }, + }, + })).rejects.toThrow(/was not found on PATH/) + await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) + await ctx.fiber.dispose() + }) + + it('rolls back earlier registrations when a later server conflicts', async () => { + const ctx = new Context() + await ctx.plugin(Lsp) + await expect(ctx.plugin(LspLocal, { + servers: { + first: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, + second: { command: process.execPath, extensionToLanguage: { '.ts': 'typescript' } }, + }, + })).rejects.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' })) + await expect(ctx.lsp.query(query())).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' })) await ctx.fiber.dispose() }) }) diff --git a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts index ca4df620d3..300ce7d318 100644 --- a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts +++ b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts @@ -53,10 +53,13 @@ beforeAll(async () => { ctx = new Context() await ctx.plugin(Lsp) await ctx.plugin(LspLocal, { - providerId: 'typescript', - command: serverBin, - args: ['--stdio'], - extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' }, + servers: { + typescript: { + command: serverBin, + args: ['--stdio'], + extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' }, + }, + }, }) }, 60_000) diff --git a/packages/lsp/lsp/README.md b/packages/lsp/lsp/README.md index 8e51d0653a..8923523e05 100644 --- a/packages/lsp/lsp/README.md +++ b/packages/lsp/lsp/README.md @@ -7,7 +7,7 @@ This package is the interface third of the LSP capability: | Package | Role | |---|---| | `@deepseek-ai/dsh-lsp` (this) | the interface: the service, provider registry keyed by branded id + extension mapping, per-query selection, request/result vocabulary, the `LspError` taxonomy | -| `@deepseek-ai/dsh-lsp-local` | a generic stdio language-server provider | +| `@deepseek-ai/dsh-lsp-local` | a generic local backend that registers configured stdio language-server providers | | `@deepseek-ai/dsh-tool-lsp` | the model-facing `lsp` tool over `ctx.lsp` | The seam exposes exactly four semantic operations — `definition`, `references`, `implementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`. diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index f2e8d1c46a..74d9c6ae77 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -52,12 +52,15 @@ async function mount(hang: boolean, timeoutMs?: number): Promise { await ctx.plugin(ToolRegistry) await ctx.plugin(Lsp) await ctx.plugin(LspLocal, { - providerId: 'inline', - command: process.execPath, - args: ['-e', serverScript(hang)], - extensionToLanguage: { '.ts': 'typescript' }, - shutdownTimeoutMs: 200, - killGraceMs: 200, + servers: { + inline: { + command: process.execPath, + args: ['-e', serverScript(hang)], + extensionToLanguage: { '.ts': 'typescript' }, + shutdownTimeoutMs: 200, + killGraceMs: 200, + }, + }, }) await ctx.plugin(TimeoutPolicy) await ctx.plugin(ToolLsp, timeoutMs !== undefined ? { timeoutMs } : {})