Merge latest master into invariant service seam
This commit is contained in:
13
packages/lsp/README.md
Normal file
13
packages/lsp/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# lsp/ - LSP capability family
|
||||
|
||||
The language-server capability seam: an abstract LSP interface, a generic stdio provider, and the model-facing `lsp` tool. All **product** packages.
|
||||
|
||||
| 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 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 — `goToDefinition`, `findReferences`, `goToImplementation`, `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.
|
||||
|
||||
See the [LSP capability seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md) for the design rationale, including why documents open transiently per query, why the local host reads through Node APIs rather than `ctx.fs`, and why extension ownership is exclusive within one runtime.
|
||||
55
packages/lsp/lsp-local/README.md
Normal file
55
packages/lsp/lsp-local/README.md
Normal file
@@ -0,0 +1,55 @@
|
||||
# @deepseek-ai/dsh-lsp-local
|
||||
|
||||
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
|
||||
|
||||
- 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`. A failed or canceled `didOpen` write terminates the instance before the pool can reuse it. Documents close after each call, so the first version needs no `didChange`, content cache, or document LRU.
|
||||
- Serializes each source-read/open/query/close lifecycle through one abortable per-workspace queue so queued calls read current source only when their turn starts; distinct workspaces 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
|
||||
|
||||
The `servers` record key is the stable provider id reserved on `ctx.lsp`; each value has this shape:
|
||||
|
||||
| Server key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `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). |
|
||||
| `extensionToLanguage` | (required) | Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). |
|
||||
| `initializationOptions` | `null` | Static `initialize` options forwarded to the server. |
|
||||
| `configuration` | `null` | Static answer to every `workspace/configuration` item. |
|
||||
| `maxMessageBytes` | `16000000` | Largest single framed message accepted from the server. |
|
||||
| `maxStderrBytes` | `1000000` | Largest stderr tail retained for diagnostics. |
|
||||
| `maxDocumentBytes` | `4000000` | Largest source file this host will open. |
|
||||
| `shutdownTimeoutMs` | `5000` | Graceful `shutdown`/`exit` budget before escalation. |
|
||||
| `killGraceMs` | `2000` | Grace for request cancellation and for SIGTERM→SIGKILL escalation. |
|
||||
|
||||
`servers` must contain at least one entry, and every id must be non-empty. Timer budgets must be positive integers no greater than Node's `2_147_483_647` ms timer limit. 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
|
||||
|
||||
Initialization advertises `general.positionEncodings: ['utf-16']`, `workspace: { workspaceFolders: true, configuration: true }`, `textDocument.hover.contentFormat: ['markdown', 'plaintext']`, and `linkSupport: true` for definition and implementation, with no dynamic registration. The server's returned capabilities are authoritative: an unsupported operation, or synchronization without transient open/close, fails the query. An omitted server `positionEncoding` defaults to `utf-16`; any other value is a protocol error. The client answers `workspace/configuration` from static config, accepts lifecycle bookkeeping requests, and rejects `workspace/applyEdit` — it never applies edits or runs commands. Navigation maps `Location` directly and `LocationLink` from `targetUri` + `targetSelectionRange`; hover normalization takes valid `MarkupContent.value`, preserves string `MarkedString`s, renders language-tagged values as fenced code, and joins arrays with one blank line. Missing results, malformed ranges or positions, and malformed hover encodings fail as structured `LSP_MALFORMED_RESPONSE` errors.
|
||||
|
||||
## Security boundary
|
||||
|
||||
The provider trusts its configured server and claims no sandbox confinement. It canonicalizes and reads source through Node APIs, rejecting a source that is missing, non-regular, non-UTF-8, oversized, or whose canonical path resolves outside the canonical workspace (symlink aliases share one instance). Result locations may be external, but an external path cannot become a query source. The first implementation therefore requires trusted host-local deployment; restricted, remote, or virtual workspaces require another provider.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-lsp`, which surfaces this provider's normalized results; this host contributes no prompt or schema itself.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; `dsh-tool-lsp` owns request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **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 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)). Containment resolves `realpath`, then opens the source through one handle with `O_NOFOLLOW | O_NONBLOCK` (final-component symlink guard plus nonblocking rejection of FIFOs) 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-server/workspace serialization latency** — parallel agents sharing one server and workspace queue behind one process; long-lived workspace processes consume memory until disposal.
|
||||
50
packages/lsp/lsp-local/package.json
Normal file
50
packages/lsp/lsp-local/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-lsp-local",
|
||||
"description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-lsp": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-lsp": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7",
|
||||
"typescript": "^6.0.3",
|
||||
"typescript-language-server": "^5.0.0"
|
||||
}
|
||||
}
|
||||
48
packages/lsp/lsp-local/src/abort.ts
Normal file
48
packages/lsp/lsp-local/src/abort.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Shared cancellation helpers for the local LSP provider's host-I/O, queue, and protocol phases.
|
||||
* @module @deepseek-ai/dsh-lsp-local/abort
|
||||
*/
|
||||
|
||||
import { timeoutOf } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
/**
|
||||
* Build an abort Error carrying the signal's reason and preserving timeout classification.
|
||||
* @param signal - the aborted signal whose reason to surface.
|
||||
* @returns the timeout reason if present, else the Error reason, else a generic aborted Error.
|
||||
*/
|
||||
export function abortError(signal: AbortSignal): Error {
|
||||
const timeout = timeoutOf(signal)
|
||||
if (timeout !== undefined) return timeout
|
||||
const reason: unknown = signal.reason
|
||||
if (reason instanceof Error) return reason
|
||||
return new Error('LSP query aborted')
|
||||
}
|
||||
|
||||
/**
|
||||
* Throw the signal's classified abort error when it has already fired.
|
||||
* @param signal - the optional query cancellation signal.
|
||||
*/
|
||||
export function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw abortError(signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Await work while allowing a query signal to abandon its wait; the underlying work keeps its own
|
||||
* handlers and continues to its owner-defined quiescence boundary.
|
||||
* @param work - the owned asynchronous work.
|
||||
* @param signal - optional query cancellation.
|
||||
* @returns the work result, or a rejection carrying the classified abort reason.
|
||||
*/
|
||||
export function abortable<T>(work: Promise<T>, signal?: AbortSignal): Promise<T> {
|
||||
if (signal === undefined) return work
|
||||
if (signal.aborted) return Promise.reject(abortError(signal))
|
||||
const canceled = Promise.withResolvers<never>()
|
||||
const onAbort = (): void => { canceled.reject(abortError(signal)) }
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
const normalized = work.catch((error: unknown) => {
|
||||
/* v8 ignore next -- owned LSP promises reject with Error; coercion defends the generic helper. */
|
||||
throw error instanceof Error ? error : new Error(String(error))
|
||||
})
|
||||
return Promise.race([normalized, canceled.promise])
|
||||
.finally(() => { signal.removeEventListener('abort', onAbort) })
|
||||
}
|
||||
331
packages/lsp/lsp-local/src/connection.ts
Normal file
331
packages/lsp/lsp-local/src/connection.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
/**
|
||||
* A JSON-RPC endpoint over one spawned language server's stdio. Owns id correlation, outbound
|
||||
* requests/notifications, and inbound server→client requests: it answers `workspace/configuration`
|
||||
* from static config, and rejects `workspace/applyEdit` (this host never applies edits or runs
|
||||
* commands). It caps stderr, surfaces framing/decoder failures as a fatal close, and exposes the
|
||||
* child handle so the instance owns process-signal teardown.
|
||||
* @module @deepseek-ai/dsh-lsp-local/connection
|
||||
*/
|
||||
|
||||
import type { ChildProcessByStdio } from 'node:child_process'
|
||||
import { spawn } from 'node:child_process'
|
||||
import type { Readable, Writable } from 'node:stream'
|
||||
import { setImmediate as yieldToEventLoop } from 'node:timers/promises'
|
||||
import { encodeMessage, MessageDecoder } from './framing.ts'
|
||||
|
||||
/** How to launch the server and answer its config requests. */
|
||||
export interface ConnectionSpec {
|
||||
/** The resolved absolute executable path (no shell). */
|
||||
readonly command: string
|
||||
/** Arguments passed to the executable. */
|
||||
readonly args: readonly string[]
|
||||
/** The child's working directory (the canonical workspace). */
|
||||
readonly cwd: string
|
||||
/** The child's environment (credential-scrubbed, with overrides applied). */
|
||||
readonly env: Record<string, string>
|
||||
/** Largest single framed message accepted from the server. */
|
||||
readonly maxMessageBytes: number
|
||||
/** Largest stderr tail retained for diagnostics. */
|
||||
readonly maxStderrBytes: number
|
||||
/** Static answer to every `workspace/configuration` item. */
|
||||
readonly configuration: unknown
|
||||
}
|
||||
|
||||
interface Pending {
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
/** A live JSON-RPC endpoint bound to one child process. */
|
||||
export class LspConnection {
|
||||
private readonly child: ChildProcessByStdio<Writable, Readable, Readable>
|
||||
private readonly decoder: MessageDecoder
|
||||
private readonly pending = new Map<number, Pending>()
|
||||
private nextId = 1
|
||||
private stderr = Buffer.alloc(0)
|
||||
private closeReason: Error | undefined
|
||||
/** Set once the process has fully exited; the instance awaits it during teardown. */
|
||||
readonly closed: Promise<void>
|
||||
|
||||
/**
|
||||
* @param spec - how to launch the server and answer its config requests.
|
||||
* @param onServerRequest - answers a server→client request; rejects to send an error response.
|
||||
*/
|
||||
constructor(
|
||||
private readonly spec: ConnectionSpec,
|
||||
private readonly onServerRequest: (method: string, params: unknown) => Promise<unknown>,
|
||||
) {
|
||||
this.decoder = new MessageDecoder(spec.maxMessageBytes)
|
||||
// `detached` puts the server in its own process group so teardown can signal the WHOLE group
|
||||
// (via `process.kill(-pid)`), reaching helper processes a language server spawns (e.g. tsserver).
|
||||
this.child = spawn(spec.command, [...spec.args], {
|
||||
cwd: spec.cwd,
|
||||
env: spec.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
})
|
||||
this.closed = new Promise<void>((resolve) => {
|
||||
this.child.on('close', () => {
|
||||
const reason = this.closeReason ?? new Error(this.exitMessage())
|
||||
// Record the reason so any request issued AFTER close rejects immediately instead of hanging
|
||||
// (a closed process sends no further responses).
|
||||
this.closeReason = reason
|
||||
this.failAll(reason)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
this.child.on('error', (error) => { this.fail(error) })
|
||||
// Child stdin can fail while the process itself remains alive (for example, a server closes fd
|
||||
// 0). Treat that as a fatal connection error so pending requests reject immediately instead of
|
||||
// waiting for a process-close event that may never arrive.
|
||||
this.child.stdin.on('error', (error) => { this.fail(error) })
|
||||
this.child.stdout.on('data', (chunk: Buffer) => { this.onStdout(chunk) })
|
||||
this.child.stderr.on('data', (chunk: Buffer) => { this.onStderr(chunk) })
|
||||
}
|
||||
|
||||
/** The child's pid, or `-1` when the spawn produced no pid (so signalling is a no-op). */
|
||||
get pid(): number {
|
||||
/* v8 ignore next -- the `-1` fallback only applies to a spawn that produced no pid; defensive. */
|
||||
return this.child.pid ?? -1
|
||||
}
|
||||
|
||||
/** The retained stderr tail, for diagnostics on a failed server. */
|
||||
get stderrTail(): string {
|
||||
return this.stderr.toString('utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a request and await its result.
|
||||
* @param method - the JSON-RPC method.
|
||||
* @param params - the request params.
|
||||
* @returns the response result; rejects on an error response, write failure, or close.
|
||||
*/
|
||||
request(method: string, params: unknown): Promise<unknown> {
|
||||
const id = this.nextId++
|
||||
const promise = new Promise<unknown>((resolve, reject) => {
|
||||
if (this.closeReason !== undefined) {
|
||||
reject(this.closeReason)
|
||||
return
|
||||
}
|
||||
this.pending.set(id, { resolve, reject })
|
||||
// `write()` records either synchronous or callback-delivered failures on the connection and
|
||||
// rejects every pending request. This handler only consumes the write promise itself.
|
||||
void this.write({ jsonrpc: '2.0', id, method, params }).catch(() => {})
|
||||
})
|
||||
// A caller that stops awaiting (e.g. an aborted query) can leave this promise to reject later
|
||||
// when the process closes; a benign no-op handler keeps that from surfacing as an unhandled
|
||||
// rejection. The returned promise still delivers the rejection to the caller's own await/catch.
|
||||
promise.catch(() => {})
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a notification (no id, no response).
|
||||
* @param method - the JSON-RPC method.
|
||||
* @param params - the notification params.
|
||||
* @returns a promise that settles when the framed notification has been written.
|
||||
*/
|
||||
notify(method: string, params: unknown): Promise<void> {
|
||||
return this.write({ jsonrpc: '2.0', method, params })
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a `$/cancelRequest` for an in-flight request id (best-effort; ignores write failure).
|
||||
* @param requestId - the numeric id of the request to cancel.
|
||||
*/
|
||||
cancel(requestId: number): void {
|
||||
// The server is already gone or unwritable when this rejects; `write()` has recorded the fatal
|
||||
// connection failure and rejected the pending request, so cancellation remains best-effort.
|
||||
void this.write({ jsonrpc: '2.0', method: '$/cancelRequest', params: { id: requestId } }).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
* The id the NEXT `request()` will use, so the instance can pre-arm a cancel.
|
||||
* @returns the numeric id the next request will be assigned.
|
||||
*/
|
||||
peekNextId(): number {
|
||||
return this.nextId
|
||||
}
|
||||
|
||||
/** Send SIGTERM to the server's process group (idempotent-safe; a dead group ignores it). */
|
||||
terminate(): void {
|
||||
this.signalGroup('SIGTERM')
|
||||
}
|
||||
|
||||
/** Send SIGKILL to the server's process group. */
|
||||
kill(): void {
|
||||
this.signalGroup('SIGKILL')
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the owned process group has no members.
|
||||
* @param signal - optional bound for the wait.
|
||||
* @returns `true` when the group exited, or `false` when the signal aborted first.
|
||||
*/
|
||||
async waitForProcessGroupExit(signal?: AbortSignal): Promise<boolean> {
|
||||
while (this.processGroupAlive()) {
|
||||
if (signal?.aborted) return false
|
||||
await yieldToEventLoop()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Signal the whole process group (negative pid) so helper processes are reached; fall back to the
|
||||
* direct child if the group send fails. Never throws — teardown races process exit.
|
||||
*/
|
||||
private signalGroup(sig: NodeJS.Signals): void {
|
||||
const pid = this.child.pid
|
||||
if (pid === undefined) return
|
||||
try {
|
||||
process.kill(-pid, sig)
|
||||
} catch {
|
||||
// The group is gone (already exited) or could not be signalled; try the direct child.
|
||||
try {
|
||||
this.child.kill(sig)
|
||||
} catch {
|
||||
// Already dead; nothing to signal.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the detached process group still has at least one member. */
|
||||
private processGroupAlive(): boolean {
|
||||
const pid = this.child.pid
|
||||
/* v8 ignore next -- only an asynchronous spawn failure omits pid; its close path owns cleanup. */
|
||||
if (pid === undefined) return false
|
||||
try {
|
||||
process.kill(-pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
/* v8 ignore next -- POSIX reports an absent group as ESRCH, but child-reaping timing makes
|
||||
whether lifecycle tests observe this branch platform-dependent. */
|
||||
if (code === 'ESRCH') return false
|
||||
/* v8 ignore start -- EPERM and non-POSIX negative-pid failures are platform defenses; CI runs
|
||||
process-group lifecycle tests on POSIX hosts where absence reports ESRCH. */
|
||||
if (code === 'EPERM') return true
|
||||
return this.child.exitCode === null && this.child.signalCode === null
|
||||
/* v8 ignore stop */
|
||||
}
|
||||
}
|
||||
|
||||
private onStdout(chunk: Buffer): void {
|
||||
let messages: unknown[]
|
||||
try {
|
||||
messages = this.decoder.push(chunk)
|
||||
} catch (error) {
|
||||
// A framing/JSON failure corrupts the stream position irrecoverably: fail the instance and
|
||||
// SIGKILL the whole group so helper processes don't outlive the leader.
|
||||
this.fail(asError(error))
|
||||
this.signalGroup('SIGKILL')
|
||||
return
|
||||
}
|
||||
for (const message of messages) this.dispatch(message)
|
||||
}
|
||||
|
||||
private onStderr(chunk: Buffer): void {
|
||||
// Retain the TAIL, not the prefix: a language server's fatal diagnostic usually appears just
|
||||
// before it exits, so the final bounded segment is the useful one.
|
||||
const cap = this.spec.maxStderrBytes
|
||||
if (chunk.length >= cap) {
|
||||
// Copy the bounded suffix so retaining it does not pin an arbitrarily large incoming buffer.
|
||||
this.stderr = Buffer.from(chunk.subarray(chunk.length - cap))
|
||||
return
|
||||
}
|
||||
const retainedBytes = Math.min(this.stderr.length, cap - chunk.length)
|
||||
this.stderr = Buffer.concat([
|
||||
this.stderr.subarray(this.stderr.length - retainedBytes),
|
||||
chunk,
|
||||
], retainedBytes + chunk.length)
|
||||
}
|
||||
|
||||
private dispatch(message: unknown): void {
|
||||
if (message === null || typeof message !== 'object') return
|
||||
const frame = message as Record<string, unknown>
|
||||
const id = frame.id
|
||||
const method = frame.method
|
||||
if (typeof method === 'string' && (typeof id === 'number' || typeof id === 'string')) {
|
||||
// A response-write failure has already invalidated the connection in `write()`.
|
||||
/* v8 ignore next -- protocol tests exercise response writes; only a simultaneous connection
|
||||
failure makes this consumption handler run. */
|
||||
void this.handleServerRequest(id, method, frame.params).catch(() => {})
|
||||
return
|
||||
}
|
||||
if (typeof method === 'string') {
|
||||
// A server→client notification (e.g. diagnostics, logs): ignored by this MVP host.
|
||||
return
|
||||
}
|
||||
if (typeof id === 'number') this.handleResponse(id, frame)
|
||||
}
|
||||
|
||||
private async handleServerRequest(id: number | string, method: string, params: unknown): Promise<void> {
|
||||
try {
|
||||
const result = await this.onServerRequest(method, params)
|
||||
await this.write({ jsonrpc: '2.0', id, result })
|
||||
} catch (error) {
|
||||
await this.write({ jsonrpc: '2.0', id, error: { code: -32601, message: asError(error).message } })
|
||||
}
|
||||
}
|
||||
|
||||
private handleResponse(id: number, frame: Record<string, unknown>): void {
|
||||
const pending = this.pending.get(id)
|
||||
if (!pending) return
|
||||
this.pending.delete(id)
|
||||
const error = frame.error
|
||||
if (error !== null && typeof error === 'object') {
|
||||
const record = error as Record<string, unknown>
|
||||
pending.reject(new Error(typeof record.message === 'string' ? record.message : 'LSP error response'))
|
||||
return
|
||||
}
|
||||
pending.resolve(frame.result)
|
||||
}
|
||||
|
||||
private write(message: unknown): Promise<void> {
|
||||
if (this.closeReason !== undefined) return Promise.reject(this.closeReason)
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const done = (error?: Error | null): void => {
|
||||
if (error === undefined || error === null) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
this.fail(error)
|
||||
reject(error)
|
||||
}
|
||||
try {
|
||||
this.child.stdin.write(encodeMessage(message), done)
|
||||
/* v8 ignore start -- Node stream write failures are callback-delivered; this guards a
|
||||
nonconforming Writable implementation throwing synchronously. */
|
||||
} catch (error) {
|
||||
const failure = asError(error)
|
||||
this.fail(failure)
|
||||
reject(failure)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
})
|
||||
}
|
||||
|
||||
/** The exit-close error message, appending the retained stderr tail when the server wrote any. */
|
||||
private exitMessage(): string {
|
||||
const tail = this.stderrTail.trim()
|
||||
return tail === '' ? 'language server exited' : `language server exited; stderr: ${tail}`
|
||||
}
|
||||
|
||||
private fail(error: Error): void {
|
||||
/* v8 ignore next -- the second arm (closeReason already set) needs two fail() calls before close; defensive. */
|
||||
if (this.closeReason === undefined) this.closeReason = error
|
||||
this.failAll(error)
|
||||
}
|
||||
|
||||
private failAll(error: Error): void {
|
||||
const waiting = [...this.pending.values()]
|
||||
this.pending.clear()
|
||||
for (const pending of waiting) pending.reject(error)
|
||||
}
|
||||
}
|
||||
|
||||
/** Coerce an unknown thrown value to an `Error`. */
|
||||
function asError(value: unknown): Error {
|
||||
/* v8 ignore next -- the non-Error branch guards against a non-Error throw, which our paths never produce. */
|
||||
return value instanceof Error ? value : new Error(String(value))
|
||||
}
|
||||
102
packages/lsp/lsp-local/src/framing.ts
Normal file
102
packages/lsp/lsp-local/src/framing.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* LSP base-protocol framing: `Content-Length`-delimited JSON-RPC over a byte stream. The encoder
|
||||
* produces one framed buffer; the decoder buffers incoming bytes and yields complete message bodies,
|
||||
* bounding the header and total message size so a hostile or broken server cannot exhaust memory.
|
||||
* @module @deepseek-ai/dsh-lsp-local/framing
|
||||
*/
|
||||
|
||||
/** The header/body separator in the LSP base protocol. */
|
||||
const HEADER_SEPARATOR = '\r\n\r\n'
|
||||
|
||||
/** Cap on the header section so a server that never sends the separator cannot grow the buffer forever. */
|
||||
const MAX_HEADER_BYTES = 1 << 16
|
||||
|
||||
/**
|
||||
* Encode one JSON-RPC message as a framed LSP buffer (`Content-Length: N\r\n\r\n<utf-8 json>`).
|
||||
* @param message - the JSON-RPC message object to serialize.
|
||||
* @returns the framed bytes ready to write to the server's stdin.
|
||||
*/
|
||||
export function encodeMessage(message: unknown): Buffer {
|
||||
const body = Buffer.from(JSON.stringify(message), 'utf8')
|
||||
const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii')
|
||||
return Buffer.concat([header, body])
|
||||
}
|
||||
|
||||
/**
|
||||
* A streaming decoder for `Content-Length`-framed JSON-RPC. Feed it stdout chunks; it returns any
|
||||
* whole message bodies that completed. It parses only the `Content-Length` header and ignores other
|
||||
* headers (e.g. `Content-Type`), matching the base protocol.
|
||||
*/
|
||||
export class MessageDecoder {
|
||||
private buffer: Buffer = Buffer.alloc(0)
|
||||
private readonly maxMessageBytes: number
|
||||
|
||||
/**
|
||||
* @param maxMessageBytes - reject any single framed body larger than this (guards memory).
|
||||
*/
|
||||
constructor(maxMessageBytes: number) {
|
||||
this.maxMessageBytes = maxMessageBytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a chunk and return every message body that is now complete.
|
||||
* @param chunk - raw bytes from the server's stdout.
|
||||
* @returns the parsed JSON bodies, in arrival order (possibly empty).
|
||||
* @throws Error when a header is malformed or a body exceeds `maxMessageBytes`.
|
||||
*/
|
||||
push(chunk: Buffer): unknown[] {
|
||||
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk])
|
||||
const messages: unknown[] = []
|
||||
for (;;) {
|
||||
const step = this.next()
|
||||
if (!step.ready) break
|
||||
messages.push(step.message)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
/** Parse and consume the next complete message, or report that more bytes are needed. */
|
||||
private next(): { ready: false } | { ready: true; message: unknown } {
|
||||
const separator = this.buffer.indexOf(HEADER_SEPARATOR)
|
||||
if (separator < 0) {
|
||||
if (this.buffer.length > MAX_HEADER_BYTES) {
|
||||
throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes without a terminator`)
|
||||
}
|
||||
return { ready: false }
|
||||
}
|
||||
if (separator > MAX_HEADER_BYTES) {
|
||||
throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes`)
|
||||
}
|
||||
const headerText = this.buffer.toString('ascii', 0, separator)
|
||||
const contentLength = parseContentLength(headerText)
|
||||
if (contentLength > this.maxMessageBytes) {
|
||||
throw new Error(`LSP message length ${contentLength} exceeds the ${this.maxMessageBytes}-byte limit`)
|
||||
}
|
||||
const bodyStart = separator + HEADER_SEPARATOR.length
|
||||
const bodyEnd = bodyStart + contentLength
|
||||
if (this.buffer.length < bodyEnd) return { ready: false }
|
||||
const body = this.buffer.toString('utf8', bodyStart, bodyEnd)
|
||||
this.buffer = this.buffer.subarray(bodyEnd)
|
||||
try {
|
||||
return { ready: true, message: JSON.parse(body) }
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- JSON.parse throws a SyntaxError (an Error); the String() fallback is defensive. */
|
||||
throw new Error(`LSP message body was not valid JSON: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the `Content-Length` header value (case-insensitive), rejecting a missing or non-numeric one. */
|
||||
function parseContentLength(headerText: string): number {
|
||||
for (const line of headerText.split('\r\n')) {
|
||||
const colon = line.indexOf(':')
|
||||
if (colon < 0) continue
|
||||
if (line.slice(0, colon).trim().toLowerCase() !== 'content-length') continue
|
||||
const value = Number(line.slice(colon + 1).trim())
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`invalid Content-Length header: ${JSON.stringify(line)}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
throw new Error(`LSP header block missing Content-Length: ${JSON.stringify(headerText)}`)
|
||||
}
|
||||
154
packages/lsp/lsp-local/src/host.ts
Normal file
154
packages/lsp/lsp-local/src/host.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Host-filesystem source access for the local provider, using Node APIs directly in the
|
||||
* subprocess's namespace (never `ctx.fs`): only the LSP result is model-visible, so a query does not
|
||||
* satisfy read-before-write policy and emits no `fs/observed`. Canonicalization derives target
|
||||
* identity from `realpath`, so symlink aliases share a workspace; a source is rejected before server
|
||||
* startup when it is missing, non-regular, non-UTF-8, oversized, or canonically outside the
|
||||
* workspace. External result locations are allowed, but an external path can never become a query
|
||||
* source.
|
||||
* @module @deepseek-ai/dsh-lsp-local/host
|
||||
*/
|
||||
|
||||
import { constants } from 'node:fs'
|
||||
import { open, realpath, stat } from 'node:fs/promises'
|
||||
import type { FileHandle } from 'node:fs/promises'
|
||||
import { isAbsolute, resolve as resolvePath, sep } from 'node:path'
|
||||
import { throwIfAborted } from './abort.ts'
|
||||
|
||||
/** A validated source: its canonical absolute path and current UTF-8 text. */
|
||||
export interface HostSource {
|
||||
/** The canonical (realpath-resolved) absolute path, inside the canonical workspace. */
|
||||
readonly canonicalPath: string
|
||||
/** The file's current text, read as UTF-8. */
|
||||
readonly text: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonicalize a workspace root: it must exist and be a directory. The returned realpath supplies
|
||||
* process cwd, `rootUri`, the sole `workspaceFolders` entry, and pool identity, so symlinked roots
|
||||
* collapse to one instance.
|
||||
* @param workspaceRoot - the caller's workspace root (absolute).
|
||||
* @param signal - optional cancellation observed around each filesystem operation.
|
||||
* @returns the canonical directory path.
|
||||
* @throws Error when the path is missing or not a directory.
|
||||
*/
|
||||
export async function canonicalizeWorkspace(workspaceRoot: string, signal?: AbortSignal): Promise<string> {
|
||||
throwIfAborted(signal)
|
||||
let canonical: string
|
||||
try {
|
||||
canonical = await realpath(workspaceRoot)
|
||||
} catch (error) {
|
||||
throw new Error(`workspace root "${workspaceRoot}" cannot be resolved: ${messageOf(error)}`)
|
||||
}
|
||||
throwIfAborted(signal)
|
||||
const info = await stat(canonical)
|
||||
throwIfAborted(signal)
|
||||
if (!info.isDirectory()) {
|
||||
throw new Error(`workspace root "${workspaceRoot}" is not a directory`)
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve, canonicalize, validate, and read a query source in one pass. A relative `filePath`
|
||||
* resolves against `canonicalWorkspace`; an absolute one is taken directly. The canonical target
|
||||
* must be a regular UTF-8 file no larger than `maxDocumentBytes`, and must lie inside the canonical
|
||||
* workspace.
|
||||
* @param filePath - the model-supplied source path (relative or absolute).
|
||||
* @param canonicalWorkspace - the already-canonicalized workspace root.
|
||||
* @param maxDocumentBytes - the largest source this host will open.
|
||||
* @param signal - optional cancellation observed throughout resolution, validation, and reading.
|
||||
* @returns the canonical path and current UTF-8 text.
|
||||
* @throws Error when the source is missing, non-regular, oversized, non-UTF-8, or out of workspace.
|
||||
*/
|
||||
export async function readHostSource(
|
||||
filePath: string,
|
||||
canonicalWorkspace: string,
|
||||
maxDocumentBytes: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HostSource> {
|
||||
throwIfAborted(signal)
|
||||
const requested = isAbsolute(filePath) ? filePath : resolvePath(canonicalWorkspace, filePath)
|
||||
let canonicalPath: string
|
||||
try {
|
||||
canonicalPath = await realpath(requested)
|
||||
} catch (error) {
|
||||
throw new Error(`source "${filePath}" cannot be resolved: ${messageOf(error)}`)
|
||||
}
|
||||
throwIfAborted(signal)
|
||||
if (!isInside(canonicalWorkspace, canonicalPath)) {
|
||||
throw new Error(`source "${filePath}" resolves outside the workspace`)
|
||||
}
|
||||
// Open ONE handle after containment, then stat and read through it: a concurrent replace between
|
||||
// realpath and read cannot swap the target, so the regular-file and size checks bind the bytes we
|
||||
// actually read (no path-based TOCTOU). O_NOFOLLOW rejects the final component being swapped for a
|
||||
// symlink between realpath and open (which would otherwise escape the workspace).
|
||||
// O_NONBLOCK prevents a FIFO with no writer from hanging before fstat can reject it as nonregular.
|
||||
const handle = await open(canonicalPath, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)
|
||||
try {
|
||||
throwIfAborted(signal)
|
||||
const info = await handle.stat()
|
||||
throwIfAborted(signal)
|
||||
if (!info.isFile()) {
|
||||
throw new Error(`source "${filePath}" is not a regular file`)
|
||||
}
|
||||
if (info.size > maxDocumentBytes) {
|
||||
throw new Error(`source "${filePath}" is ${info.size} bytes, over the ${maxDocumentBytes}-byte limit`)
|
||||
}
|
||||
// Bound the read to the cap even if the file grew after stat: read one extra byte and reject on
|
||||
// overflow, so a concurrent grow cannot defeat the memory bound.
|
||||
const buffer = await readCapped(handle, maxDocumentBytes, filePath, signal)
|
||||
const text = decodeUtf8Strict(buffer, filePath)
|
||||
throwIfAborted(signal)
|
||||
return { canonicalPath, text }
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Read at most `maxBytes` from the handle, rejecting when the source overflows the cap. */
|
||||
async function readCapped(
|
||||
handle: FileHandle,
|
||||
maxBytes: number,
|
||||
filePath: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Buffer> {
|
||||
const limit = maxBytes + 1
|
||||
const chunk = Buffer.allocUnsafe(limit)
|
||||
let total = 0
|
||||
for (;;) {
|
||||
throwIfAborted(signal)
|
||||
const { bytesRead } = await handle.read(chunk, total, limit - total, total)
|
||||
throwIfAborted(signal)
|
||||
if (bytesRead === 0) break
|
||||
total += bytesRead
|
||||
/* v8 ignore next 3 -- overflow requires the file to grow past the cap between stat and read (a concurrent mutation); defensive. */
|
||||
if (total > maxBytes) {
|
||||
throw new Error(`source "${filePath}" grew past the ${maxBytes}-byte limit while reading`)
|
||||
}
|
||||
}
|
||||
return chunk.subarray(0, total)
|
||||
}
|
||||
|
||||
/** Whether `child` is the workspace itself or a descendant of it (both already canonical). */
|
||||
function isInside(workspace: string, child: string): boolean {
|
||||
if (child === workspace) return true
|
||||
/* v8 ignore next -- a canonical non-root workspace never ends with a separator; the guard covers the filesystem root. */
|
||||
const base = workspace.endsWith(sep) ? workspace : workspace + sep
|
||||
return child.startsWith(base)
|
||||
}
|
||||
|
||||
/** Decode strictly as UTF-8: a fatal decoder rejects only malformed bytes, keeping a legitimate U+FFFD. */
|
||||
function decodeUtf8Strict(buffer: Buffer, filePath: string): string {
|
||||
try {
|
||||
return new TextDecoder('utf-8', { fatal: true }).decode(buffer)
|
||||
} catch {
|
||||
throw new Error(`source "${filePath}" is not valid UTF-8 text`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract a message from an unknown thrown value without leaking `any`. */
|
||||
function messageOf(error: unknown): string {
|
||||
/* v8 ignore next -- Node fs rejections are always Error instances; the String() fallback is defensive. */
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
336
packages/lsp/lsp-local/src/index.ts
Normal file
336
packages/lsp/lsp-local/src/index.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
/**
|
||||
* 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.
|
||||
* @module @deepseek-ai/dsh-lsp-local
|
||||
*/
|
||||
|
||||
import { accessSync, constants, statSync } from 'node:fs'
|
||||
import { delimiter, isAbsolute, join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp'
|
||||
import type {
|
||||
LspProvider,
|
||||
LspProviderQuery,
|
||||
LspQueryResult,
|
||||
} from '@deepseek-ai/dsh-lsp'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { abortable, abortError } from './abort.ts'
|
||||
import { canonicalizeWorkspace, readHostSource } from './host.ts'
|
||||
import { LspInstance } from './instance.ts'
|
||||
import type { InstanceSpec } from './instance.ts'
|
||||
|
||||
export { canonicalizeWorkspace, readHostSource } from './host.ts'
|
||||
export { encodeMessage, MessageDecoder } from './framing.ts'
|
||||
export {
|
||||
negotiatePositionEncoding,
|
||||
normalizeHover,
|
||||
normalizeLocations,
|
||||
requestMethod,
|
||||
supportsOperation,
|
||||
supportsTransientOpen,
|
||||
} from './translate.ts'
|
||||
export { LspInstance } from './instance.ts'
|
||||
export { LspConnection } from './connection.ts'
|
||||
|
||||
/** Cordis plugin name for loader diagnostics. */
|
||||
export const name = 'lsp-local'
|
||||
|
||||
/** Services required by this plugin. */
|
||||
export const inject = ['lsp']
|
||||
|
||||
/** Credential-shaped ambient env vars are NOT forwarded to the child by default. */
|
||||
const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i
|
||||
|
||||
const DEFAULT_MAX_MESSAGE_BYTES = 16_000_000
|
||||
const DEFAULT_MAX_STDERR_BYTES = 1_000_000
|
||||
const DEFAULT_MAX_DOCUMENT_BYTES = 4_000_000
|
||||
const DEFAULT_SHUTDOWN_TIMEOUT_MS = 5_000
|
||||
const DEFAULT_KILL_GRACE_MS = 2_000
|
||||
|
||||
/** 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' }`). */
|
||||
extensionToLanguage: Record<string, string>
|
||||
/** Arguments passed to the executable (no shell). Default `[]`. */
|
||||
args?: string[]
|
||||
/** Extra env vars merged on top of the scrubbed ambient env. Default `{}`. */
|
||||
env?: Record<string, string>
|
||||
/** Static `initialize` options forwarded to the server. Default `null`. */
|
||||
initializationOptions?: unknown
|
||||
/** Static answer to every `workspace/configuration` item. Default `null`. */
|
||||
configuration?: unknown
|
||||
/** Largest single framed message accepted from the server (bytes). Default 16000000. */
|
||||
maxMessageBytes?: number
|
||||
/** Largest stderr tail retained for diagnostics (bytes). Default 1000000. */
|
||||
maxStderrBytes?: number
|
||||
/** Largest source file this host will open (bytes). Default 4000000. */
|
||||
maxDocumentBytes?: number
|
||||
/** Graceful `shutdown`/`exit` budget before escalation (ms). Default 5000. */
|
||||
shutdownTimeoutMs?: number
|
||||
/** Request-cancel and SIGTERM→SIGKILL grace (ms). Default 2000. */
|
||||
killGraceMs?: number
|
||||
}
|
||||
|
||||
/** 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<string, LspLocalServerConfig>
|
||||
}
|
||||
|
||||
/** One server config after schemastery fills every default. */
|
||||
type ResolvedServerConfig = Required<LspLocalServerConfig>
|
||||
|
||||
const LspLocalServerConfig: z<LspLocalServerConfig> = z.object({
|
||||
command: z.string().required(),
|
||||
args: z.array(String).default([]),
|
||||
env: z.dict(String).default({}),
|
||||
extensionToLanguage: z.dict(String).required(),
|
||||
initializationOptions: z.any().default(null),
|
||||
configuration: z.any().default(null),
|
||||
maxMessageBytes: z.number().default(DEFAULT_MAX_MESSAGE_BYTES),
|
||||
maxStderrBytes: z.number().default(DEFAULT_MAX_STDERR_BYTES),
|
||||
maxDocumentBytes: z.number().default(DEFAULT_MAX_DOCUMENT_BYTES),
|
||||
shutdownTimeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_SHUTDOWN_TIMEOUT_MS),
|
||||
killGraceMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_KILL_GRACE_MS),
|
||||
})
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
servers: z.dict(LspLocalServerConfig).required(),
|
||||
})
|
||||
|
||||
/**
|
||||
* 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 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.
|
||||
assertTimer(providerId, 'shutdownTimeoutMs', resolved.shutdownTimeoutMs)
|
||||
assertTimer(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(providerId, 'maxStderrBytes', resolved.maxStderrBytes)
|
||||
assertPositiveInteger(providerId, 'maxMessageBytes', resolved.maxMessageBytes)
|
||||
assertPositiveInteger(providerId, 'maxDocumentBytes', resolved.maxDocumentBytes)
|
||||
}
|
||||
|
||||
/** Reject a timer value Node would clamp instead of scheduling as configured. */
|
||||
function assertTimer(providerId: string, name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`lsp-local: servers.${providerId}.${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject a nonpositive or non-integer config value at load, so misconfiguration fails loud. */
|
||||
function assertPositiveInteger(providerId: string, name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`lsp-local: servers.${providerId}.${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/** A pooled generic provider: one server process per canonical workspace, created on demand. */
|
||||
class LocalLspProvider implements LspProvider {
|
||||
readonly id: LspProviderId
|
||||
readonly extensionToLanguage: Readonly<Record<string, string>>
|
||||
/** One live instance per canonical workspace realpath. */
|
||||
private readonly instances = new Map<string, LspInstance>()
|
||||
/** One complete source-read→open→query→close serialization tail per canonical workspace. */
|
||||
private readonly queues = new Map<string, Promise<void>>()
|
||||
private disposed = false
|
||||
|
||||
constructor(
|
||||
providerId: string,
|
||||
private readonly config: ResolvedServerConfig,
|
||||
private readonly childEnv: Record<string, string>,
|
||||
private readonly executable: string,
|
||||
) {
|
||||
this.id = LspProviderId(providerId)
|
||||
this.extensionToLanguage = config.extensionToLanguage
|
||||
}
|
||||
|
||||
/** Read the disposed flag through a method so a `query()` await cannot narrow it to a literal. */
|
||||
private isDisposed(): boolean {
|
||||
return this.disposed
|
||||
}
|
||||
|
||||
/** Reject work that cannot publish or use a provider-owned instance. */
|
||||
private assertActive(signal?: AbortSignal): void {
|
||||
/* v8 ignore next -- the seam unregisters this provider before disposal; direct in-flight calls
|
||||
exercise the post-await check instead. */
|
||||
if (this.isDisposed()) throw new LspError('lsp-local provider is disposed', 'LSP_DISPOSED')
|
||||
if (signal?.aborted) throw abortError(signal)
|
||||
}
|
||||
|
||||
async query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
// Honor an already-aborted signal before host I/O so a canceled request never starts a server.
|
||||
this.assertActive(signal)
|
||||
const workspace = await canonicalizeWorkspace(request.workspaceRoot, signal)
|
||||
this.assertActive(signal)
|
||||
return this.enqueue(workspace, signal, async () => {
|
||||
this.assertActive(signal)
|
||||
// Read inside the workspace queue but before spawning: a queued query sees current bytes when
|
||||
// its turn starts, while an invalid source still cannot leave an idle process pooled.
|
||||
const source = await readHostSource(request.filePath, workspace, this.config.maxDocumentBytes, signal)
|
||||
// Disposal may have snapshotted the instance map while host I/O was pending. Re-check before a
|
||||
// synchronous get-or-create so every spawned process remains owned by teardown.
|
||||
this.assertActive(signal)
|
||||
let instance = this.instanceFor(workspace)
|
||||
if (instance.dead) {
|
||||
this.evictIfCurrent(workspace, instance)
|
||||
instance = this.instanceFor(workspace)
|
||||
}
|
||||
try {
|
||||
return await instance.query(request, source, signal)
|
||||
} finally {
|
||||
// Drop a crashed slot only when it still owns this instance; a replacement must survive.
|
||||
if (instance.dead) this.evictIfCurrent(workspace, instance)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Serialize one complete query lifecycle for a canonical workspace. */
|
||||
private enqueue<T>(workspace: string, signal: AbortSignal | undefined, run: () => Promise<T>): Promise<T> {
|
||||
const previous = this.queues.get(workspace) ?? Promise.resolve()
|
||||
const result = abortable(previous, signal).then(run)
|
||||
// The tail follows the actual prior work even when this caller aborts its wait. It never rejects,
|
||||
// so later callers serialize without inheriting an earlier query's outcome.
|
||||
const tail = previous.then(() => result).then(() => undefined, () => undefined)
|
||||
this.queues.set(workspace, tail)
|
||||
void tail.then(() => {
|
||||
if (this.queues.get(workspace) === tail) this.queues.delete(workspace)
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
/** Return or synchronously publish the one instance for a canonical workspace. */
|
||||
private instanceFor(workspace: string): LspInstance {
|
||||
this.assertActive()
|
||||
const existing = this.instances.get(workspace)
|
||||
if (existing !== undefined) return existing
|
||||
const created = this.createInstance(workspace)
|
||||
this.instances.set(workspace, created)
|
||||
return created
|
||||
}
|
||||
|
||||
/** Drop the slot iff it still contains this instance. */
|
||||
private evictIfCurrent(workspace: string, instance: LspInstance): void {
|
||||
/* v8 ignore next -- mismatch requires another query to replace the slot before this finally runs. */
|
||||
if (this.instances.get(workspace) === instance) this.instances.delete(workspace)
|
||||
}
|
||||
|
||||
private createInstance(workspace: string): LspInstance {
|
||||
const spec: InstanceSpec = {
|
||||
command: this.executable,
|
||||
args: this.config.args,
|
||||
cwd: workspace,
|
||||
env: this.childEnv,
|
||||
configuration: this.config.configuration,
|
||||
initializationOptions: this.config.initializationOptions,
|
||||
maxMessageBytes: this.config.maxMessageBytes,
|
||||
maxStderrBytes: this.config.maxStderrBytes,
|
||||
shutdownTimeoutMs: this.config.shutdownTimeoutMs,
|
||||
killGraceMs: this.config.killGraceMs,
|
||||
}
|
||||
return new LspInstance(spec)
|
||||
}
|
||||
|
||||
/** Dispose every live instance and block further queries. */
|
||||
async disposeAll(): Promise<void> {
|
||||
this.disposed = true
|
||||
const live = [...this.instances.values()]
|
||||
const draining = [...this.queues.values()]
|
||||
this.instances.clear()
|
||||
await Promise.all([
|
||||
...live.map(instance => instance.dispose()),
|
||||
...draining,
|
||||
])
|
||||
this.queues.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/** The ambient env minus credential-shaped vars, plus the config's explicit env. */
|
||||
function buildChildEnv(extra: Record<string, string>): Record<string, string> {
|
||||
const scrubbed = Object.entries(process.env).filter(
|
||||
([key, value]) => value !== undefined && !SENSITIVE_ENV_PATTERN.test(key),
|
||||
) as [string, string][]
|
||||
return { ...Object.fromEntries(scrubbed), ...extra }
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the server executable to an absolute path: an absolute command is verified directly; a
|
||||
* bare command is looked up on the child's PATH. Fails loudly when nothing is executable.
|
||||
*/
|
||||
function resolveExecutable(command: string, childEnv: Record<string, string>): string {
|
||||
if (isAbsolute(command)) {
|
||||
// Verify an absolute command too, so an unavailable one fails at load, not on the first query.
|
||||
if (!isExecutableFileSync(command)) {
|
||||
throw new Error(`lsp-local: command "${command}" is not an executable file`)
|
||||
}
|
||||
return command
|
||||
}
|
||||
/* v8 ignore next -- buildChildEnv always sets PATH from the ambient env; the further fallbacks are defensive. */
|
||||
const pathValue = childEnv.PATH ?? process.env.PATH ?? ''
|
||||
for (const dir of pathValue.split(delimiter)) {
|
||||
if (dir === '') continue
|
||||
const candidate = join(dir, command)
|
||||
if (isExecutableFileSync(candidate)) return candidate
|
||||
}
|
||||
throw new Error(`lsp-local: command "${command}" was not found on PATH`)
|
||||
}
|
||||
|
||||
/** Synchronous regular-file and executable check used only at load-time resolution. */
|
||||
function isExecutableFileSync(path: string): boolean {
|
||||
try {
|
||||
if (!statSync(path).isFile()) return false
|
||||
accessSync(path, constants.X_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
334
packages/lsp/lsp-local/src/instance.ts
Normal file
334
packages/lsp/lsp-local/src/instance.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
/**
|
||||
* One language-server instance: a connection plus the initialize handshake, the serialized abortable
|
||||
* query queue, the transient `didOpen`→request→`didClose` lifecycle, and bounded teardown. One
|
||||
* instance owns one `(provider id, canonical workspace)` process. Queries serialize through a single
|
||||
* queue so a cancellation that fails to stop the server can terminate it without killing unrelated
|
||||
* work; distinct instances run in parallel.
|
||||
* @module @deepseek-ai/dsh-lsp-local/instance
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { LspError } from '@deepseek-ai/dsh-lsp'
|
||||
import type {
|
||||
LspOperation,
|
||||
LspProviderQuery,
|
||||
LspQueryResult,
|
||||
} from '@deepseek-ai/dsh-lsp'
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import { abortable, abortError } from './abort.ts'
|
||||
import { LspConnection } from './connection.ts'
|
||||
import type { ConnectionSpec } from './connection.ts'
|
||||
import type { HostSource } from './host.ts'
|
||||
import type { WireInitializeResult, WireServerCapabilities } from './protocol.ts'
|
||||
import {
|
||||
negotiatePositionEncoding,
|
||||
normalizeHover,
|
||||
normalizeLocations,
|
||||
requestMethod,
|
||||
supportsOperation,
|
||||
supportsTransientOpen,
|
||||
} from './translate.ts'
|
||||
|
||||
/** Everything an instance needs beyond the connection spec. */
|
||||
export interface InstanceSpec extends ConnectionSpec {
|
||||
/** Static `initialize` options forwarded to the server. */
|
||||
readonly initializationOptions: unknown
|
||||
/** Graceful `shutdown`/`exit` budget before escalation (ms). */
|
||||
readonly shutdownTimeoutMs: number
|
||||
/** SIGTERM→SIGKILL grace after graceful shutdown fails (ms). */
|
||||
readonly killGraceMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A single initialized server process. Not exported as a provider — the provider single-flights and
|
||||
* pools these. `query()` serializes; `dispose()` rejects queued work and tears the process down.
|
||||
*/
|
||||
export class LspInstance {
|
||||
private readonly connection: LspConnection
|
||||
private capabilities: WireServerCapabilities | undefined
|
||||
/** The serialization tail: each query awaits the prior one, so lifecycles never interleave. */
|
||||
private queue: Promise<unknown> = Promise.resolve()
|
||||
private disposed = false
|
||||
/** The one teardown transaction shared by abort, failure, and explicit disposal. */
|
||||
private teardownPromise: Promise<void> | undefined
|
||||
/** Set once the process closes, so the pool can synchronously skip a dead instance. */
|
||||
private processClosed = false
|
||||
/** Populated once `initialize` succeeds; a failed handshake rejects every query. */
|
||||
private readonly ready: Promise<void>
|
||||
|
||||
/**
|
||||
* @param spec - the launch, initialize, and teardown parameters.
|
||||
*/
|
||||
constructor(private readonly spec: InstanceSpec) {
|
||||
this.connection = new LspConnection(spec, (method, params) => this.answerServerRequest(method, params))
|
||||
this.ready = this.initialize()
|
||||
// A handshake rejection must not surface as an unhandled rejection before the first query awaits
|
||||
// it; queries attach the real handler.
|
||||
this.ready.catch(() => {})
|
||||
void this.connection.closed.then(() => { this.processClosed = true })
|
||||
}
|
||||
|
||||
/** Synchronous liveness check: true once the process has closed or the instance was disposed. */
|
||||
get dead(): boolean {
|
||||
return this.processClosed || this.disposed
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one query through the serialized queue.
|
||||
* @param request - the resolved provider query.
|
||||
* @param source - the pre-validated, already-read host source (the provider reads before spawning).
|
||||
* @param signal - optional cancellation for this query's full lifecycle.
|
||||
* @returns the normalized result.
|
||||
*/
|
||||
query(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
// Serialize behind prior work, but observe abort DURING the queue wait too: if an earlier query
|
||||
// hangs (e.g. a signal-less seam caller), a later tool's timeout must still be able to give up
|
||||
// rather than block on the shared tail forever.
|
||||
const run = abortable(this.queue, signal).then(() => this.runQuery(request, source, signal))
|
||||
// Keep the tail alive regardless of this query's outcome so the next caller still serializes. The
|
||||
// tail follows the ACTUAL prior work (this.queue), not the abortable view, so a caller giving up
|
||||
// on the wait does not deserialize the queue.
|
||||
this.queue = this.queue.then(() => run).then(() => undefined, () => undefined)
|
||||
return run
|
||||
}
|
||||
|
||||
private async initialize(): Promise<void> {
|
||||
const initializeResult = await this.connection.request('initialize', {
|
||||
processId: process.pid,
|
||||
rootUri: pathToFileURL(this.spec.cwd).href,
|
||||
workspaceFolders: [{ uri: pathToFileURL(this.spec.cwd).href, name: 'workspace' }],
|
||||
capabilities: CLIENT_CAPABILITIES,
|
||||
initializationOptions: this.spec.initializationOptions,
|
||||
}) as WireInitializeResult
|
||||
const capabilities = initializeResult.capabilities
|
||||
// An omitted encoding defaults to utf-16; any other value is a protocol error we reject here.
|
||||
negotiatePositionEncoding(capabilities.positionEncoding)
|
||||
this.capabilities = capabilities
|
||||
await this.connection.notify('initialized', {})
|
||||
}
|
||||
|
||||
private async runQuery(request: LspProviderQuery, source: HostSource, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
if (this.disposed) throw new LspError('LSP instance was disposed', 'LSP_DISPOSED')
|
||||
/* v8 ignore next -- the abortable queue wait rejects a pre-aborted signal before runQuery; this is a belt-and-suspenders guard. */
|
||||
if (signal?.aborted) throw abortError(signal)
|
||||
// Observe abort during the handshake wait, and never pool a poisoned instance: if the wait ends
|
||||
// in failure — an abort on a still-pending handshake, OR `initialize` rejecting (utf-8
|
||||
// negotiation, malformed result) without the process exiting — tear the instance down so a
|
||||
// permanently-rejecting/pending `ready` can't make every later query for this workspace fail.
|
||||
try {
|
||||
await abortable(this.ready, signal)
|
||||
} catch (error) {
|
||||
if (!this.dead) {
|
||||
await this.startTeardown()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const capabilities = this.capabilities
|
||||
/* v8 ignore next -- `ready` resolves only after capabilities are set, else it rejects above; defensive. */
|
||||
if (capabilities === undefined) throw new Error('LSP instance is not initialized')
|
||||
if (!supportsOperation(capabilities, request.operation)) {
|
||||
throw new LspError(`server does not support ${request.operation}`, 'LSP_UNSUPPORTED_OPERATION')
|
||||
}
|
||||
if (!supportsTransientOpen(capabilities.textDocumentSync)) {
|
||||
throw new LspError('server does not support the transient textDocument/didOpen this host requires', 'LSP_UNSUPPORTED_OPERATION')
|
||||
}
|
||||
|
||||
const uri = pathToFileURL(source.canonicalPath).href
|
||||
let opened = false
|
||||
try {
|
||||
/* v8 ignore next -- guards an abort landing between the ready wait and didOpen; not deterministically reproducible. */
|
||||
if (signal?.aborted) throw abortError(signal)
|
||||
try {
|
||||
await abortable(this.connection.notify('textDocument/didOpen', {
|
||||
textDocument: { uri, languageId: request.languageId, version: 1, text: source.text },
|
||||
}), signal)
|
||||
} catch (error) {
|
||||
// A canceled backpressured write or failed stdin leaves the protocol stream unusable before
|
||||
// `opened` can arm the didClose cleanup. Teardown here makes the pool evict the instance.
|
||||
await this.startTeardown()
|
||||
throw error
|
||||
}
|
||||
opened = true
|
||||
const payload = await this.sendRequest(request.operation, uri, request.position, signal)
|
||||
return this.normalize(request.operation, payload)
|
||||
} finally {
|
||||
// A disposed or closed instance (e.g. an aborted request whose server ignored
|
||||
// `$/cancelRequest`) is already tearing down; sending didClose would race that teardown and let
|
||||
// the next queued query's document lifecycle overlap the still-active request.
|
||||
if (opened && !this.dead) {
|
||||
try {
|
||||
await this.connection.notify('textDocument/didClose', { textDocument: { uri } })
|
||||
} catch {
|
||||
// A close-write failure does not replace the settled result/error, but the instance can no
|
||||
// longer be trusted: invalidate it and await bounded process termination.
|
||||
try {
|
||||
await this.startTeardown()
|
||||
} catch {
|
||||
/* v8 ignore next -- teardown owns all expected process races; this only preserves the
|
||||
already-settled query outcome if an unexpected cleanup primitive itself rejects. */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async sendRequest(
|
||||
operation: LspOperation,
|
||||
uri: string,
|
||||
position: LspProviderQuery['position'],
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> {
|
||||
const params = {
|
||||
textDocument: { uri },
|
||||
position: { line: position.line, character: position.character },
|
||||
// findReferences always includes declarations: the caller gets no flag and impact analysis
|
||||
// never omits the defining site.
|
||||
...(operation === 'findReferences' ? { context: { includeDeclaration: true } } : {}),
|
||||
}
|
||||
const requestId = this.connection.peekNextId()
|
||||
const send = this.connection.request(requestMethod(operation), params)
|
||||
if (signal === undefined) return send
|
||||
return this.raceAbort(send, requestId, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* Race a pending request against abort. On abort, send `$/cancelRequest` and give the server a
|
||||
* bounded grace to acknowledge; if it does not settle in time, invalidate and tear down the
|
||||
* instance so the still-active request cannot overlap the next queued query's document lifecycle.
|
||||
*/
|
||||
private async raceAbort(send: Promise<unknown>, requestId: number, signal: AbortSignal): Promise<unknown> {
|
||||
try {
|
||||
return await abortable(send, signal)
|
||||
} catch (error) {
|
||||
if (!signal.aborted) throw error
|
||||
this.connection.cancel(requestId)
|
||||
// Wait, bounded, for the server to honor the cancellation. If it does not, the request is still
|
||||
// running: terminate the instance (disposal awaits process close) so nothing outlives the query.
|
||||
const grace = deadline(undefined, this.spec.killGraceMs, 'LSP_CANCEL_GRACE')
|
||||
try {
|
||||
// `settled` is true if the request finished (either outcome) before the grace elapsed.
|
||||
const settled = await Promise.race([
|
||||
send.then(markSettled, markSettled),
|
||||
new Promise<boolean>((resolve) => {
|
||||
/* v8 ignore next -- the cancel-grace deadline signal is freshly armed and not yet aborted here; defensive. */
|
||||
if (grace.signal.aborted) { resolve(false); return }
|
||||
grace.signal.addEventListener('abort', () => { resolve(false) }, { once: true })
|
||||
}),
|
||||
])
|
||||
if (!settled) await this.startTeardown()
|
||||
} finally {
|
||||
grace[Symbol.dispose]()
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private normalize(operation: LspOperation, payload: unknown): LspQueryResult {
|
||||
if (operation === 'hover') {
|
||||
return { kind: 'hover', hover: normalizeHover(payload) }
|
||||
}
|
||||
// `spec.cwd` is the canonical workspace realpath (the provider canonicalizes before spawning),
|
||||
// and every `file:` location URI is relative to it — so it is the root a caller must relativize
|
||||
// display paths against, not the request's possibly-symlinked workspaceRoot.
|
||||
return { kind: 'locations', locations: normalizeLocations(payload), resolvedWorkspaceRoot: this.spec.cwd }
|
||||
}
|
||||
|
||||
private answerServerRequest(method: string, params: unknown): Promise<unknown> {
|
||||
if (method === 'workspace/configuration') {
|
||||
// Answer every requested item with the one static configuration value.
|
||||
const record = params as { items?: unknown[] } | null
|
||||
/* v8 ignore next -- a configuration request always carries an items array; the empty fallback is defensive. */
|
||||
const items = Array.isArray(record?.items) ? record.items : []
|
||||
return Promise.resolve(items.map(() => this.spec.configuration))
|
||||
}
|
||||
if (LIFECYCLE_NOOP_METHODS.has(method)) {
|
||||
// Accept lifecycle bookkeeping requests with an empty result; we register nothing dynamic.
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
if (method === 'workspace/applyEdit') {
|
||||
// This host never applies edits or runs commands.
|
||||
return Promise.reject(new Error('workspace/applyEdit is not permitted by this host'))
|
||||
}
|
||||
return Promise.reject(new Error(`unsupported server request: ${method}`))
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject queued work, attempt graceful `shutdown`/`exit`, then escalate SIGTERM→SIGKILL, awaiting
|
||||
* process close so nothing outlives disposal.
|
||||
*/
|
||||
async dispose(): Promise<void> {
|
||||
await this.startTeardown()
|
||||
}
|
||||
|
||||
/** Publish disposal once and make every caller await the same quiescence boundary. */
|
||||
private startTeardown(): Promise<void> {
|
||||
this.disposed = true
|
||||
this.teardownPromise ??= this.tearDown()
|
||||
return this.teardownPromise
|
||||
}
|
||||
|
||||
private async tearDown(): Promise<void> {
|
||||
const shutdownDeadline = deadline(undefined, this.spec.shutdownTimeoutMs, 'LSP_SHUTDOWN')
|
||||
try {
|
||||
await this.gracefulShutdown(shutdownDeadline.signal)
|
||||
} catch {
|
||||
// Graceful shutdown failed or timed out; process-group cleanup below remains authoritative.
|
||||
} finally {
|
||||
shutdownDeadline[Symbol.dispose]()
|
||||
}
|
||||
await this.forceTerminate()
|
||||
}
|
||||
|
||||
/** Best-effort LSP `shutdown`/`exit`, including process close, bounded by `signal`. */
|
||||
private async gracefulShutdown(signal: AbortSignal): Promise<void> {
|
||||
await abortable(this.connection.request('shutdown', null), signal)
|
||||
await this.connection.notify('exit', null)
|
||||
await abortable(this.connection.closed, signal)
|
||||
}
|
||||
|
||||
/** SIGTERM the group, escalate after `killGraceMs`, then await leader and helper exit. */
|
||||
private async forceTerminate(): Promise<void> {
|
||||
this.connection.terminate()
|
||||
const graceDeadline = deadline(undefined, this.spec.killGraceMs, 'LSP_KILL_GRACE')
|
||||
let groupExited: boolean
|
||||
try {
|
||||
groupExited = await this.connection.waitForProcessGroupExit(graceDeadline.signal)
|
||||
} finally {
|
||||
graceDeadline[Symbol.dispose]()
|
||||
}
|
||||
if (!groupExited) this.connection.kill()
|
||||
await Promise.all([
|
||||
this.connection.closed,
|
||||
this.connection.waitForProcessGroupExit(),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
/** Server→client request methods this host acknowledges with an empty result (no dynamic registration). */
|
||||
const LIFECYCLE_NOOP_METHODS = new Set([
|
||||
'window/workDoneProgress/create',
|
||||
'client/registerCapability',
|
||||
'client/unregisterCapability',
|
||||
])
|
||||
|
||||
/** Mark a settled request in the cancel-grace race (either outcome means the request finished). */
|
||||
function markSettled(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* The client capabilities advertised at `initialize`: UTF-16 positions, workspace folders and
|
||||
* configuration, markdown/plaintext hover, and link support for definition/implementation. No
|
||||
* dynamic registration; the server's returned capabilities are authoritative.
|
||||
*/
|
||||
const CLIENT_CAPABILITIES = {
|
||||
general: { positionEncodings: ['utf-16'] },
|
||||
workspace: { workspaceFolders: true, configuration: true },
|
||||
textDocument: {
|
||||
synchronization: { dynamicRegistration: false },
|
||||
hover: { contentFormat: ['markdown', 'plaintext'] },
|
||||
definition: { linkSupport: true },
|
||||
implementation: { linkSupport: true },
|
||||
references: {},
|
||||
},
|
||||
} as const
|
||||
30
packages/lsp/lsp-local/src/invariant.ts
Normal file
30
packages/lsp/lsp-local/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-lsp-local`.
|
||||
* @module @deepseek-ai/dsh-lsp-local/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'lsp-local-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: process pools and per-workspace queues are private implementation state,
|
||||
* and this provider publishes no independent lifecycle event stream or enumerable snapshot.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
80
packages/lsp/lsp-local/src/protocol.ts
Normal file
80
packages/lsp/lsp-local/src/protocol.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* The subset of LSP wire types this generic host reads and writes: initialize capabilities, the four
|
||||
* request results (`Location`, `LocationLink`, `Hover`), and the `textDocumentSync` shapes used to
|
||||
* decide transient-open support. Types only. Fields absent from a real server payload stay optional;
|
||||
* the translation layer normalizes them into the seam's closed unions.
|
||||
* @module @deepseek-ai/dsh-lsp-local/protocol
|
||||
*/
|
||||
|
||||
/** A zero-based UTF-16 position on the wire (the protocol's `Position`). */
|
||||
export interface WirePosition {
|
||||
readonly line: number
|
||||
readonly character: number
|
||||
}
|
||||
|
||||
/** A wire range (`Range`). */
|
||||
export interface WireRange {
|
||||
readonly start: WirePosition
|
||||
readonly end: WirePosition
|
||||
}
|
||||
|
||||
/** A `Location`: a document URI plus a range. */
|
||||
export interface WireLocation {
|
||||
readonly uri: string
|
||||
readonly range: WireRange
|
||||
}
|
||||
|
||||
/** A `LocationLink`: the target uri plus the selection range to focus. */
|
||||
export interface WireLocationLink {
|
||||
readonly targetUri: string
|
||||
readonly targetSelectionRange: WireRange
|
||||
readonly targetRange?: WireRange
|
||||
}
|
||||
|
||||
/** A `MarkupContent` hover body (`markdown` or `plaintext`). */
|
||||
export interface WireMarkupContent {
|
||||
readonly kind: 'markdown' | 'plaintext'
|
||||
readonly value: string
|
||||
}
|
||||
|
||||
/** A `MarkedString` object form (`{ language, value }`); the string form is a bare `string`. */
|
||||
export interface WireMarkedStringObject {
|
||||
readonly language: string
|
||||
readonly value: string
|
||||
}
|
||||
|
||||
/** One `MarkedString`: a raw string or a language-tagged code block. */
|
||||
export type WireMarkedString = string | WireMarkedStringObject
|
||||
|
||||
/** A `Hover`: contents in any of the protocol's three encodings, plus an optional range. */
|
||||
export interface WireHover {
|
||||
readonly contents: WireMarkupContent | WireMarkedString | readonly WireMarkedString[]
|
||||
readonly range?: WireRange
|
||||
}
|
||||
|
||||
/** The legacy enum form of `textDocumentSync` (`0` None, `1` Full, `2` Incremental). */
|
||||
export type WireTextDocumentSyncKind = 0 | 1 | 2
|
||||
|
||||
/** The options form of `textDocumentSync` (`{ openClose, change }`). */
|
||||
export interface WireTextDocumentSyncOptions {
|
||||
readonly openClose?: boolean
|
||||
readonly change?: WireTextDocumentSyncKind
|
||||
}
|
||||
|
||||
/** A `ServerCapabilities.provider` slot: a boolean or an options object (both mean "supported"). */
|
||||
export type WireProviderCapability = boolean | Record<string, unknown> | undefined
|
||||
|
||||
/** The `ServerCapabilities` fields this host inspects. */
|
||||
export interface WireServerCapabilities {
|
||||
readonly positionEncoding?: string
|
||||
readonly textDocumentSync?: WireTextDocumentSyncKind | WireTextDocumentSyncOptions
|
||||
readonly definitionProvider?: WireProviderCapability
|
||||
readonly referencesProvider?: WireProviderCapability
|
||||
readonly implementationProvider?: WireProviderCapability
|
||||
readonly hoverProvider?: WireProviderCapability
|
||||
}
|
||||
|
||||
/** The `initialize` result envelope. */
|
||||
export interface WireInitializeResult {
|
||||
readonly capabilities: WireServerCapabilities
|
||||
}
|
||||
235
packages/lsp/lsp-local/src/translate.ts
Normal file
235
packages/lsp/lsp-local/src/translate.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Pure protocol translation for the local host: what the server's capabilities allow, and how its
|
||||
* `Location`/`LocationLink`/`Hover` payloads normalize into the seam's closed result unions. No I/O
|
||||
* or process state — every function here is a pure transform, which the fake-stdio tests pin exactly.
|
||||
* @module @deepseek-ai/dsh-lsp-local/translate
|
||||
*/
|
||||
|
||||
import type {
|
||||
LspHover,
|
||||
LspLocation,
|
||||
LspOperation,
|
||||
LspRange,
|
||||
} from '@deepseek-ai/dsh-lsp'
|
||||
import { LspError } from '@deepseek-ai/dsh-lsp'
|
||||
import { assertNever } from '@deepseek-ai/dsh-llm'
|
||||
import type {
|
||||
WireHover,
|
||||
WireLocation,
|
||||
WireLocationLink,
|
||||
WireMarkedString,
|
||||
WireProviderCapability,
|
||||
WireRange,
|
||||
WireServerCapabilities,
|
||||
WireTextDocumentSyncKind,
|
||||
} from './protocol.ts'
|
||||
|
||||
/**
|
||||
* The `textDocument/*` request method for each seam operation.
|
||||
* @param operation - the seam operation to map.
|
||||
* @returns the LSP request method name.
|
||||
*/
|
||||
export function requestMethod(operation: LspOperation): string {
|
||||
switch (operation) {
|
||||
case 'goToDefinition': return 'textDocument/definition'
|
||||
case 'findReferences': return 'textDocument/references'
|
||||
case 'goToImplementation': return 'textDocument/implementation'
|
||||
case 'hover': return 'textDocument/hover'
|
||||
/* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */
|
||||
default: return assertNever(operation, 'requestMethod')
|
||||
}
|
||||
}
|
||||
|
||||
/** The `ServerCapabilities` provider field backing each operation. */
|
||||
function capabilityValue(capabilities: WireServerCapabilities, operation: LspOperation): WireProviderCapability {
|
||||
switch (operation) {
|
||||
case 'goToDefinition': return capabilities.definitionProvider
|
||||
case 'findReferences': return capabilities.referencesProvider
|
||||
case 'goToImplementation': return capabilities.implementationProvider
|
||||
case 'hover': return capabilities.hoverProvider
|
||||
/* v8 ignore next -- exhaustive over the closed LspOperation union; unreachable. */
|
||||
default: return assertNever(operation, 'capabilityValue')
|
||||
}
|
||||
}
|
||||
|
||||
/** A provider capability is present when the server sent `true` or an options object (not `false`/absent). */
|
||||
function supportsCapability(value: WireProviderCapability): boolean {
|
||||
if (value === undefined) return false
|
||||
if (typeof value === 'boolean') return value
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the server advertises the requested operation.
|
||||
* @param capabilities - the server's `initialize` capabilities.
|
||||
* @param operation - the seam operation to check.
|
||||
* @returns true when the corresponding provider capability is present.
|
||||
*/
|
||||
export function supportsOperation(capabilities: WireServerCapabilities, operation: LspOperation): boolean {
|
||||
return supportsCapability(capabilityValue(capabilities, operation))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a `textDocumentSync` value permits the transient `didOpen`/`didClose` this host relies on.
|
||||
* The legacy enum form implies open/close for `Full`/`Incremental`; the options form requires an
|
||||
* explicit `openClose: true`, because the protocol defaults an omitted `openClose` to false.
|
||||
* @param sync - the server's advertised `textDocumentSync` capability.
|
||||
* @returns true when transient open/close is supported.
|
||||
*/
|
||||
export function supportsTransientOpen(sync: WireServerCapabilities['textDocumentSync']): boolean {
|
||||
if (sync === undefined) return false
|
||||
if (typeof sync === 'number') return isOpenCloseKind(sync)
|
||||
return sync.openClose === true
|
||||
}
|
||||
|
||||
/** Legacy enum: `Full` (1) or `Incremental` (2) imply open/close support; `None` (0) does not. */
|
||||
function isOpenCloseKind(kind: WireTextDocumentSyncKind): boolean {
|
||||
return kind === 1 || kind === 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the negotiated position encoding. An omitted encoding defaults to `utf-16`; any value
|
||||
* other than `utf-16` is a protocol error this host does not support.
|
||||
* @param encoding - the server's advertised `positionEncoding`, if any.
|
||||
* @returns the string `'utf-16'`.
|
||||
* @throws Error for any non-`utf-16` encoding.
|
||||
*/
|
||||
export function negotiatePositionEncoding(encoding: string | undefined): 'utf-16' {
|
||||
if (encoding === undefined || encoding === 'utf-16') return 'utf-16'
|
||||
throw new Error(`server negotiated unsupported position encoding "${encoding}"; this host requires utf-16`)
|
||||
}
|
||||
|
||||
/** Convert a wire range to the seam's range (structurally identical, but re-shaped as `readonly`). */
|
||||
function toRange(range: WireRange): LspRange {
|
||||
return {
|
||||
start: { line: range.start.line, character: range.start.character },
|
||||
end: { line: range.end.line, character: range.end.character },
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a record is a `LocationLink` (has `targetUri` + `targetSelectionRange`). */
|
||||
function isLocationLink(value: Record<string, unknown>): boolean {
|
||||
return typeof value.targetUri === 'string' && isRange(value.targetSelectionRange)
|
||||
}
|
||||
|
||||
/** Whether a record is a `Location` (has string `uri` + a range). */
|
||||
function isLocation(value: Record<string, unknown>): boolean {
|
||||
return typeof value.uri === 'string' && isRange(value.range)
|
||||
}
|
||||
|
||||
/** Structural range guard used by both location shapes. */
|
||||
function isRange(value: unknown): value is WireRange {
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
const range = value as Record<string, unknown>
|
||||
return isPosition(range.start) && isPosition(range.end)
|
||||
}
|
||||
|
||||
/** Structural position guard. */
|
||||
function isPosition(value: unknown): boolean {
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
const position = value as Record<string, unknown>
|
||||
return isProtocolCoordinate(position.line) && isProtocolCoordinate(position.character)
|
||||
}
|
||||
|
||||
/** Whether a wire coordinate is a valid nonnegative integer. */
|
||||
function isProtocolCoordinate(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a navigation result (`Location`, `Location[]`, `LocationLink[]`, or `null`) to the seam's
|
||||
* locations. `Location` maps directly; `LocationLink` maps `targetUri` + `targetSelectionRange`.
|
||||
* @param payload - the raw `textDocument/definition|references|implementation` result.
|
||||
* @returns the normalized locations (empty for `null`/`[]`).
|
||||
* @throws Error when an element is neither a `Location` nor a `LocationLink`.
|
||||
*/
|
||||
export function normalizeLocations(payload: unknown): LspLocation[] {
|
||||
if (payload === null) return []
|
||||
if (payload === undefined) throw malformedResponse('LSP navigation result was missing')
|
||||
const elements = Array.isArray(payload) ? payload : [payload]
|
||||
const locations: LspLocation[] = []
|
||||
for (const element of elements) {
|
||||
if (element === null || typeof element !== 'object') {
|
||||
throw malformedResponse('LSP navigation result contained a non-object entry')
|
||||
}
|
||||
const record = element as Record<string, unknown>
|
||||
if (isLocationLink(record)) {
|
||||
const link = record as unknown as WireLocationLink
|
||||
locations.push({ uri: link.targetUri, range: toRange(link.targetSelectionRange) })
|
||||
} else if (isLocation(record)) {
|
||||
const location = record as unknown as WireLocation
|
||||
locations.push({ uri: location.uri, range: toRange(location.range) })
|
||||
} else {
|
||||
throw malformedResponse('LSP navigation result contained neither a Location nor a LocationLink')
|
||||
}
|
||||
}
|
||||
return locations
|
||||
}
|
||||
|
||||
/** Render one `MarkedString` (string form verbatim; object form as a language-tagged fenced block). */
|
||||
function renderMarkedString(value: WireMarkedString): string {
|
||||
if (typeof value === 'string') return value
|
||||
return `\`\`\`${value.language}\n${value.value}\n\`\`\``
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a `Hover` (or `null`) to the seam's hover. `MarkupContent` uses its `value`; a string
|
||||
* `MarkedString` is verbatim; a language-tagged `MarkedString` becomes a fenced code block; an array
|
||||
* joins its rendered parts with one blank line. The model-facing tool owns the complete result cap.
|
||||
* @param payload - the raw `textDocument/hover` result.
|
||||
* @returns the normalized hover, or `null` when there is no content.
|
||||
* @throws Error when the payload is a non-null, non-object, or structurally invalid hover.
|
||||
*/
|
||||
export function normalizeHover(payload: unknown): LspHover | null {
|
||||
if (payload === null) return null
|
||||
if (payload === undefined) throw malformedResponse('LSP hover result was missing')
|
||||
if (typeof payload !== 'object') throw malformedResponse('LSP hover result was not an object')
|
||||
const hover = payload as unknown as WireHover
|
||||
const contents = renderHoverContents(hover.contents)
|
||||
if (contents === '') return null
|
||||
const range = hover.range
|
||||
if (range === undefined) return { contents }
|
||||
if (!isRange(range)) throw malformedResponse('LSP hover result contained a malformed range')
|
||||
return { contents, range: toRange(range) }
|
||||
}
|
||||
|
||||
/** Render the three `Hover.contents` encodings into one string (input is untrusted wire data). */
|
||||
function renderHoverContents(contents: unknown): string {
|
||||
if (contents === null || contents === undefined) {
|
||||
throw malformedResponse('LSP hover result had no contents')
|
||||
}
|
||||
if (typeof contents === 'string') return contents
|
||||
if (Array.isArray(contents)) {
|
||||
return contents.map((value) => {
|
||||
if (isMarkedString(value)) return renderMarkedString(value)
|
||||
throw malformedResponse('LSP hover contents contained a malformed MarkedString')
|
||||
}).join('\n\n')
|
||||
}
|
||||
if (typeof contents !== 'object') {
|
||||
throw malformedResponse('LSP hover contents were not MarkupContent, MarkedString, or an array')
|
||||
}
|
||||
const record = contents as Record<string, unknown>
|
||||
if (record.kind === 'markdown' || record.kind === 'plaintext') {
|
||||
if (typeof record.value !== 'string') {
|
||||
throw malformedResponse('LSP hover MarkupContent value was not a string')
|
||||
}
|
||||
return record.value
|
||||
}
|
||||
if (typeof record.language === 'string' && typeof record.value === 'string') {
|
||||
return renderMarkedString({ language: record.language, value: record.value })
|
||||
}
|
||||
throw malformedResponse('LSP hover contents were not MarkupContent, MarkedString, or an array')
|
||||
}
|
||||
|
||||
/** Whether an untrusted value is either form of `MarkedString`. */
|
||||
function isMarkedString(value: unknown): value is WireMarkedString {
|
||||
if (typeof value === 'string') return true
|
||||
if (value === null || typeof value !== 'object') return false
|
||||
const record = value as Record<string, unknown>
|
||||
return typeof record.language === 'string' && typeof record.value === 'string'
|
||||
}
|
||||
|
||||
/** Create the stable structured error used for malformed server result payloads. */
|
||||
function malformedResponse(message: string): LspError {
|
||||
return new LspError(message, 'LSP_MALFORMED_RESPONSE')
|
||||
}
|
||||
73
packages/lsp/lsp-local/tests/built-lib.e2e.ts
Normal file
73
packages/lsp/lsp-local/tests/built-lib.e2e.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Keyless built-artifact smoke: plain Node imports `@deepseek-ai/dsh-lsp` and
|
||||
* `@deepseek-ai/dsh-lsp-local` by name through their exports maps, spawns the fixture server, runs
|
||||
* one query (exercising real `Content-Length` framing over `lib/index.js`), and disposes (exercising
|
||||
* subprocess cleanup). Unit tests use `src/`; this pins the downstream `lib/` path. Skips when `lib/`
|
||||
* is absent; CI runs it after the build.
|
||||
*/
|
||||
|
||||
const pkgDir = fileURLToPath(new URL('..', import.meta.url))
|
||||
const seamLib = join(pkgDir, '../lsp/lib/index.js')
|
||||
const built = existsSync(join(pkgDir, 'lib/index.js')) && existsSync(seamLib)
|
||||
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-built-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
if (root) await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe.skipIf(!built)('built lib real load path (plain node)', () => {
|
||||
it('runs a query through lib/index.js and disposes cleanly, framing over the base protocol', async () => {
|
||||
const location = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
|
||||
const script = `
|
||||
const { Context } = await import('cordis')
|
||||
const { default: Lsp } = await import('@deepseek-ai/dsh-lsp')
|
||||
const LspLocal = await import('@deepseek-ai/dsh-lsp-local')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LspLocal, {
|
||||
servers: {
|
||||
fake: {
|
||||
command: ${JSON.stringify(process.execPath)},
|
||||
args: [${JSON.stringify(fixtureServer)}],
|
||||
env: { LSP_FAKE_DEF: ${JSON.stringify(location)} },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = await ctx.lsp.query({ operation: 'goToDefinition', filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ${JSON.stringify(ws)} })
|
||||
console.log(JSON.stringify(result))
|
||||
await ctx.fiber.dispose()
|
||||
`
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', script], { cwd: pkgDir, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
|
||||
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
|
||||
const exitCode = await new Promise<number | null>(resolve => child.on('close', resolve))
|
||||
|
||||
expect(exitCode, `stderr:\n${stderr}`).toBe(0)
|
||||
const lastLine = stdout.trim().split('\n').at(-1) ?? ''
|
||||
const result = JSON.parse(lastLine) as { kind: string; locations: unknown[] }
|
||||
expect(result.kind).toBe('locations')
|
||||
expect(result.locations).toHaveLength(1)
|
||||
}, 60_000)
|
||||
})
|
||||
240
packages/lsp/lsp-local/tests/connection.spec.ts
Normal file
240
packages/lsp/lsp-local/tests/connection.spec.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { LspConnection } from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
|
||||
/** A recorded server→client request the test's handler saw. */
|
||||
interface SeenRequest { method: string; params: unknown }
|
||||
|
||||
let open: LspConnection[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
for (const conn of open) {
|
||||
conn.kill()
|
||||
await conn.closed
|
||||
}
|
||||
open = []
|
||||
})
|
||||
|
||||
/** Spawn the fixture as a raw connection, with a scripted server-request handler. */
|
||||
function connect(
|
||||
env: Record<string, string>,
|
||||
onServerRequest: (method: string, params: unknown) => Promise<unknown> = () => Promise.resolve(null),
|
||||
seen?: SeenRequest[],
|
||||
): LspConnection {
|
||||
const conn = new LspConnection({
|
||||
command: process.execPath,
|
||||
args: [fixtureServer],
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env as Record<string, string>, ...env },
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes: 100_000,
|
||||
configuration: { setting: 42 },
|
||||
}, (method, params) => {
|
||||
seen?.push({ method, params })
|
||||
return onServerRequest(method, params)
|
||||
})
|
||||
open.push(conn)
|
||||
return conn
|
||||
}
|
||||
|
||||
describe('LspConnection', () => {
|
||||
it('completes an initialize request/response round-trip and exposes a pid', async () => {
|
||||
const conn = connect({})
|
||||
const result = await conn.request('initialize', { capabilities: {} })
|
||||
expect(result).toMatchObject({ capabilities: { hoverProvider: true } })
|
||||
expect(conn.pid).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('rejects a request when the server replies with an error', async () => {
|
||||
const conn = connect({ LSP_FAKE_ERROR: '1' })
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/server refused the request/)
|
||||
})
|
||||
|
||||
it('answers a server workspace/configuration request from static config', async () => {
|
||||
const seen: SeenRequest[] = []
|
||||
const conn = connect(
|
||||
{ LSP_FAKE_ON_OPEN: 'configuration' },
|
||||
(method, params) => {
|
||||
if (method === 'workspace/configuration') {
|
||||
const items = (params as { items: unknown[] }).items
|
||||
return Promise.resolve(items.map(() => ({ setting: 42 })))
|
||||
}
|
||||
return Promise.resolve(null)
|
||||
},
|
||||
seen,
|
||||
)
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
await waitFor(() => seen.some(s => s.method === 'workspace/configuration'))
|
||||
expect(seen[0]?.method).toBe('workspace/configuration')
|
||||
})
|
||||
|
||||
it('drops a server→client notification without replying', async () => {
|
||||
const conn = connect({ LSP_FAKE_ON_OPEN: 'notification' })
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
// No throw and the connection stays usable.
|
||||
await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('sends an error response when the server-request handler rejects', async () => {
|
||||
const seen: SeenRequest[] = []
|
||||
const conn = connect(
|
||||
{ LSP_FAKE_ON_OPEN: 'applyEdit' },
|
||||
method => method === 'workspace/applyEdit' ? Promise.reject(new Error('not permitted')) : Promise.resolve(null),
|
||||
seen,
|
||||
)
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
await conn.notify('textDocument/didOpen', { textDocument: { uri: 'file:///x', languageId: 'ts', version: 1, text: '' } })
|
||||
await waitFor(() => seen.some(s => s.method === 'workspace/applyEdit'))
|
||||
// The connection remains healthy after emitting the error response.
|
||||
await expect(conn.request('textDocument/hover', {})).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('fails all pending requests and kills the process on a framing error', async () => {
|
||||
const conn = connect({ LSP_FAKE_GARBAGE: '1' })
|
||||
// The garbage byte precedes a valid initialize reply; unframed bytes are tolerated until a
|
||||
// Content-Length header, so initialize still resolves. This exercises the decoder's resilience.
|
||||
await expect(conn.request('initialize', { capabilities: {} })).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects a new request issued after the process closes', async () => {
|
||||
const conn = connect({})
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
conn.terminate()
|
||||
await conn.closed
|
||||
await expect(conn.request('textDocument/hover', {})).rejects.toThrow(/exited|closed/)
|
||||
})
|
||||
|
||||
it('cancel is a no-op-safe write after close', async () => {
|
||||
const conn = connect({})
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
conn.terminate()
|
||||
await conn.closed
|
||||
expect(() => { conn.cancel(1) }).not.toThrow()
|
||||
})
|
||||
|
||||
it('caps the retained stderr tail', async () => {
|
||||
const conn = connect({})
|
||||
await conn.request('initialize', { capabilities: {} })
|
||||
expect(conn.stderrTail.length).toBeLessThanOrEqual(100_000)
|
||||
})
|
||||
})
|
||||
|
||||
/** Spawn a raw connection running an inline node script as the "server". */
|
||||
function connectScript(script: string, maxStderrBytes = 100_000): LspConnection {
|
||||
const conn = new LspConnection({
|
||||
command: process.execPath,
|
||||
args: ['-e', script],
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env as Record<string, string> },
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes,
|
||||
configuration: null,
|
||||
}, () => Promise.resolve(null))
|
||||
open.push(conn)
|
||||
return conn
|
||||
}
|
||||
|
||||
describe('LspConnection edge behavior', () => {
|
||||
it('fails a request when the command cannot be spawned', async () => {
|
||||
const conn = new LspConnection({
|
||||
command: '/definitely/not/a/real/binary/xyz',
|
||||
args: [],
|
||||
cwd: process.cwd(),
|
||||
env: {},
|
||||
maxMessageBytes: 1000,
|
||||
maxStderrBytes: 1000,
|
||||
configuration: null,
|
||||
}, () => Promise.resolve(null))
|
||||
open.push(conn)
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('kills the process and fails pending requests on a framing error', async () => {
|
||||
// Emit an invalid Content-Length header, corrupting the stream irrecoverably.
|
||||
const conn = connectScript('process.stdout.write("Content-Length: abc\\r\\n\\r\\n{}"); setInterval(()=>{}, 1000)')
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('ignores a framed non-object message', async () => {
|
||||
// Send a framed JSON number and a framed null (both non-objects) then a proper response to id 1.
|
||||
const script = 'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdout.write(fr("42"));process.stdout.write(fr("null"));'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
|
||||
const conn = connectScript(script)
|
||||
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('drops a response for an unknown id', async () => {
|
||||
// Emit a response for id 999 (never sent), then answer our real request.
|
||||
const script = 'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:999,result:{stray:true}})));'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
|
||||
const conn = connectScript(script)
|
||||
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
|
||||
})
|
||||
|
||||
it('caps the retained stderr tail at maxStderrBytes across chunks', async () => {
|
||||
// Write stderr repeatedly so a later chunk arrives after the cap is already reached.
|
||||
const conn = connectScript('setInterval(()=>process.stderr.write("E".repeat(200)), 5); setInterval(()=>{}, 1000)', 100)
|
||||
await waitFor(() => conn.stderrTail.length >= 100)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 50))
|
||||
expect(conn.stderrTail.length).toBe(100)
|
||||
})
|
||||
|
||||
it('caps the retained stderr tail by bytes for multibyte UTF-8', async () => {
|
||||
const conn = connectScript('process.stderr.write("😀😀")', 4)
|
||||
await conn.closed
|
||||
expect(conn.stderrTail).toBe('😀')
|
||||
expect(Buffer.byteLength(conn.stderrTail)).toBe(4)
|
||||
})
|
||||
|
||||
it('rejects with a fallback message when the error response has no message string', async () => {
|
||||
const script = 'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,error:{code:-1}})));});'
|
||||
const conn = connectScript(script)
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow(/LSP error response/)
|
||||
})
|
||||
|
||||
it('rejects a pending request when the process exits mid-flight', async () => {
|
||||
// Never responds, then exits shortly: the pending request must reject on close.
|
||||
const conn = connectScript('setTimeout(()=>process.exit(0), 100)')
|
||||
await expect(conn.request('initialize', {})).rejects.toThrow(/exited|closed/)
|
||||
})
|
||||
|
||||
it('rejects a pending request when child stdin closes but the process stays alive', async () => {
|
||||
const conn = connectScript('require("node:fs").closeSync(0); setInterval(()=>{}, 1000)')
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 100))
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
setTimeout(() => { reject(new Error('request timed out')) }, 1000)
|
||||
})
|
||||
await expect(Promise.race([conn.request('initialize', {}), timeout])).rejects.not.toThrow(/timed out/)
|
||||
})
|
||||
|
||||
it('ignores a frame that is neither a valid request nor a numeric-id response', async () => {
|
||||
// A frame with a string id and no method: not dispatchable; the client must ignore it and still
|
||||
// answer our real request.
|
||||
const script = 'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(s)=>{const x=Buffer.from(s);return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:"str-id"})));'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);const s=b.indexOf("\\r\\n\\r\\n");if(s<0)return;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);const body=JSON.parse(b.toString("utf8",s+4,s+4+len));process.stdout.write(fr(JSON.stringify({jsonrpc:"2.0",id:body.id,result:{ok:true}})));});'
|
||||
const conn = connectScript(script)
|
||||
await expect(conn.request('initialize', {})).resolves.toEqual({ ok: true })
|
||||
})
|
||||
})
|
||||
|
||||
/** Poll a predicate until it holds or a deadline elapses. */
|
||||
async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise<void> {
|
||||
const start = Date.now()
|
||||
while (!predicate()) {
|
||||
if (Date.now() - start > timeoutMs) throw new Error('waitFor timed out')
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
207
packages/lsp/lsp-local/tests/fixture-server.ts
Normal file
207
packages/lsp/lsp-local/tests/fixture-server.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* A scriptable fake LSP server over stdio for lsp-local tests. It speaks the real
|
||||
* `Content-Length`-framed base protocol so it exercises the client's framing, initialize handshake,
|
||||
* transient open/close, request mapping, and teardown — without a real language server.
|
||||
*
|
||||
* Behavior is driven by env vars so one file backs many scenarios:
|
||||
* - LSP_FAKE_ENCODING: advertised positionEncoding (default utf-16; "utf-8" forces a mismatch).
|
||||
* - LSP_FAKE_SYNC: textDocumentSync value as JSON (default 1/Full).
|
||||
* - LSP_FAKE_CAPS: JSON of extra capability flags merged into the defaults.
|
||||
* - LSP_FAKE_DEF / LSP_FAKE_REFS / LSP_FAKE_IMPL / LSP_FAKE_HOVER: JSON result per request.
|
||||
* - LSP_FAKE_HANG: "1" makes textDocument/* requests never respond (for abort/timeout tests).
|
||||
* - LSP_FAKE_CRASH_ON_OPEN: "1" exits the process when a didOpen arrives (crash test).
|
||||
* - LSP_FAKE_EXIT_AFTER_REPLY: "1" exits the process right after answering a textDocument/* request,
|
||||
* simulating a server that dies while idle so the pool holds a dead instance (eviction test).
|
||||
* - LSP_FAKE_REPLY_DELAY_MS: delays each textDocument/* response by this many milliseconds.
|
||||
* - LSP_FAKE_OPEN_MARKER: appends each didOpen document text as one JSON line to this path.
|
||||
* - LSP_FAKE_INITIALIZED_MARKER: records when the initialized notification is received.
|
||||
* - LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: "1" stops consuming stdin after initialized.
|
||||
* - LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: "1" closes fd 0 after the initialized notification.
|
||||
* - LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: "1" closes fd 0 before sending the first query response.
|
||||
* - LSP_FAKE_EXIT_DELAY_MS / LSP_FAKE_EXIT_MARKER: delay protocol exit and record exit/termination.
|
||||
* - LSP_FAKE_NO_SHUTDOWN: "1" ignores the shutdown request (forces kill escalation).
|
||||
* - LSP_FAKE_ON_OPEN: server→client request to emit when a didOpen arrives, one of
|
||||
* "configuration" | "applyEdit" | "notification" | "unknown"; the reply is logged to stderr.
|
||||
* - LSP_FAKE_ERROR: "1" answers textDocument/* requests with a JSON-RPC error response.
|
||||
* - LSP_FAKE_GARBAGE: "1" emits an unframed garbage byte before the initialize reply.
|
||||
*
|
||||
* Run: node fixture-server.ts (Node's erasable TypeScript syntax support).
|
||||
*/
|
||||
|
||||
import { appendFileSync, closeSync } from 'node:fs'
|
||||
|
||||
const enc = process.env.LSP_FAKE_ENCODING ?? 'utf-16'
|
||||
const sync: unknown = process.env.LSP_FAKE_SYNC !== undefined ? JSON.parse(process.env.LSP_FAKE_SYNC) : 1
|
||||
const extraCaps: unknown = process.env.LSP_FAKE_CAPS !== undefined ? JSON.parse(process.env.LSP_FAKE_CAPS) : {}
|
||||
const hang = process.env.LSP_FAKE_HANG === '1'
|
||||
const crashOnOpen = process.env.LSP_FAKE_CRASH_ON_OPEN === '1'
|
||||
const exitAfterReply = process.env.LSP_FAKE_EXIT_AFTER_REPLY === '1'
|
||||
const replyDelayMs = Number(process.env.LSP_FAKE_REPLY_DELAY_MS ?? 0)
|
||||
const openMarker = process.env.LSP_FAKE_OPEN_MARKER
|
||||
const initializedMarker = process.env.LSP_FAKE_INITIALIZED_MARKER
|
||||
const pauseStdinAfterInitialized = process.env.LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED === '1'
|
||||
const closeStdinAfterInitialized = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED === '1'
|
||||
const closeStdinAfterReply = process.env.LSP_FAKE_CLOSE_STDIN_AFTER_REPLY === '1'
|
||||
const exitDelayMs = Number(process.env.LSP_FAKE_EXIT_DELAY_MS ?? 0)
|
||||
const exitMarker = process.env.LSP_FAKE_EXIT_MARKER
|
||||
const noShutdown = process.env.LSP_FAKE_NO_SHUTDOWN === '1'
|
||||
const onOpen = process.env.LSP_FAKE_ON_OPEN
|
||||
const errorReply = process.env.LSP_FAKE_ERROR === '1'
|
||||
const garbage = process.env.LSP_FAKE_GARBAGE === '1'
|
||||
|
||||
let serverRequestId = 10_000
|
||||
const pendingServerRequests = new Map<number, string>()
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
markExit('TERM')
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
function resultFor(method: string): unknown {
|
||||
switch (method) {
|
||||
case 'textDocument/definition': return envJson('LSP_FAKE_DEF', null)
|
||||
case 'textDocument/references': return envJson('LSP_FAKE_REFS', null)
|
||||
case 'textDocument/implementation': return envJson('LSP_FAKE_IMPL', null)
|
||||
case 'textDocument/hover': return envJson('LSP_FAKE_HOVER', null)
|
||||
default: return null
|
||||
}
|
||||
}
|
||||
|
||||
function envJson(name: string, fallback: unknown): unknown {
|
||||
const raw = process.env[name]
|
||||
return raw === undefined ? fallback : JSON.parse(raw)
|
||||
}
|
||||
|
||||
let buffer = Buffer.alloc(0)
|
||||
process.stdin.on('data', (chunk: Buffer) => {
|
||||
buffer = Buffer.concat([buffer, chunk])
|
||||
for (;;) {
|
||||
const sep = buffer.indexOf('\r\n\r\n')
|
||||
if (sep < 0) break
|
||||
const header = buffer.toString('ascii', 0, sep)
|
||||
const match = /content-length:\s*(\d+)/i.exec(header)
|
||||
if (!match) { buffer = buffer.subarray(sep + 4); continue }
|
||||
const length = Number(match[1])
|
||||
const start = sep + 4
|
||||
if (buffer.length < start + length) break
|
||||
const body = buffer.toString('utf8', start, start + length)
|
||||
buffer = buffer.subarray(start + length)
|
||||
handle(JSON.parse(body) as { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown })
|
||||
}
|
||||
})
|
||||
|
||||
function handle(message: { id?: number; method?: string; params?: unknown; result?: unknown; error?: unknown }): void {
|
||||
const { id, method } = message
|
||||
// A frame with an id but no method is the client's REPLY to a server→client request; log it.
|
||||
if (method === undefined && id !== undefined && pendingServerRequests.has(id)) {
|
||||
const kind = pendingServerRequests.get(id)
|
||||
pendingServerRequests.delete(id)
|
||||
process.stderr.write(`REPLY ${kind} ${JSON.stringify({ result: message.result, error: message.error })}\n`)
|
||||
return
|
||||
}
|
||||
if (method === 'initialize') {
|
||||
if (garbage) process.stdout.write('this is not a framed message\r\n')
|
||||
send({
|
||||
id,
|
||||
result: {
|
||||
capabilities: {
|
||||
positionEncoding: enc,
|
||||
textDocumentSync: sync,
|
||||
definitionProvider: true,
|
||||
referencesProvider: true,
|
||||
implementationProvider: true,
|
||||
hoverProvider: true,
|
||||
...(extraCaps as Record<string, unknown>),
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
if (method === 'shutdown') {
|
||||
if (noShutdown) return
|
||||
send({ id, result: null })
|
||||
return
|
||||
}
|
||||
if (method === 'exit') {
|
||||
markExit('EXIT')
|
||||
if (exitDelayMs > 0) {
|
||||
setTimeout(() => {
|
||||
markExit('CLEAN')
|
||||
process.exit(0)
|
||||
}, exitDelayMs)
|
||||
return
|
||||
}
|
||||
markExit('CLEAN')
|
||||
process.exit(0)
|
||||
}
|
||||
if (method === 'textDocument/didOpen') {
|
||||
if (crashOnOpen) process.exit(1)
|
||||
if (openMarker !== undefined) {
|
||||
const params = message.params as { textDocument?: { text?: unknown } } | undefined
|
||||
appendFileSync(openMarker, `${JSON.stringify(params?.textDocument?.text)}\n`)
|
||||
}
|
||||
if (onOpen !== undefined) emitServerRequest(onOpen)
|
||||
return
|
||||
}
|
||||
if (method === 'initialized') {
|
||||
if (initializedMarker !== undefined) appendFileSync(initializedMarker, 'INITIALIZED\n')
|
||||
if (pauseStdinAfterInitialized) process.stdin.pause()
|
||||
if (closeStdinAfterInitialized) closeSync(0)
|
||||
return
|
||||
}
|
||||
if (method === 'textDocument/didClose') return
|
||||
if (method?.startsWith('textDocument/')) {
|
||||
if (hang) return
|
||||
const reply = (): void => {
|
||||
if (closeStdinAfterReply) closeSync(0)
|
||||
if (errorReply) {
|
||||
send({ id, error: { code: -32000, message: 'server refused the request' } })
|
||||
} else {
|
||||
send({ id, result: resultFor(method) })
|
||||
}
|
||||
// Simulate an idle death: answer this request, then exit before the next one arrives so the
|
||||
// pool is left holding a dead instance.
|
||||
if (exitAfterReply) setTimeout(() => process.exit(0), 20)
|
||||
}
|
||||
if (replyDelayMs > 0) setTimeout(reply, replyDelayMs)
|
||||
else reply()
|
||||
return
|
||||
}
|
||||
// Unknown request with an id: answer null so the client never stalls.
|
||||
if (id !== undefined) send({ id, result: null })
|
||||
}
|
||||
|
||||
/** Append one teardown event when the fixture is configured to expose process ordering. */
|
||||
function markExit(event: string): void {
|
||||
if (exitMarker !== undefined) appendFileSync(exitMarker, `${event}\n`)
|
||||
}
|
||||
|
||||
/** Emit a server→client request and log the client's reply to stderr for the test to assert. */
|
||||
function emitServerRequest(kind: string): void {
|
||||
if (kind === 'notification') {
|
||||
send({ method: 'window/logMessage', params: { type: 3, message: 'hello' } })
|
||||
return
|
||||
}
|
||||
const id = serverRequestId++
|
||||
const method = kind === 'configuration'
|
||||
? 'workspace/configuration'
|
||||
: kind === 'applyEdit'
|
||||
? 'workspace/applyEdit'
|
||||
: kind === 'lifecycle'
|
||||
? 'client/registerCapability'
|
||||
: 'window/showMessageRequest'
|
||||
const params = kind === 'configuration' ? { items: [{ section: 'a' }, { section: 'b' }] } : {}
|
||||
pendingServerRequests.set(id, method)
|
||||
send({ id, method, params })
|
||||
}
|
||||
|
||||
function send(message: Record<string, unknown>): void {
|
||||
const body = Buffer.from(JSON.stringify({ jsonrpc: '2.0', ...message }), 'utf8')
|
||||
process.stdout.write(Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'), body]))
|
||||
}
|
||||
|
||||
// Keep the event loop alive.
|
||||
process.stdin.resume()
|
||||
if (pauseStdinAfterInitialized || closeStdinAfterInitialized || closeStdinAfterReply) {
|
||||
setInterval(() => {}, 1000)
|
||||
}
|
||||
82
packages/lsp/lsp-local/tests/framing.spec.ts
Normal file
82
packages/lsp/lsp-local/tests/framing.spec.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { encodeMessage, MessageDecoder } from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
/** Frame a message the way a server would, for decoder round-trips. */
|
||||
function frame(body: string): Buffer {
|
||||
return Buffer.concat([Buffer.from(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n`, 'ascii'), Buffer.from(body, 'utf8')])
|
||||
}
|
||||
|
||||
describe('encodeMessage', () => {
|
||||
it('prefixes a Content-Length header with the utf-8 byte length', () => {
|
||||
const buffer = encodeMessage({ jsonrpc: '2.0', method: 'x', params: { s: 'é' } })
|
||||
const text = buffer.toString('utf8')
|
||||
const body = '{"jsonrpc":"2.0","method":"x","params":{"s":"é"}}'
|
||||
expect(text).toBe(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`)
|
||||
})
|
||||
})
|
||||
|
||||
describe('MessageDecoder', () => {
|
||||
it('decodes a single framed message', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
expect(decoder.push(frame('{"id":1,"result":42}'))).toEqual([{ id: 1, result: 42 }])
|
||||
})
|
||||
|
||||
it('decodes multiple messages arriving in one chunk', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const chunk = Buffer.concat([frame('{"a":1}'), frame('{"b":2}')])
|
||||
expect(decoder.push(chunk)).toEqual([{ a: 1 }, { b: 2 }])
|
||||
})
|
||||
|
||||
it('reassembles a message split across chunks', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const full = frame('{"hello":"world"}')
|
||||
expect(decoder.push(full.subarray(0, 10))).toEqual([])
|
||||
expect(decoder.push(full.subarray(10))).toEqual([{ hello: 'world' }])
|
||||
})
|
||||
|
||||
it('handles a header split from its body', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const body = '{"x":1}'
|
||||
expect(decoder.push(Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii'))).toEqual([])
|
||||
expect(decoder.push(Buffer.from(body, 'utf8'))).toEqual([{ x: 1 }])
|
||||
})
|
||||
|
||||
it('reads a case-insensitive header and ignores other headers', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const body = '{"ok":true}'
|
||||
const chunk = Buffer.from(`content-length: ${body.length}\r\nContent-Type: x\r\n\r\n${body}`, 'utf8')
|
||||
expect(decoder.push(chunk)).toEqual([{ ok: true }])
|
||||
})
|
||||
|
||||
it('rejects a body over the size limit', () => {
|
||||
const decoder = new MessageDecoder(4)
|
||||
expect(() => decoder.push(frame('{"big":true}'))).toThrow(/exceeds the 4-byte limit/)
|
||||
})
|
||||
|
||||
it('rejects a missing Content-Length header', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
expect(() => decoder.push(Buffer.from('X: 1\r\n\r\n{}', 'utf8'))).toThrow(/missing Content-Length/)
|
||||
})
|
||||
|
||||
it('rejects a non-numeric Content-Length', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
expect(() => decoder.push(Buffer.from('Content-Length: abc\r\n\r\n{}', 'utf8'))).toThrow(/invalid Content-Length/)
|
||||
})
|
||||
|
||||
it('rejects a header block that never terminates', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const huge = Buffer.alloc((1 << 16) + 1, 0x41)
|
||||
expect(() => decoder.push(huge)).toThrow(/exceeded .* bytes without a terminator/)
|
||||
})
|
||||
|
||||
it('rejects an oversized header block that includes its terminator', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
const huge = Buffer.from(`Content-Length: 2\r\nX-Fill: ${'a'.repeat(70_000)}\r\n\r\n{}`, 'ascii')
|
||||
expect(() => decoder.push(huge)).toThrow(/header exceeded .* bytes/)
|
||||
})
|
||||
|
||||
it('rejects a non-JSON body', () => {
|
||||
const decoder = new MessageDecoder(1_000)
|
||||
expect(() => decoder.push(frame('not json'))).toThrow(/not valid JSON/)
|
||||
})
|
||||
})
|
||||
131
packages/lsp/lsp-local/tests/host.spec.ts
Normal file
131
packages/lsp/lsp-local/tests/host.spec.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { execFile } from 'node:child_process'
|
||||
import { promisify } from 'node:util'
|
||||
import { deadline } from '@deepseek-ai/dsh-timeout'
|
||||
import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-host-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const BIG = 1_000_000
|
||||
|
||||
describe('canonicalizeWorkspace', () => {
|
||||
it('returns the realpath of a directory', async () => {
|
||||
expect(await canonicalizeWorkspace(ws)).toBe(ws)
|
||||
})
|
||||
|
||||
it('resolves a symlinked workspace to its target so aliases share identity', async () => {
|
||||
const link = join(root, 'ws-link')
|
||||
await symlink(ws, link)
|
||||
expect(await canonicalizeWorkspace(link)).toBe(ws)
|
||||
})
|
||||
|
||||
it('rejects a missing workspace', async () => {
|
||||
await expect(canonicalizeWorkspace(join(root, 'nope'))).rejects.toThrow(/cannot be resolved/)
|
||||
})
|
||||
|
||||
it('rejects a non-directory workspace', async () => {
|
||||
const file = join(root, 'file.txt')
|
||||
await writeFile(file, 'x')
|
||||
await expect(canonicalizeWorkspace(file)).rejects.toThrow(/not a directory/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('readHostSource', () => {
|
||||
it('reads a relative path against the workspace', async () => {
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
|
||||
const source = await readHostSource('a.ts', ws, BIG)
|
||||
expect(source.canonicalPath).toBe(join(ws, 'a.ts'))
|
||||
expect(source.text).toBe('const x = 1\n')
|
||||
})
|
||||
|
||||
it('reads an absolute path inside the workspace', async () => {
|
||||
const abs = join(ws, 'b.ts')
|
||||
await writeFile(abs, 'b')
|
||||
const source = await readHostSource(abs, ws, BIG)
|
||||
expect(source.canonicalPath).toBe(abs)
|
||||
})
|
||||
|
||||
it('accepts a source reached through a symlink that stays inside the workspace', async () => {
|
||||
await mkdir(join(ws, 'real'))
|
||||
await writeFile(join(ws, 'real', 'c.ts'), 'c')
|
||||
await symlink(join(ws, 'real'), join(ws, 'linked'))
|
||||
const source = await readHostSource('linked/c.ts', ws, BIG)
|
||||
expect(source.canonicalPath).toBe(join(ws, 'real', 'c.ts'))
|
||||
})
|
||||
|
||||
it('rejects a source whose canonical path escapes the workspace via symlink', async () => {
|
||||
const outside = join(root, 'outside.ts')
|
||||
await writeFile(outside, 'secret')
|
||||
await symlink(outside, join(ws, 'escape.ts'))
|
||||
await expect(readHostSource('escape.ts', ws, BIG)).rejects.toThrow(/outside the workspace/)
|
||||
})
|
||||
|
||||
it('rejects an absolute source outside the workspace', async () => {
|
||||
const outside = join(root, 'out.ts')
|
||||
await writeFile(outside, 'x')
|
||||
await expect(readHostSource(outside, ws, BIG)).rejects.toThrow(/outside the workspace/)
|
||||
})
|
||||
|
||||
it('rejects a missing source', async () => {
|
||||
await expect(readHostSource('nope.ts', ws, BIG)).rejects.toThrow(/cannot be resolved/)
|
||||
})
|
||||
|
||||
it('rejects a non-regular source (directory)', async () => {
|
||||
await mkdir(join(ws, 'dir'))
|
||||
await expect(readHostSource('dir', ws, BIG)).rejects.toThrow(/not a regular file/)
|
||||
})
|
||||
|
||||
it('rejects a FIFO with no writer without blocking in open', async () => {
|
||||
const fifo = join(ws, 'pipe.ts')
|
||||
await execFileAsync('mkfifo', [fifo])
|
||||
using d = deadline(undefined, 1000, 'FIFO_READ_TIMEOUT')
|
||||
await expect(readHostSource('pipe.ts', ws, BIG, d.signal)).rejects.toThrow(/not a regular file/)
|
||||
})
|
||||
|
||||
it('honors a pre-aborted source read before filesystem work', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('source read cancelled'))
|
||||
await expect(readHostSource('missing.ts', ws, BIG, controller.signal)).rejects.toThrow(/source read cancelled/)
|
||||
})
|
||||
|
||||
it('treats the workspace root itself as inside, then rejects it as non-regular', async () => {
|
||||
// filePath '.' canonicalizes to the workspace dir: isInside's identity branch is taken, and the
|
||||
// directory then fails the regular-file check.
|
||||
await expect(readHostSource('.', ws, BIG)).rejects.toThrow(/not a regular file/)
|
||||
})
|
||||
|
||||
it('rejects an oversized source', async () => {
|
||||
await writeFile(join(ws, 'big.ts'), 'x'.repeat(100))
|
||||
await expect(readHostSource('big.ts', ws, 10)).rejects.toThrow(/over the 10-byte limit/)
|
||||
})
|
||||
|
||||
it('rejects a non-UTF-8 source', async () => {
|
||||
await writeFile(join(ws, 'bin.ts'), Buffer.from([0xff, 0xfe, 0x00]))
|
||||
await expect(readHostSource('bin.ts', ws, BIG)).rejects.toThrow(/not valid UTF-8/)
|
||||
})
|
||||
|
||||
it('keeps a valid U+FFFD replacement character in otherwise-valid UTF-8', async () => {
|
||||
// The literal replacement char is valid UTF-8; a fatal decoder must accept it (only malformed
|
||||
// byte sequences are rejected).
|
||||
await writeFile(join(ws, 'repl.ts'), 'const s = "<22>"\n')
|
||||
const source = await readHostSource('repl.ts', ws, BIG)
|
||||
expect(source.text).toBe('const s = "<22>"\n')
|
||||
})
|
||||
})
|
||||
338
packages/lsp/lsp-local/tests/instance.spec.ts
Normal file
338
packages/lsp/lsp-local/tests/instance.spec.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { InstanceSpec } from '@deepseek-ai/dsh-lsp-local/src/instance.ts'
|
||||
import type { LspProviderQuery, LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
let live: LspInstance[] = []
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-inst-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
for (const instance of live) await instance.dispose()
|
||||
live = []
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function makeInstance(env: Record<string, string> = {}, overrides: Partial<InstanceSpec> = {}): LspInstance {
|
||||
const instance = new LspInstance({
|
||||
command: process.execPath,
|
||||
args: [fixtureServer],
|
||||
cwd: ws,
|
||||
env: { ...process.env as Record<string, string>, ...env },
|
||||
configuration: { setting: 42 },
|
||||
initializationOptions: { init: true },
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes: 100_000,
|
||||
shutdownTimeoutMs: 200,
|
||||
killGraceMs: 200,
|
||||
...overrides,
|
||||
})
|
||||
live.push(instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
function query(operation: LspProviderQuery['operation'] = 'goToDefinition'): LspProviderQuery {
|
||||
return { operation, filePath: 'a.ts', position: { line: 0, character: 6 }, workspaceRoot: ws, languageId: 'typescript' }
|
||||
}
|
||||
|
||||
/** Run a query against an instance, reading the source first the way the provider does. */
|
||||
async function run(instance: LspInstance, operation: LspProviderQuery['operation'] = 'goToDefinition', signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
const source = await readHostSource('a.ts', ws, 4_000_000)
|
||||
return instance.query(query(operation), source, signal)
|
||||
}
|
||||
|
||||
/** Build an instance whose "server" is an inline node script (for teardown-escalation control). */
|
||||
function scriptInstance(script: string, overrides: Partial<InstanceSpec> = {}): LspInstance {
|
||||
const instance = new LspInstance({
|
||||
command: process.execPath,
|
||||
args: ['-e', script],
|
||||
cwd: ws,
|
||||
env: { ...process.env as Record<string, string> },
|
||||
configuration: null,
|
||||
initializationOptions: null,
|
||||
maxMessageBytes: 16_000_000,
|
||||
maxStderrBytes: 100_000,
|
||||
shutdownTimeoutMs: 150,
|
||||
killGraceMs: 150,
|
||||
...overrides,
|
||||
})
|
||||
live.push(instance)
|
||||
return instance
|
||||
}
|
||||
|
||||
/** An inline server that answers initialize + definition and echoes a location. */
|
||||
const RESPONDING_SERVER =
|
||||
'let b=Buffer.alloc(0);'
|
||||
+ 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
|
||||
+ 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
|
||||
+ 'else if(m.method==="textDocument/definition")process.stdout.write(fr({id:m.id,result:null}));'
|
||||
+ '}});'
|
||||
|
||||
const locJson = () => JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
|
||||
|
||||
describe('LspInstance server-request handling', () => {
|
||||
it('answers workspace/configuration with the static config per item', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'configuration', LSP_FAKE_DEF: locJson() })
|
||||
// The query drives didOpen, which makes the fake emit workspace/configuration; a healthy answer
|
||||
// keeps the query working.
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toMatchObject({ kind: 'locations' })
|
||||
})
|
||||
|
||||
it('accepts a lifecycle client/registerCapability request', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'lifecycle', LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
})
|
||||
|
||||
it('rejects a workspace/applyEdit request but keeps serving', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'applyEdit', LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
})
|
||||
|
||||
it('rejects an unknown server request but keeps serving', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_ON_OPEN: 'unknown', LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
})
|
||||
})
|
||||
|
||||
describe('LspInstance query and abort', () => {
|
||||
it('sends includeDeclaration for references', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_REFS: JSON.stringify([JSON.parse(locJson())]) })
|
||||
await expect(run(instance, 'findReferences')).resolves.toMatchObject({ kind: 'locations' })
|
||||
})
|
||||
|
||||
it('rejects a query aborted before it starts', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('pre-abort'))
|
||||
await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/pre-abort/)
|
||||
})
|
||||
|
||||
it('cancels an in-flight request on abort and rejects', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_HANG: '1' })
|
||||
const controller = new AbortController()
|
||||
// Warm the instance first so the abort lands during the hanging request, not during startup.
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
controller.abort(new Error('mid-flight'))
|
||||
await expect(pending).rejects.toThrow(/mid-flight/)
|
||||
})
|
||||
|
||||
it('terminates the instance when the server ignores $/cancelRequest past the grace', async () => {
|
||||
// The hang server never honors cancellation, so after the bounded grace the instance must be torn
|
||||
// down (its process closed) rather than left with an active request.
|
||||
const instance = makeInstance({ LSP_FAKE_HANG: '1' }, { killGraceMs: 100 })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
controller.abort(new Error('mid-flight'))
|
||||
await expect(pending).rejects.toThrow(/mid-flight/)
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
|
||||
it('resolves the cancel grace when the server honors $/cancelRequest', async () => {
|
||||
// A server that answers $/cancelRequest by settling the pending request lets the grace race
|
||||
// resolve via the request rather than the timeout, so the instance is NOT force-terminated.
|
||||
const script = 'let b=Buffer.alloc(0),reqId=null;'
|
||||
+ 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
|
||||
+ 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
|
||||
+ 'else if(m.method==="textDocument/definition")reqId=m.id;'
|
||||
+ 'else if(m.method==="$/cancelRequest"&&reqId!==null)process.stdout.write(fr({id:reqId,error:{code:-32800,message:"request cancelled"}}));'
|
||||
+ 'else if(m.method==="shutdown")process.stdout.write(fr({id:m.id,result:null}));'
|
||||
+ 'else if(m.method==="exit")process.exit(0);'
|
||||
+ '}});'
|
||||
const instance = scriptInstance(script, { killGraceMs: 2_000 })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 300))
|
||||
controller.abort(new Error('mid-flight'))
|
||||
await expect(pending).rejects.toThrow(/mid-flight/)
|
||||
// The server acknowledged cancellation within grace, so the instance was not force-killed.
|
||||
expect(instance.dead).toBe(false)
|
||||
await instance.dispose()
|
||||
})
|
||||
|
||||
it('observes abort while awaiting a slow initialize handshake', async () => {
|
||||
// A server that answers nothing (not even initialize) leaves `ready` pending; an abort must be
|
||||
// observed during that wait instead of hanging the tool-timeout signal.
|
||||
const instance = scriptInstance('setInterval(()=>{},1000)', { killGraceMs: 100 })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 150))
|
||||
controller.abort(new Error('handshake-abort'))
|
||||
await expect(pending).rejects.toThrow(/handshake-abort/)
|
||||
await instance.dispose()
|
||||
})
|
||||
|
||||
it('terminates when abort interrupts a backpressured didOpen write', async () => {
|
||||
// The fixture consumes initialized, then stops reading. A document larger than the stdio pipe
|
||||
// keeps didOpen's write callback pending until cancellation forces bounded process teardown.
|
||||
await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
|
||||
const marker = join(root, 'initialized.log')
|
||||
const instance = makeInstance({
|
||||
LSP_FAKE_INITIALIZED_MARKER: marker,
|
||||
LSP_FAKE_PAUSE_STDIN_AFTER_INITIALIZED: '1',
|
||||
}, {
|
||||
shutdownTimeoutMs: 100,
|
||||
killGraceMs: 100,
|
||||
})
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await waitForFile(marker)
|
||||
// Let the client enter the large didOpen write after the fixture has paused stdin.
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 100))
|
||||
controller.abort(new Error('didOpen-abort'))
|
||||
await expect(pending).rejects.toThrow(/didOpen-abort/)
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
|
||||
it('terminates when stdin fails during the didOpen write', async () => {
|
||||
// Closing stdin after initialized makes a large didOpen fail before `opened` can arm didClose;
|
||||
// the instance must still become dead so its provider can replace it.
|
||||
await writeFile(join(ws, 'a.ts'), 'x'.repeat(2_000_000))
|
||||
const instance = makeInstance({ LSP_FAKE_CLOSE_STDIN_AFTER_INITIALIZED: '1' }, {
|
||||
shutdownTimeoutMs: 100,
|
||||
killGraceMs: 100,
|
||||
})
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow()
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects when the server lacks the operation capability', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_CAPS: JSON.stringify({ definitionProvider: false }), LSP_FAKE_DEF: 'null' })
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow(/does not support goToDefinition/)
|
||||
})
|
||||
|
||||
it('propagates a server error response even when a signal is supplied (not an abort)', async () => {
|
||||
// A live signal is passed, but the request fails for a server reason; the catch must rethrow
|
||||
// without treating it as an abort.
|
||||
const instance = makeInstance({ LSP_FAKE_ERROR: '1' })
|
||||
const controller = new AbortController()
|
||||
await expect(run(instance, 'goToDefinition', controller.signal)).rejects.toThrow(/server refused/)
|
||||
})
|
||||
|
||||
it('keeps a settled result but awaits teardown when didClose cannot be written', async () => {
|
||||
const instance = makeInstance({
|
||||
LSP_FAKE_DEF: 'null',
|
||||
LSP_FAKE_CLOSE_STDIN_AFTER_REPLY: '1',
|
||||
}, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
await expect(run(instance, 'goToDefinition')).resolves.toEqual({
|
||||
kind: 'locations',
|
||||
locations: [],
|
||||
resolvedWorkspaceRoot: ws,
|
||||
})
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('LspInstance disposal', () => {
|
||||
it('lets a server finish protocol exit before signal escalation', async () => {
|
||||
const marker = join(root, 'graceful-exit.log')
|
||||
const instance = makeInstance({
|
||||
LSP_FAKE_DEF: 'null',
|
||||
LSP_FAKE_EXIT_DELAY_MS: '75',
|
||||
LSP_FAKE_EXIT_MARKER: marker,
|
||||
}, { shutdownTimeoutMs: 500 })
|
||||
await run(instance, 'goToDefinition')
|
||||
await instance.dispose()
|
||||
expect(await readFile(marker, 'utf8')).toBe('EXIT\nCLEAN\n')
|
||||
})
|
||||
|
||||
it('is idempotent — a second dispose awaits close without error', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await run(instance, 'goToDefinition')
|
||||
await instance.dispose()
|
||||
await expect(instance.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a query after disposal', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await run(instance, 'goToDefinition')
|
||||
await instance.dispose()
|
||||
await expect(run(instance, 'goToDefinition')).rejects.toThrow(expect.objectContaining({ code: 'LSP_DISPOSED' }))
|
||||
})
|
||||
|
||||
it('reports dead after the process closes', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_DEF: 'null' })
|
||||
await run(instance, 'goToDefinition')
|
||||
await instance.dispose()
|
||||
expect(instance.dead).toBe(true)
|
||||
})
|
||||
|
||||
it('escalates to SIGKILL when the server ignores shutdown and SIGTERM', async () => {
|
||||
// Server answers initialize, ignores shutdown, and traps SIGTERM so only SIGKILL stops it.
|
||||
const script = RESPONDING_SERVER + 'process.on("SIGTERM",()=>{});'
|
||||
const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
await run(instance, 'goToDefinition')
|
||||
await expect(instance.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('awaits a surviving process-group helper on every concurrent dispose', async () => {
|
||||
const marker = join(root, 'helper.pid')
|
||||
const helper = 'process.on("SIGTERM",()=>{});setInterval(()=>{},1000);'
|
||||
const script = 'const{spawn}=require("node:child_process");const{writeFileSync}=require("node:fs");'
|
||||
+ `const helper=spawn(process.execPath,["-e",${JSON.stringify(helper)}],{stdio:"ignore"});`
|
||||
+ `writeFileSync(${JSON.stringify(marker)},String(helper.pid));`
|
||||
+ RESPONDING_SERVER
|
||||
const instance = scriptInstance(script, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
await run(instance, 'goToDefinition')
|
||||
const helperPid = Number(await readFile(marker, 'utf8'))
|
||||
try {
|
||||
const first = instance.dispose()
|
||||
await instance.dispose()
|
||||
expect(processAlive(helperPid)).toBe(false)
|
||||
await first
|
||||
} finally {
|
||||
if (processAlive(helperPid)) process.kill(helperPid, 'SIGKILL')
|
||||
}
|
||||
})
|
||||
|
||||
it('carries a non-Error abort reason as a generic aborted error', async () => {
|
||||
const instance = makeInstance({ LSP_FAKE_HANG: '1' })
|
||||
const controller = new AbortController()
|
||||
const pending = run(instance, 'goToDefinition', controller.signal)
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 200))
|
||||
controller.abort('a string reason, not an Error')
|
||||
await expect(pending).rejects.toThrow(/aborted/)
|
||||
})
|
||||
})
|
||||
|
||||
/** Probe a pid without changing its state. */
|
||||
function processAlive(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Wait until a fixture marker exists, bounded so a broken handshake cannot hang the test. */
|
||||
async function waitForFile(path: string, timeoutMs = 3000): Promise<void> {
|
||||
const started = Date.now()
|
||||
for (;;) {
|
||||
try {
|
||||
await readFile(path)
|
||||
return
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
||||
}
|
||||
if (Date.now() - started > timeoutMs) throw new Error('waitForFile timed out')
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
319
packages/lsp/lsp-local/tests/lifecycle.spec.ts
Normal file
319
packages/lsp/lsp-local/tests/lifecycle.spec.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL, fileURLToPath } from 'node:url'
|
||||
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 { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
const fixtureServer = fileURLToPath(new URL('./fixture-server.ts', import.meta.url))
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-local-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\nconst y = x\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** One fake stdio server entry with optional behavior and host-bound overrides. */
|
||||
function fakeServer(fakeEnv: Record<string, string> = {}, overrides: Partial<LspLocalServerConfig> = {}): LspLocalServerConfig {
|
||||
return {
|
||||
command: process.execPath,
|
||||
args: [fixtureServer],
|
||||
env: { ...fakeEnv },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount the real seam + lsp-local plugin driving one fake server. */
|
||||
async function mount(fakeEnv: Record<string, string> = {}, overrides: Partial<LspLocalServerConfig> = {}): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LspLocal, {
|
||||
servers: { fake: fakeServer(fakeEnv, overrides) },
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
function query(operation: LspQueryRequest['operation'], filePath = 'a.ts'): LspQueryRequest {
|
||||
return { operation, filePath, position: { line: 0, character: 6 }, workspaceRoot: ws }
|
||||
}
|
||||
|
||||
/** A single Location JSON pointing into the workspace. */
|
||||
function locationJson(line: number): unknown {
|
||||
return { uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line, character: 0 }, end: { line, character: 3 } } }
|
||||
}
|
||||
|
||||
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('goToDefinition'))
|
||||
expect(result).toEqual<LspQueryResult>({
|
||||
kind: 'locations',
|
||||
locations: [{ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }],
|
||||
resolvedWorkspaceRoot: ws,
|
||||
})
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('maps a LocationLink for implementation', async () => {
|
||||
const link = { targetUri: pathToFileURL(join(ws, 'a.ts')).href, targetSelectionRange: { start: { line: 1, character: 0 }, end: { line: 1, character: 2 } } }
|
||||
const ctx = await mount({ LSP_FAKE_IMPL: JSON.stringify([link]) })
|
||||
const result = await ctx.lsp.query(query('goToImplementation'))
|
||||
expect(result).toMatchObject({ kind: 'locations', locations: [{ range: { start: { line: 1, character: 0 } } }] })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('returns references (server includes the declaration)', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_REFS: JSON.stringify([locationJson(0), locationJson(1)]) })
|
||||
const result = await ctx.lsp.query(query('findReferences'))
|
||||
expect(result).toMatchObject({ kind: 'locations' })
|
||||
if (result.kind !== 'locations') throw new Error('expected locations')
|
||||
expect(result.locations).toHaveLength(2)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('normalizes a hover MarkupContent', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_HOVER: JSON.stringify({ contents: { kind: 'markdown', value: 'docs' } }) })
|
||||
const result = await ctx.lsp.query(query('hover'))
|
||||
expect(result).toEqual({ kind: 'hover', hover: { contents: 'docs' } })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('returns an empty locations result for a null definition', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('returns a null hover for a null result', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_HOVER: 'null' })
|
||||
expect(await ctx.lsp.query(query('hover'))).toEqual({ kind: 'hover', hover: null })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a non-utf-16 position encoding at initialize', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not pool a poisoned instance when initialize rejects', async () => {
|
||||
// A utf-8 server makes `initialize` reject; the instance must be torn down (not left with a
|
||||
// permanently-rejecting `ready`) so a later query starts a fresh process rather than reusing it.
|
||||
const ctx = await mount({ LSP_FAKE_ENCODING: 'utf-8', LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
// A second query must also fail the same way (fresh instance), and must NOT hang on a poisoned one.
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/unsupported position encoding/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a server without transient-open sync (None)', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_SYNC: '0', LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/transient textDocument\/didOpen/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('accepts openClose options sync', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_SYNC: JSON.stringify({ openClose: true, change: 2 }), LSP_FAKE_DEF: 'null' })
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails a query for an unsupported operation', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_CAPS: JSON.stringify({ hoverProvider: false }), LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query(query('hover'))).rejects.toThrow(/does not support hover/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a source outside the workspace before startup', async () => {
|
||||
const outside = join(root, 'out.ts')
|
||||
await writeFile(outside, 'x')
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
await expect(ctx.lsp.query({ ...query('goToDefinition'), filePath: outside })).rejects.toThrow(/outside the workspace/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('serializes queries through one instance and runs them in order', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
const results = await Promise.all([
|
||||
ctx.lsp.query(query('goToDefinition')),
|
||||
ctx.lsp.query(query('goToDefinition')),
|
||||
ctx.lsp.query(query('goToDefinition')),
|
||||
])
|
||||
for (const result of results) expect(result).toMatchObject({ kind: 'locations' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('reads a queued query source only when its lifecycle starts', async () => {
|
||||
const marker = join(root, 'opened.jsonl')
|
||||
const ctx = await mount({
|
||||
LSP_FAKE_DEF: 'null',
|
||||
LSP_FAKE_REPLY_DELAY_MS: '300',
|
||||
LSP_FAKE_OPEN_MARKER: marker,
|
||||
})
|
||||
const first = ctx.lsp.query(query('goToDefinition'))
|
||||
await waitFor(async () => (await markerLines(marker)).length === 1)
|
||||
const second = ctx.lsp.query(query('goToDefinition'))
|
||||
await writeFile(join(ws, 'a.ts'), 'const changed = 2\n')
|
||||
await Promise.all([first, second])
|
||||
expect(await markerLines(marker)).toEqual([
|
||||
'const x = 1\nconst y = x\n',
|
||||
'const changed = 2\n',
|
||||
])
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('aborts an in-flight query when the signal fires', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_HANG: '1' })
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.lsp.query(query('goToDefinition'), controller.signal)
|
||||
controller.abort(new Error('caller cancelled'))
|
||||
await expect(pending).rejects.toThrow(/cancelled/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('honors an already-aborted signal before any host I/O or startup', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('pre-aborted'))
|
||||
await expect(ctx.lsp.query(query('goToDefinition'), controller.signal)).rejects.toThrow(/pre-aborted/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('surfaces the server stderr tail in the exit error', async () => {
|
||||
// A server that writes to stderr then exits without answering: the query rejection carries the
|
||||
// retained stderr tail so the failure is diagnosable.
|
||||
const ctx = await mount({}, {
|
||||
command: process.execPath,
|
||||
args: ['-e', 'process.stderr.write("FATAL: boom\\n"); setTimeout(()=>process.exit(1), 50)'],
|
||||
})
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow(/FATAL: boom/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('classifies a timeout deadline as the abort reason', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_HANG: '1' })
|
||||
using d = deadline(undefined, 50, 'TEST_TIMEOUT')
|
||||
await expect(ctx.lsp.query(query('goToDefinition'), d.signal)).rejects.toThrow(/TEST_TIMEOUT/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('fails the active query when the server crashes on open, and replaces it next query', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_CRASH_ON_OPEN: '1', LSP_FAKE_DEF: 'null' }, { shutdownTimeoutMs: 100, killGraceMs: 100 })
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow()
|
||||
// A later query starts a fresh process; still crashes, but proves the slot was replaced (no hang).
|
||||
await expect(ctx.lsp.query(query('goToDefinition'))).rejects.toThrow()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('evicts a pooled server that died while idle and serves the next query from a fresh one', async () => {
|
||||
// The first query succeeds, then the server exits before the second arrives, leaving a dead
|
||||
// instance in the pool. The next query must evict-and-replace it and still succeed, rather than
|
||||
// failing once on the closed connection first.
|
||||
const ctx = await mount({ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
|
||||
// Wait past the fixture's post-reply exit so the pooled instance is observably dead.
|
||||
await new Promise(resolve => setTimeout(resolve, 60))
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('does not spawn a server when the signal aborts during source read', async () => {
|
||||
// Abort right after issuing the query: the abort lands while canonicalizeWorkspace/readHostSource
|
||||
// are awaited, so the pre-spawn recheck must reject without ever creating a pooled instance.
|
||||
const ctx = await mount({ LSP_FAKE_DEF: 'null' })
|
||||
const controller = new AbortController()
|
||||
const pending = ctx.lsp.query(query('goToDefinition'), controller.signal)
|
||||
controller.abort(new Error('mid-read cancel'))
|
||||
await expect(pending).rejects.toThrow(/mid-read cancel/)
|
||||
// A subsequent live query still works, proving no half-created instance poisoned the pool.
|
||||
expect(await ctx.lsp.query(query('goToDefinition'))).toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: ws })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('runs distinct workspaces in parallel instances', async () => {
|
||||
const ws2 = join(root, 'ws2')
|
||||
await mkdir(ws2)
|
||||
await writeFile(join(ws2, 'a.ts'), 'const z = 2\n')
|
||||
const ctx = await mount({ LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
|
||||
const [r1, r2] = await Promise.all([
|
||||
ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws }),
|
||||
ctx.lsp.query({ ...query('goToDefinition'), workspaceRoot: ws2 }),
|
||||
])
|
||||
expect(r1).toMatchObject({ kind: 'locations' })
|
||||
expect(r2).toMatchObject({ kind: 'locations' })
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('disposes cleanly, terminating a server that ignores shutdown', async () => {
|
||||
const ctx = await mount({ LSP_FAKE_NO_SHUTDOWN: '1', LSP_FAKE_DEF: 'null' }, { killGraceMs: 100, shutdownTimeoutMs: 100 })
|
||||
await ctx.lsp.query(query('goToDefinition'))
|
||||
await expect(ctx.fiber.dispose()).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects at load when the command is not found', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
/** Read the fixture's JSON-lines didOpen marker, returning no entries before it exists. */
|
||||
async function markerLines(path: string): Promise<string[]> {
|
||||
try {
|
||||
const text = await readFile(path, 'utf8')
|
||||
return text.trim().split('\n').filter(Boolean).map(line => JSON.parse(line) as string)
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return []
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Poll an asynchronous condition until it succeeds or the test-local deadline expires. */
|
||||
async function waitFor(condition: () => Promise<boolean>, timeoutMs = 3000): Promise<void> {
|
||||
const started = Date.now()
|
||||
while (!await condition()) {
|
||||
if (Date.now() - started > timeoutMs) throw new Error('waitFor timed out')
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 10))
|
||||
}
|
||||
}
|
||||
185
packages/lsp/lsp-local/tests/provider.spec.ts
Normal file
185
packages/lsp/lsp-local/tests/provider.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
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'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-prov-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function query(): LspQueryRequest {
|
||||
return { operation: 'goToDefinition', 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.
|
||||
const bin = join(root, 'bin')
|
||||
await mkdir(bin)
|
||||
const exe = join(bin, 'fake-lsp')
|
||||
await writeFile(exe, '#!/bin/sh\nexit 0\n')
|
||||
await chmod(exe, 0o755)
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, config('onpath', {
|
||||
command: 'fake-lsp',
|
||||
args: [],
|
||||
env: { PATH: bin },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))).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, config('nope', {
|
||||
command: 'fake-lsp',
|
||||
args: [],
|
||||
env: { PATH: `::${join(root, 'empty')}` },
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))).rejects.toThrow(/was not found on PATH/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a query after the provider is disposed', async () => {
|
||||
// Use a server that never emits results and dispose the plugin, then confirm queries are refused.
|
||||
const ctx = new Context()
|
||||
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, 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' }))
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a nonpositive teardown budget at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, config('bad-budget', {
|
||||
command: process.execPath,
|
||||
args: ['-e', ''],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
killGraceMs: 0,
|
||||
}))).rejects.toThrow(/servers\.bad-budget\.killGraceMs must be a positive integer/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects a nonpositive byte cap at load', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, config('bad-cap', {
|
||||
command: process.execPath,
|
||||
args: ['-e', ''],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
maxDocumentBytes: 0,
|
||||
}))).rejects.toThrow(/servers\.bad-cap\.maxDocumentBytes must be a positive integer/)
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it.each(['shutdownTimeoutMs', 'killGraceMs'] as const)('rejects %s above Node timer range at load', async (name) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, config('bad-timer', {
|
||||
command: process.execPath,
|
||||
args: ['-e', ''],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
[name]: MAX_TIMER_DELAY_MS + 1,
|
||||
}))).rejects.toThrow(new RegExp(`servers\\.bad-timer\\.${name}`))
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('rejects an absolute command that is not executable at load', async () => {
|
||||
const notExe = join(root, 'not-exe.txt')
|
||||
await writeFile(notExe, 'plain text, not executable')
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await expect(ctx.plugin(LspLocal, config('abs-bad', {
|
||||
command: notExe,
|
||||
args: [],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))).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, config('abs-directory', {
|
||||
command: ws,
|
||||
args: [],
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
}))).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()
|
||||
})
|
||||
})
|
||||
173
packages/lsp/lsp-local/tests/translate.spec.ts
Normal file
173
packages/lsp/lsp-local/tests/translate.spec.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
negotiatePositionEncoding,
|
||||
normalizeHover,
|
||||
normalizeLocations,
|
||||
requestMethod,
|
||||
supportsOperation,
|
||||
supportsTransientOpen,
|
||||
} from '@deepseek-ai/dsh-lsp-local'
|
||||
import type { WireServerCapabilities } from '@deepseek-ai/dsh-lsp-local/src/protocol.ts'
|
||||
|
||||
const RANGE = { start: { line: 1, character: 2 }, end: { line: 1, character: 5 } }
|
||||
|
||||
describe('requestMethod', () => {
|
||||
it('maps each operation to its textDocument request', () => {
|
||||
expect(requestMethod('goToDefinition')).toBe('textDocument/definition')
|
||||
expect(requestMethod('findReferences')).toBe('textDocument/references')
|
||||
expect(requestMethod('goToImplementation')).toBe('textDocument/implementation')
|
||||
expect(requestMethod('hover')).toBe('textDocument/hover')
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsOperation', () => {
|
||||
it('reads the provider slot for each operation (boolean and options forms)', () => {
|
||||
const caps: WireServerCapabilities = {
|
||||
definitionProvider: true,
|
||||
referencesProvider: { workDoneProgress: true },
|
||||
implementationProvider: false,
|
||||
}
|
||||
expect(supportsOperation(caps, 'goToDefinition')).toBe(true)
|
||||
expect(supportsOperation(caps, 'findReferences')).toBe(true)
|
||||
expect(supportsOperation(caps, 'goToImplementation')).toBe(false)
|
||||
expect(supportsOperation(caps, 'hover')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsTransientOpen', () => {
|
||||
it('accepts legacy Full and Incremental enums, rejects None and absent', () => {
|
||||
expect(supportsTransientOpen(1)).toBe(true)
|
||||
expect(supportsTransientOpen(2)).toBe(true)
|
||||
expect(supportsTransientOpen(0)).toBe(false)
|
||||
expect(supportsTransientOpen(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts options with openClose:true and rejects openClose:false', () => {
|
||||
expect(supportsTransientOpen({ openClose: true })).toBe(true)
|
||||
expect(supportsTransientOpen({ openClose: false, change: 2 })).toBe(false)
|
||||
})
|
||||
|
||||
it('requires an explicit openClose for the options form (no change-enum fallback)', () => {
|
||||
expect(supportsTransientOpen({ change: 1 })).toBe(false)
|
||||
expect(supportsTransientOpen({ change: 2 })).toBe(false)
|
||||
expect(supportsTransientOpen({})).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('negotiatePositionEncoding', () => {
|
||||
it('defaults an omitted encoding to utf-16', () => {
|
||||
expect(negotiatePositionEncoding(undefined)).toBe('utf-16')
|
||||
expect(negotiatePositionEncoding('utf-16')).toBe('utf-16')
|
||||
})
|
||||
|
||||
it('rejects any other encoding', () => {
|
||||
expect(() => negotiatePositionEncoding('utf-8')).toThrow(/unsupported position encoding/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeLocations', () => {
|
||||
it('returns empty only for the protocol no-result value null', () => {
|
||||
expect(normalizeLocations(null)).toEqual([])
|
||||
expect(() => normalizeLocations(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
|
||||
it('maps a single Location', () => {
|
||||
expect(normalizeLocations({ uri: 'file:///a', range: RANGE })).toEqual([{ uri: 'file:///a', range: RANGE }])
|
||||
})
|
||||
|
||||
it('maps an array of Locations', () => {
|
||||
const result = normalizeLocations([{ uri: 'file:///a', range: RANGE }, { uri: 'file:///b', range: RANGE }])
|
||||
expect(result.map(l => l.uri)).toEqual(['file:///a', 'file:///b'])
|
||||
})
|
||||
|
||||
it('maps a LocationLink from targetUri + targetSelectionRange', () => {
|
||||
const link = { targetUri: 'file:///c', targetSelectionRange: RANGE, targetRange: RANGE }
|
||||
expect(normalizeLocations([link])).toEqual([{ uri: 'file:///c', range: RANGE }])
|
||||
})
|
||||
|
||||
it('rejects a non-object entry', () => {
|
||||
expect(() => normalizeLocations([42])).toThrow(/non-object/)
|
||||
})
|
||||
|
||||
it('rejects an entry that is neither a Location nor a LocationLink', () => {
|
||||
expect(() => normalizeLocations([{ nope: true }])).toThrow(/neither a Location nor a LocationLink/)
|
||||
})
|
||||
|
||||
it('rejects a Location whose range is not an object', () => {
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: 'nope' }])).toThrow(/neither a Location/)
|
||||
})
|
||||
|
||||
it('rejects a Location whose range positions are malformed', () => {
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: null, end: null } }])).toThrow(/neither a Location/)
|
||||
})
|
||||
|
||||
it('rejects negative and fractional position coordinates', () => {
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: { line: -1, character: 0 }, end: RANGE.end } }]))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
expect(() => normalizeLocations([{ uri: 'file:///a', range: { start: RANGE.start, end: { line: 1.5, character: 5 } } }]))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeHover', () => {
|
||||
it('returns null for null', () => {
|
||||
expect(normalizeHover(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a missing hover result', () => {
|
||||
expect(() => normalizeHover(undefined)).toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
|
||||
it('reads MarkupContent value and keeps a range', () => {
|
||||
expect(normalizeHover({ contents: { kind: 'markdown', value: '# H' }, range: RANGE }))
|
||||
.toEqual({ contents: '# H', range: RANGE })
|
||||
})
|
||||
|
||||
it('keeps a bare string MarkedString verbatim', () => {
|
||||
expect(normalizeHover({ contents: 'plain text' })).toEqual({ contents: 'plain text' })
|
||||
})
|
||||
|
||||
it('renders a language-tagged MarkedString object as a fenced code block', () => {
|
||||
expect(normalizeHover({ contents: { language: 'ts', value: 'const x = 1' } }))
|
||||
.toEqual({ contents: '```ts\nconst x = 1\n```' })
|
||||
})
|
||||
|
||||
it('joins a MarkedString array with one blank line', () => {
|
||||
expect(normalizeHover({ contents: ['a', { language: 'ts', value: 'b' }] }))
|
||||
.toEqual({ contents: 'a\n\n```ts\nb\n```' })
|
||||
})
|
||||
|
||||
it('drops an empty-contents hover to null', () => {
|
||||
expect(normalizeHover({ contents: { kind: 'plaintext', value: '' } })).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a MarkupContent with a non-string value', () => {
|
||||
expect(() => normalizeHover({ contents: { kind: 'markdown', value: 42 } }))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
|
||||
it('rejects a non-object payload', () => {
|
||||
expect(() => normalizeHover(42)).toThrow(/was not an object/)
|
||||
})
|
||||
|
||||
it('rejects malformed contents', () => {
|
||||
expect(() => normalizeHover({ contents: { weird: true } })).toThrow(/were not MarkupContent/)
|
||||
expect(() => normalizeHover({ contents: 42 })).toThrow(/were not MarkupContent/)
|
||||
})
|
||||
|
||||
it('rejects a malformed MarkedString array member', () => {
|
||||
expect(() => normalizeHover({ contents: ['ok', { language: 'ts', value: 42 }] }))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
expect(() => normalizeHover({ contents: [null] }))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
|
||||
it('rejects a hover with no contents field', () => {
|
||||
expect(() => normalizeHover({ range: RANGE })).toThrow(/no contents/)
|
||||
})
|
||||
|
||||
it('rejects a malformed range instead of silently dropping it', () => {
|
||||
expect(() => normalizeHover({ contents: 'x', range: { start: { line: 1 } } }))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_MALFORMED_RESPONSE' }))
|
||||
})
|
||||
})
|
||||
114
packages/lsp/lsp-local/tests/typescript-server.e2e.ts
Normal file
114
packages/lsp/lsp-local/tests/typescript-server.e2e.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Keyless real-server e2e: drives the real `typescript-language-server` through the full
|
||||
* `ctx.lsp` → `dsh-lsp-local` stack over the base protocol, exercising all four operations. No API
|
||||
* key needed — the server is a local dev dependency. This establishes one compatibility floor
|
||||
* (TypeScript), not a cross-language claim.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
|
||||
|
||||
// The server binary is a dev dependency of this package; resolve its pnpm-hoisted .bin path.
|
||||
const serverBin = join(
|
||||
new URL('..', import.meta.url).pathname,
|
||||
'node_modules',
|
||||
'.bin',
|
||||
'typescript-language-server',
|
||||
)
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
let ctx: Context
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-ts-e2e-')))
|
||||
ws = join(root, 'proj')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'tsconfig.json'), JSON.stringify({ compilerOptions: { strict: true, module: 'nodenext' } }))
|
||||
// A small program with a definition, a reference, an interface + implementation, and a typed value.
|
||||
await writeFile(join(ws, 'shapes.ts'), [
|
||||
'export interface Shape {',
|
||||
' area(): number',
|
||||
'}',
|
||||
'',
|
||||
'export class Circle implements Shape {',
|
||||
' constructor(private r: number) {}',
|
||||
' area(): number { return Math.PI * this.r * this.r }',
|
||||
'}',
|
||||
'',
|
||||
'export function describe(s: Shape): string {',
|
||||
' return `area=${s.area()}`',
|
||||
'}',
|
||||
'',
|
||||
'const c = new Circle(2)',
|
||||
'export const text = describe(c)',
|
||||
'',
|
||||
].join('\n'))
|
||||
|
||||
ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LspLocal, {
|
||||
servers: {
|
||||
typescript: {
|
||||
command: serverBin,
|
||||
args: ['--stdio'],
|
||||
extensionToLanguage: { '.ts': 'typescript', '.tsx': 'typescriptreact' },
|
||||
},
|
||||
},
|
||||
})
|
||||
}, 60_000)
|
||||
|
||||
afterAll(async () => {
|
||||
if (ctx) await ctx.fiber.dispose()
|
||||
if (root) await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** One-based helper mirroring the model contract, converted to the seam's zero-based position. */
|
||||
function at(operation: LspQueryRequest['operation'], line1: number, char1: number, filePath = 'shapes.ts'): LspQueryRequest {
|
||||
return { operation, filePath, position: { line: line1 - 1, character: char1 - 1 }, workspaceRoot: ws }
|
||||
}
|
||||
|
||||
function locations(result: LspQueryResult): readonly { uri: string }[] {
|
||||
if (result.kind !== 'locations') throw new Error(`expected locations, got ${result.kind}`)
|
||||
return result.locations
|
||||
}
|
||||
|
||||
describe('real typescript-language-server', () => {
|
||||
it('resolves the definition of a call site to its declaration', async () => {
|
||||
// `export const text = describe(c)` (line 15): `describe` begins at column 21.
|
||||
const result = await ctx.lsp.query(at('goToDefinition', 15, 22))
|
||||
const locs = locations(result)
|
||||
expect(locs.length).toBeGreaterThanOrEqual(1)
|
||||
expect(locs.some(l => l.uri.endsWith('shapes.ts'))).toBe(true)
|
||||
}, 60_000)
|
||||
|
||||
it('finds references to a symbol including its declaration', async () => {
|
||||
// References to `describe` from its declaration (line 10, col 17).
|
||||
const result = await ctx.lsp.query(at('findReferences', 10, 17))
|
||||
const locs = locations(result)
|
||||
// At least the declaration plus the call site.
|
||||
expect(locs.length).toBeGreaterThanOrEqual(2)
|
||||
}, 60_000)
|
||||
|
||||
it('resolves implementations of an interface', async () => {
|
||||
// Implementations of `Shape` (line 1, col 18) → Circle.
|
||||
const result = await ctx.lsp.query(at('goToImplementation', 1, 18))
|
||||
const locs = locations(result)
|
||||
expect(locs.length).toBeGreaterThanOrEqual(1)
|
||||
}, 60_000)
|
||||
|
||||
it('returns hover information for a typed symbol', async () => {
|
||||
// Hover on `Circle` in `new Circle(2)` (line 14, col 15).
|
||||
const result = await ctx.lsp.query(at('hover', 14, 15))
|
||||
expect(result.kind).toBe('hover')
|
||||
if (result.kind === 'hover') {
|
||||
expect(result.hover).not.toBeNull()
|
||||
expect(result.hover?.contents).toContain('Circle')
|
||||
}
|
||||
}, 60_000)
|
||||
})
|
||||
36
packages/lsp/lsp-local/tsconfig.json
Normal file
36
packages/lsp/lsp-local/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../lsp"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
42
packages/lsp/lsp/README.md
Normal file
42
packages/lsp/lsp/README.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# @deepseek-ai/dsh-lsp
|
||||
|
||||
The **LSP capability seam**: an abstract `LspService` (`ctx.lsp`) defining WHAT semantic code navigation the harness has — go to definition, find references, find implementations, hover — over language-server providers, without binding the model contract to local subprocesses.
|
||||
|
||||
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 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 — `goToDefinition`, `findReferences`, `goToImplementation`, `hover` — and no generic JSON-RPC escape hatch, so no protocol payload or unreviewed command/mutation reaches a provider through `ctx.lsp`.
|
||||
|
||||
## Service API (`ctx.lsp`)
|
||||
|
||||
| Member | Semantics |
|
||||
|---|---|
|
||||
| `registerProvider(provider)` | Register a backend, atomically reserving its branded `id` and every normalized file extension. Any invalid input or conflict publishes nothing and throws `LspError` (`LSP_INVALID_PROVIDER` / `LSP_CONFLICT`). Returns a disposer releasing all reservations. Disposed with the calling fiber. |
|
||||
| `query(request, signal?)` | Select the provider by the file's final extension, derive the `languageId` from that provider's mapping, and run one query. No match throws `LspError` `LSP_UNAVAILABLE`. |
|
||||
|
||||
Selection is per query and order-independent: a provider owns a set of extensions exclusively, so registration and HMR order never change routing. Extension keys normalize to lowercase, leading-dot form; the `languageId` only synchronizes the transient document, never participates in selection. The first version has no glob, language-id, or explicit route selector.
|
||||
|
||||
Providers register **capabilities**, not tools. `dsh-tool-lsp` is the only owner of the model-facing name, description, prompt guidance, schema, and presentation.
|
||||
|
||||
## Vocabulary
|
||||
|
||||
`LspQueryRequest` (`operation`, `filePath`, `position`, `workspaceRoot`) — every field required, so no field needs implementation defaulting and there is no `resolve()` step. Positions and ranges are zero-based UTF-16, matching the protocol; the tool owns the one-based cursor convention. `findReferences` always includes declarations — providers enforce this internally, so callers get no flag. `LspQueryResult` is a CLOSED discriminated union: `{ kind: 'locations'; locations; resolvedWorkspaceRoot }` for navigation, `{ kind: 'hover'; hover }` for hover (content or `null`) — consumers `switch` to exhaustiveness so a new arm breaks compilation until handled. `resolvedWorkspaceRoot` is the provider's canonical form of the request's `workspaceRoot` and the root its `file:` URIs are relative to; a caller relativizing display paths uses it, not the (possibly symlinked) request root. See `src/types.ts` for the full contracts and `src/index.ts` for the `LspError` codes, including `LSP_DISPOSED` and `LSP_MALFORMED_RESPONSE`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through `dsh-tool-lsp`, which owns the model-facing `lsp` schema, prompt, and rendered results while this registry contributes no prompt or schema itself.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; `dsh-tool-lsp` owns request-prefix changes.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Exclusive extension ownership within one runtime** — two providers cannot both claim `.ts`, even with different language ids; overlaps fail registration. The intended extension is a deployment-configured selector above registrations, which can relax exclusive reservation without adding provider choice to model input ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)).
|
||||
- **Four operations only** — symbols and call hierarchy are deferred (they need different schemas); diagnostics need separate freshness/accumulation rules; mutations (rename, code actions, formatting) require separate tools with preview, permission, and write-policy integration.
|
||||
- **No observation surface** — availability is observed only by running `query()` and routing the thrown `LspError` codes; there is no provider-change event or capability-status query.
|
||||
41
packages/lsp/lsp/package.json
Normal file
41
packages/lsp/lsp/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-lsp",
|
||||
"description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
21
packages/lsp/lsp/src/brand.ts
Normal file
21
packages/lsp/lsp/src/brand.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* dsh-lsp's owned branded id: {@link LspProviderId}, the opaque identity a provider reserves on
|
||||
* `ctx.lsp`. The `Branded<B>` primitive lives in `@deepseek-ai/dsh-brand`; keeping the type and its
|
||||
* factory together here lets `index.ts` re-export both under one name.
|
||||
* @module @deepseek-ai/dsh-lsp/brand
|
||||
*/
|
||||
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Opaque provider identity, reserved atomically with its extension mappings at registration. */
|
||||
export type LspProviderId = Branded<'LspProviderId'>
|
||||
|
||||
/**
|
||||
* Brand a string as an {@link LspProviderId}. No validation — the registry rejects an empty id at
|
||||
* registration.
|
||||
* @param id - the provider's stable identifier.
|
||||
* @returns the same string, branded.
|
||||
*/
|
||||
export function LspProviderId(id: string): LspProviderId {
|
||||
return id as LspProviderId
|
||||
}
|
||||
158
packages/lsp/lsp/src/index.ts
Normal file
158
packages/lsp/lsp/src/index.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* The LSP capability seam (`ctx.lsp`): a language-server provider registry and per-query,
|
||||
* order-independent selection over normalized goToDefinition/findReferences/goToImplementation/
|
||||
* hover queries.
|
||||
*
|
||||
* A provider reserves a branded id and an exclusive set of file extensions atomically:
|
||||
* {@link Lsp.registerProvider} validates and conflict-checks everything before mutating, so an
|
||||
* invalid or conflicting registration publishes nothing, and its disposer releases every
|
||||
* reservation together. Selection routes a query by the file's final extension; it never depends on
|
||||
* registration order. The seam exposes exactly the four operations and no JSON-RPC escape hatch.
|
||||
* @module @deepseek-ai/dsh-lsp
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import { HarnessError } from '@deepseek-ai/dsh-llm'
|
||||
import type { LspProviderId } from './brand.ts'
|
||||
import type {
|
||||
LspProvider,
|
||||
LspQueryRequest,
|
||||
LspQueryResult,
|
||||
LspService,
|
||||
} from './types.ts'
|
||||
|
||||
export { LspProviderId } from './brand.ts'
|
||||
export type {
|
||||
LspHover,
|
||||
LspLocation,
|
||||
LspOperation,
|
||||
LspPosition,
|
||||
LspProvider,
|
||||
LspProviderQuery,
|
||||
LspQueryRequest,
|
||||
LspQueryResult,
|
||||
LspRange,
|
||||
LspService,
|
||||
} from './types.ts'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
lsp: LspService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Structured LSP failure. Extends {@link HarnessError} with a stable `code`
|
||||
* (`LSP_INVALID_PROVIDER`, `LSP_CONFLICT`, `LSP_UNAVAILABLE`, `LSP_DISPOSED`,
|
||||
* `LSP_UNSUPPORTED_OPERATION`, `LSP_MALFORMED_RESPONSE`, …) that callers route on instead of
|
||||
* parsing `message`.
|
||||
*/
|
||||
export class LspError extends HarnessError {}
|
||||
|
||||
/**
|
||||
* Extract a file's final extension as a normalized, lowercase, leading-dot key (e.g. `Foo.TS` →
|
||||
* `.ts`, `foo.d.ts` → `.ts`). Returns `''` for a name with no extension or a leading-dot dotfile
|
||||
* (`.bashrc`), which no route ever matches. Splits on both `/` and `\` so a caller's path separator
|
||||
* does not change the result.
|
||||
* @param filePath - the source path to inspect.
|
||||
* @returns the normalized extension, or `''` when there is none.
|
||||
*/
|
||||
export function finalExtension(filePath: string): string {
|
||||
const lastSlash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
|
||||
const base = lastSlash >= 0 ? filePath.slice(lastSlash + 1) : filePath
|
||||
const dot = base.lastIndexOf('.')
|
||||
// dot <= 0 covers both "no dot" (-1) and a leading-dot dotfile (0): neither has an extension.
|
||||
if (dot <= 0) return ''
|
||||
return base.slice(dot).toLowerCase()
|
||||
}
|
||||
|
||||
/** A well-formed normalized extension: a dot followed by one or more non-dot, non-separator chars. */
|
||||
const EXTENSION_PATTERN = /^\.[^./\\]+$/
|
||||
|
||||
/** One selection route: the provider to run plus the language id to synchronize the document with. */
|
||||
interface Route {
|
||||
readonly provider: LspProvider
|
||||
readonly languageId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* `ctx.lsp`. Holds the id reservations and the extension→route table; both are populated and cleared
|
||||
* together per provider so a route always has a live provider.
|
||||
*/
|
||||
export class Lsp extends Service implements LspService {
|
||||
private readonly providerIds = new Set<LspProviderId>()
|
||||
private readonly routes = new Map<string, Route>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'lsp')
|
||||
}
|
||||
|
||||
registerProvider(provider: LspProvider): () => void {
|
||||
// Validate and conflict-check everything BEFORE any mutation: an invalid or conflicting
|
||||
// registration must publish nothing (fail-loud, all-or-nothing).
|
||||
const id = provider.id
|
||||
if (id.trim() === '') {
|
||||
throw new LspError('an LSP provider id must be a non-empty string', 'LSP_INVALID_PROVIDER')
|
||||
}
|
||||
if (this.providerIds.has(id)) {
|
||||
throw new LspError(`an LSP provider with id "${id}" is already registered`, 'LSP_CONFLICT')
|
||||
}
|
||||
|
||||
const entries = Object.entries(provider.extensionToLanguage)
|
||||
if (entries.length === 0) {
|
||||
throw new LspError(`LSP provider "${id}" registers no file extensions`, 'LSP_INVALID_PROVIDER')
|
||||
}
|
||||
|
||||
// Normalize into this provider's route set, catching intra-provider duplicates (e.g. `.TS` and
|
||||
// `.ts`) before checking cross-provider conflicts.
|
||||
const pending = new Map<string, Route>()
|
||||
for (const [rawExt, languageId] of entries) {
|
||||
const ext = normalizeExtension(rawExt)
|
||||
if (!EXTENSION_PATTERN.test(ext)) {
|
||||
throw new LspError(`LSP provider "${id}" maps an invalid extension "${rawExt}"`, 'LSP_INVALID_PROVIDER')
|
||||
}
|
||||
if (languageId.trim() === '') {
|
||||
throw new LspError(`LSP provider "${id}" maps extension "${ext}" to an empty language id`, 'LSP_INVALID_PROVIDER')
|
||||
}
|
||||
if (pending.has(ext)) {
|
||||
throw new LspError(`LSP provider "${id}" maps extension "${ext}" more than once`, 'LSP_INVALID_PROVIDER')
|
||||
}
|
||||
pending.set(ext, { provider, languageId })
|
||||
}
|
||||
for (const ext of pending.keys()) {
|
||||
if (this.routes.has(ext)) {
|
||||
throw new LspError(`extension "${ext}" is already handled by another LSP provider`, 'LSP_CONFLICT')
|
||||
}
|
||||
}
|
||||
|
||||
// All checks passed: reserve id and every extension in one lifecycle controller so disposal
|
||||
// releases them together.
|
||||
const dispose = this.ctx.effect(function* (this: Lsp) {
|
||||
this.providerIds.add(id)
|
||||
for (const [ext, route] of pending) this.routes.set(ext, route)
|
||||
yield () => {
|
||||
this.providerIds.delete(id)
|
||||
for (const ext of pending.keys()) this.routes.delete(ext)
|
||||
}
|
||||
}.bind(this), 'lsp.registerProvider()')
|
||||
// ctx.effect's disposer returns Promise<void>; our disposer API is synchronous
|
||||
// fire-and-forget — discard the (always-resolved) promise.
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
async query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult> {
|
||||
const route = this.routes.get(finalExtension(request.filePath))
|
||||
if (route === undefined) {
|
||||
throw new LspError(`no LSP provider handles "${request.filePath}"`, 'LSP_UNAVAILABLE')
|
||||
}
|
||||
return route.provider.query({ ...request, languageId: route.languageId }, signal)
|
||||
}
|
||||
}
|
||||
|
||||
/** Lowercase an extension and ensure it carries a leading dot; `EXTENSION_PATTERN` rejects the rest. */
|
||||
function normalizeExtension(ext: string): string {
|
||||
const lower = ext.toLowerCase()
|
||||
return lower.startsWith('.') ? lower : `.${lower}`
|
||||
}
|
||||
|
||||
export default Lsp
|
||||
30
packages/lsp/lsp/src/invariant.ts
Normal file
30
packages/lsp/lsp/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-lsp`.
|
||||
* @module @deepseek-ai/dsh-lsp/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-lsp'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'lsp-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: provider ids and extension routes are private, atomically updated state;
|
||||
* the seam exposes neither an enumerable snapshot nor lifecycle events to compare independently.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
130
packages/lsp/lsp/src/types.ts
Normal file
130
packages/lsp/lsp/src/types.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* LSP seam vocabulary: the normalized request, provider, and result contracts. Types only — the
|
||||
* {@link LspError} taxonomy and the {@link LspProviderId} brand factory are runtime and live in
|
||||
* `index.ts`. Positions and ranges are zero-based UTF-16, matching the protocol; the model-facing
|
||||
* tool owns the one-based cursor convention. The seam exposes no protocol types, process or document
|
||||
* controls, or generic JSON-RPC escape hatch — only the four semantic operations.
|
||||
* @module @deepseek-ai/dsh-lsp/types
|
||||
*/
|
||||
|
||||
import type { LspProviderId } from './brand.ts'
|
||||
|
||||
/**
|
||||
* The four semantic queries the seam and model expose. A closed union: adding an operation is a
|
||||
* compile-enforced change across the seam, providers, and the tool. Symbols and call hierarchy are
|
||||
* deliberately deferred (they need different schemas).
|
||||
*/
|
||||
export type LspOperation = 'goToDefinition' | 'findReferences' | 'goToImplementation' | 'hover'
|
||||
|
||||
/** A zero-based UTF-16 cursor coordinate, matching the LSP wire convention. */
|
||||
export interface LspPosition {
|
||||
/** Zero-based line. */
|
||||
readonly line: number
|
||||
/** Zero-based UTF-16 code-unit offset within the line. */
|
||||
readonly character: number
|
||||
}
|
||||
|
||||
/** A zero-based UTF-16 half-open range `[start, end)`. */
|
||||
export interface LspRange {
|
||||
readonly start: LspPosition
|
||||
readonly end: LspPosition
|
||||
}
|
||||
|
||||
/**
|
||||
* A caller's normalized query. Every field is required: `workspaceRoot` is caller-supplied,
|
||||
* `languageId` comes from the provider registration (not here), and consumers own timeouts and
|
||||
* result limits — so no field needs implementation defaulting and there is no `resolve()` step.
|
||||
*/
|
||||
export interface LspQueryRequest {
|
||||
/** Which semantic query to run. */
|
||||
readonly operation: LspOperation
|
||||
/** The source file to query (relative to `workspaceRoot` or absolute; the provider canonicalizes). */
|
||||
readonly filePath: string
|
||||
/** The zero-based UTF-16 cursor position to query at. */
|
||||
readonly position: LspPosition
|
||||
/** The workspace root the provider resolves against and indexes; required, never defaulted. */
|
||||
readonly workspaceRoot: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A request as a provider receives it: the caller's {@link LspQueryRequest} plus the `languageId`
|
||||
* the seam derived from the provider's extension mapping. The language id only synchronizes the
|
||||
* transient document; it does not participate in selection.
|
||||
*/
|
||||
export interface LspProviderQuery extends LspQueryRequest {
|
||||
/** The LSP language id for `filePath`, from this provider's extension mapping. */
|
||||
readonly languageId: string
|
||||
}
|
||||
|
||||
/** One resolved location: a document URI and the range within it. */
|
||||
export interface LspLocation {
|
||||
/** The target document URI (`file:` or otherwise), verbatim from the server. */
|
||||
readonly uri: string
|
||||
/** The range within the target document. */
|
||||
readonly range: LspRange
|
||||
}
|
||||
|
||||
/** Normalized hover content, or `null` for no hover at the position. */
|
||||
export interface LspHover {
|
||||
/** The normalized hover text (markdown or plaintext, provider-joined). */
|
||||
readonly contents: string
|
||||
/** The range the hover applies to, when the server supplied one. */
|
||||
readonly range?: LspRange
|
||||
}
|
||||
|
||||
/**
|
||||
* The closed result union. Navigation operations (`goToDefinition`, `findReferences`,
|
||||
* `goToImplementation`) normalize to `locations`; `hover` normalizes to content or `null`.
|
||||
* Consumers `switch` on `kind` to exhaustiveness so a new arm breaks compilation until handled.
|
||||
*
|
||||
* The `locations` variant carries `resolvedWorkspaceRoot`: the provider's canonical form of the
|
||||
* request's `workspaceRoot`, and the root its `file:` location URIs are relative to. A caller that
|
||||
* relativizes display paths MUST use this, not the request's (possibly symlinked) `workspaceRoot`;
|
||||
* otherwise a symlinked workspace misclassifies in-workspace results as external.
|
||||
*/
|
||||
export type LspQueryResult =
|
||||
| { readonly kind: 'locations'; readonly locations: readonly LspLocation[]; readonly resolvedWorkspaceRoot: string }
|
||||
| { readonly kind: 'hover'; readonly hover: LspHover | null }
|
||||
|
||||
/**
|
||||
* A language-server backend registered on `ctx.lsp`. Each provider owns a stable {@link
|
||||
* LspProviderId} and an extension-to-language-id map (lowercase, leading-dot keys).
|
||||
* `findReferences` always includes declarations — the provider enforces this internally; callers
|
||||
* get no flag.
|
||||
*/
|
||||
export interface LspProvider {
|
||||
/** Stable provider identity, reserved atomically with the extension mappings. */
|
||||
readonly id: LspProviderId
|
||||
/** Lowercase leading-dot extension → LSP language id (e.g. `{ '.ts': 'typescript' }`). */
|
||||
readonly extensionToLanguage: Readonly<Record<string, string>>
|
||||
/**
|
||||
* Run one query. The seam has already selected this provider and derived `languageId`.
|
||||
* @param request - the resolved provider query (caller request + derived language id).
|
||||
* @param signal - optional cancellation; the provider stops its own work when it aborts.
|
||||
* @returns the normalized, closed-union result.
|
||||
*/
|
||||
query(request: LspProviderQuery, signal?: AbortSignal): Promise<LspQueryResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* The LSP capability seam (`ctx.lsp`). Owns provider registration/selection and normalized query
|
||||
* execution; exposes exactly the four operations and no protocol escape hatch.
|
||||
*/
|
||||
export interface LspService {
|
||||
/**
|
||||
* Register a provider, atomically reserving its id and every normalized extension. Any conflict
|
||||
* or invalid input publishes nothing and throws `LspError`; the returned disposer releases all
|
||||
* reservations. Disposed with the calling fiber.
|
||||
* @param provider - the backend to register.
|
||||
* @returns a synchronous disposer releasing the id and all extension reservations.
|
||||
*/
|
||||
registerProvider(provider: LspProvider): () => void
|
||||
/**
|
||||
* Select a provider by the file's extension and run one query. Selection is per-query and
|
||||
* order-independent; no match throws `LspError` `LSP_UNAVAILABLE`.
|
||||
* @param request - the normalized query.
|
||||
* @param signal - optional cancellation forwarded to the selected provider.
|
||||
* @returns the normalized, closed-union result.
|
||||
*/
|
||||
query(request: LspQueryRequest, signal?: AbortSignal): Promise<LspQueryResult>
|
||||
}
|
||||
187
packages/lsp/lsp/tests/lsp.spec.ts
Normal file
187
packages/lsp/lsp/tests/lsp.spec.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import Lsp, {
|
||||
finalExtension,
|
||||
LspError,
|
||||
LspProviderId,
|
||||
type LspProvider,
|
||||
type LspProviderQuery,
|
||||
type LspQueryResult,
|
||||
} from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
/** A scripted provider that records the queries it receives. */
|
||||
function makeProvider(
|
||||
id: string,
|
||||
extensionToLanguage: Record<string, string>,
|
||||
result: LspQueryResult = { kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' },
|
||||
): LspProvider & { seen: LspProviderQuery[]; seenSignals: (AbortSignal | undefined)[] } {
|
||||
const seen: LspProviderQuery[] = []
|
||||
const seenSignals: (AbortSignal | undefined)[] = []
|
||||
return {
|
||||
id: LspProviderId(id),
|
||||
extensionToLanguage,
|
||||
seen,
|
||||
seenSignals,
|
||||
query(request, signal) {
|
||||
seen.push(request)
|
||||
seenSignals.push(signal)
|
||||
return Promise.resolve(result)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount an Lsp service on a fresh root context. */
|
||||
async function mountLsp(): Promise<{ ctx: Context; lsp: Lsp }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(Lsp)
|
||||
return { ctx, lsp: ctx.lsp as Lsp }
|
||||
}
|
||||
|
||||
const hover: LspQueryResult = { kind: 'hover', hover: { contents: 'x' } }
|
||||
|
||||
function query(filePath: string, operation: LspProviderQuery['operation'] = 'goToDefinition'): Parameters<Lsp['query']>[0] {
|
||||
return { operation, filePath, position: { line: 0, character: 0 }, workspaceRoot: '/ws' }
|
||||
}
|
||||
|
||||
describe('finalExtension', () => {
|
||||
it('lowercases and keeps only the final extension', () => {
|
||||
expect(finalExtension('src/Foo.TS')).toBe('.ts')
|
||||
expect(finalExtension('a/b/foo.d.ts')).toBe('.ts')
|
||||
expect(finalExtension('C:\\proj\\Main.CS')).toBe('.cs')
|
||||
})
|
||||
|
||||
it('returns empty for no extension or a leading-dot dotfile', () => {
|
||||
expect(finalExtension('Makefile')).toBe('')
|
||||
expect(finalExtension('.bashrc')).toBe('')
|
||||
expect(finalExtension('dir.d/file')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Lsp registration', () => {
|
||||
it('registers a provider and routes a query to it, then releases on dispose', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
const provider = makeProvider('ts', { '.ts': 'typescript' })
|
||||
const dispose = lsp.registerProvider(provider)
|
||||
|
||||
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' })
|
||||
expect(provider.seen[0]).toMatchObject({ filePath: 'a.ts', languageId: 'typescript' })
|
||||
|
||||
dispose()
|
||||
await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
})
|
||||
|
||||
it('normalizes extension keys to lowercase leading-dot and derives the language id', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
const provider = makeProvider('ts', { TS: 'typescript' })
|
||||
lsp.registerProvider(provider)
|
||||
await lsp.query(query('a.ts'))
|
||||
expect(provider.seen[0]?.languageId).toBe('typescript')
|
||||
})
|
||||
|
||||
it('rejects an empty provider id (LSP_INVALID_PROVIDER)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
expect(() => lsp.registerProvider(makeProvider(' ', { '.ts': 'typescript' })))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
|
||||
})
|
||||
|
||||
it('rejects a provider with no extensions (LSP_INVALID_PROVIDER)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
expect(() => lsp.registerProvider(makeProvider('ts', {})))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
|
||||
})
|
||||
|
||||
it('rejects an invalid extension mapping (LSP_INVALID_PROVIDER)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
expect(() => lsp.registerProvider(makeProvider('ts', { '.tar.gz': 'archive' })))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
|
||||
})
|
||||
|
||||
it('rejects an empty language id (LSP_INVALID_PROVIDER)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
expect(() => lsp.registerProvider(makeProvider('ts', { '.ts': ' ' })))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
|
||||
})
|
||||
|
||||
it('rejects an extension mapped twice within one provider (LSP_INVALID_PROVIDER)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
expect(() => lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript', TS: 'ts2' })))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_INVALID_PROVIDER' }))
|
||||
})
|
||||
|
||||
it('rejects a duplicate provider id (LSP_CONFLICT)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
|
||||
expect(() => lsp.registerProvider(makeProvider('ts', { '.tsx': 'typescriptreact' })))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
|
||||
})
|
||||
|
||||
it('rejects an extension already owned by another provider (LSP_CONFLICT)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
|
||||
expect(() => lsp.registerProvider(makeProvider('other', { '.ts': 'other-lang' })))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
|
||||
})
|
||||
|
||||
it('publishes nothing when a later extension conflicts (atomic reservation)', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
|
||||
// This provider's `.py` is free but `.ts` conflicts: the whole registration must roll back.
|
||||
expect(() => lsp.registerProvider(makeProvider('py-ts', { '.py': 'python', '.ts': 'x' })))
|
||||
.toThrow(expect.objectContaining({ code: 'LSP_CONFLICT' }))
|
||||
// `.py` must NOT have been reserved.
|
||||
await expect(lsp.query(query('a.py'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
})
|
||||
|
||||
it('releases every extension and the id together on dispose', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
const dispose = lsp.registerProvider(makeProvider('multi', { '.ts': 'typescript', '.tsx': 'typescriptreact' }))
|
||||
dispose()
|
||||
await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
await expect(lsp.query(query('a.tsx'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
// The id is free again after release.
|
||||
expect(() => lsp.registerProvider(makeProvider('multi', { '.ts': 'typescript' }))).not.toThrow()
|
||||
})
|
||||
|
||||
it('selection is order-independent across two providers', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
const ts = makeProvider('ts', { '.ts': 'typescript' }, hover)
|
||||
const py = makeProvider('py', { '.py': 'python' })
|
||||
lsp.registerProvider(ts)
|
||||
lsp.registerProvider(py)
|
||||
await expect(lsp.query(query('a.py'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' })
|
||||
await expect(lsp.query(query('a.ts', 'hover'))).resolves.toEqual(hover)
|
||||
})
|
||||
|
||||
it('forwards the abort signal verbatim to the provider', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
const provider = makeProvider('ts', { '.ts': 'typescript' })
|
||||
lsp.registerProvider(provider)
|
||||
const controller = new AbortController()
|
||||
await lsp.query(query('a.ts'), controller.signal)
|
||||
expect(provider.seenSignals[0]).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('fails LSP_UNAVAILABLE when no provider handles the extension', async () => {
|
||||
const { lsp } = await mountLsp()
|
||||
lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
|
||||
await expect(lsp.query(query('a.py'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
})
|
||||
|
||||
it('disposes provider registrations when the contributing fiber is disposed (HMR safety)', async () => {
|
||||
const { ctx, lsp } = await mountLsp()
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.lsp.registerProvider(makeProvider('ts', { '.ts': 'typescript' }))
|
||||
}, { inject: ['lsp'] }))
|
||||
await expect(lsp.query(query('a.ts'))).resolves.toEqual({ kind: 'locations', locations: [], resolvedWorkspaceRoot: '/ws' })
|
||||
await fiber.dispose()
|
||||
await expect(lsp.query(query('a.ts'))).rejects.toThrow(expect.objectContaining({ code: 'LSP_UNAVAILABLE' }))
|
||||
})
|
||||
|
||||
it('LspError carries its structured code', () => {
|
||||
expect(new LspError('m', 'LSP_UNAVAILABLE').code).toBe('LSP_UNAVAILABLE')
|
||||
})
|
||||
|
||||
it('brands a provider id without altering the string', () => {
|
||||
expect(LspProviderId('ts')).toBe('ts')
|
||||
})
|
||||
})
|
||||
27
packages/lsp/lsp/tsconfig.json
Normal file
27
packages/lsp/lsp/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
88
packages/lsp/tool-lsp/README.md
Normal file
88
packages/lsp/tool-lsp/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# @deepseek-ai/dsh-tool-lsp
|
||||
|
||||
The model-facing **`lsp` tool** over `ctx.lsp`: one read-only tool with four operations for precise code navigation. It owns the model schema, prompt guidance, coordinate conversion, result limits and formatting, and ACP presentation; it imports no provider.
|
||||
|
||||
Namespace plugin (`name` / `inject` / `Config` / `apply`, no default export). Injects `tools`, `lsp`, and `systemPrompt`.
|
||||
|
||||
## The tool
|
||||
|
||||
`lsp` accepts `operation` (`goToDefinition` | `findReferences` | `goToImplementation` | `hover`), `file_path`, `line`, and `character`. `line` and `character` are positive, one-based UTF-16 cursor coordinates; the tool converts them to the seam's zero-based positions and converts rendered locations back. `findReferences` includes declarations so impact analysis does not omit the defining site. Provider, language id, workspace root, limits, timeout, initialization, and executable stay outside model input.
|
||||
|
||||
The tool requires the workspace root from the session `header.cwd`, with no fallback: absence fails as `LSP_WORKSPACE_REQUIRED` before querying. Locations render as stable, file-grouped `path:line:character` entries relativized against the result's `resolvedWorkspaceRoot` (the provider's canonical root), not the session cwd — so a symlinked cwd still renders in-workspace results as workspace-relative paths; a `file:` URI becomes a workspace-relative path (inside) or absolute path (outside), and any other URI stays verbatim. Empty locations and `null` hover are successful no-result responses; malformed provider payloads remain structured errors.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `maxLocations` | `100` | Largest number of rendered locations before an omission marker. |
|
||||
| `maxResultChars` | `16000` | Largest complete rendered result, including truncation metadata. |
|
||||
| `timeoutMs` | `60000` | Tool-call timeout budget, enforced by `dsh-timeout-policy`; covers the complete queued open/query/close lifecycle and is not model-configurable. |
|
||||
|
||||
## Model Experience
|
||||
|
||||
### System prompt
|
||||
|
||||
#### What the model sees
|
||||
|
||||
One system-prompt section (order 112) positions LSP as a precision aid with the following text:
|
||||
|
||||
##### Verbatim guidance
|
||||
|
||||
```markdown
|
||||
Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. findReferences always includes the declaration.
|
||||
```
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed guidance cost on every request while the plugin is active.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the plugin scope and guidance text are unchanged; activation or disposal may invalidate reuse from this section.
|
||||
|
||||
### Tool schema
|
||||
|
||||
#### What the model sees
|
||||
|
||||
The model sees the generated [`lsp` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-lsp).
|
||||
|
||||
#### Token effect
|
||||
|
||||
Fixed schema cost on every request while enabled; the `timeoutMs` budget is never sent to the model.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the visible tool definition and order are unchanged; registration lifecycle or scoped restrictions may invalidate reuse from the first changed schema token.
|
||||
|
||||
### Results
|
||||
|
||||
#### What the model sees
|
||||
|
||||
File-grouped `path:line:character` location lines or normalized hover text, capped first by `maxLocations` and then by `maxResultChars`; omission and truncation markers are included inside the complete character cap. Empty results use distinct `No results.` / `No hover information.` lines.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Capped per tool result by `maxResultChars`, with `maxLocations` additionally bounding navigation item count.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Tool results append after the cached request prefix and do not directly invalidate it.
|
||||
|
||||
### ACP presentation
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing. The client renders a generic search card — `{ card: 'generic', kind: 'search', title, locations: [{ path, line }] }` — whose args-derived title carries the operation and one-based cursor; follow-along focuses the queried line while the title preserves the column.
|
||||
|
||||
#### Token effect
|
||||
|
||||
Zero direct token effect because rendering is client-side only.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; ACP presentation is outside the model request.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **UTF-16 cursor coordinates** — columns are exact for the protocol but hard for a model to count around non-BMP characters; an off-symbol position may return empty results, so the prompt explains the convention without encouraging broad LSP use ([seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md)).
|
||||
- **No cross-server completeness promise** — supported servers may return empty or partial results depending on indexing readiness; the tool promises no completeness across languages or servers.
|
||||
54
packages/lsp/tool-lsp/package.json
Normal file
54
packages/lsp/tool-lsp/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-lsp",
|
||||
"description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-lsp": "^0.0.1",
|
||||
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
|
||||
"@deepseek-ai/dsh-timeout": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-lsp": "workspace:^",
|
||||
"@deepseek-ai/dsh-lsp-local": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
145
packages/lsp/tool-lsp/src/index.ts
Normal file
145
packages/lsp/tool-lsp/src/index.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Model-facing `lsp` tool over `ctx.lsp`. One read-only tool with four operations
|
||||
* (`goToDefinition`/`findReferences`/`goToImplementation`/`hover`); it converts one-based UTF-16
|
||||
* cursor coordinates to the seam's zero-based positions, requires the session workspace with no
|
||||
* fallback, caps and renders results, and attaches a configurable timeout budget for
|
||||
* `dsh-timeout-policy` to enforce. It runtime-injects only `tools`, `lsp`, and `systemPrompt` and
|
||||
* imports no provider.
|
||||
*
|
||||
* Namespace plugin (named exports, no default export).
|
||||
* @module @deepseek-ai/dsh-tool-lsp
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import { assertNever, type ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { LspError } from '@deepseek-ai/dsh-lsp'
|
||||
import type {} from '@deepseek-ai/dsh-lsp'
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import {
|
||||
DEFAULT_MAX_LOCATIONS,
|
||||
DEFAULT_MAX_RESULT_CHARS,
|
||||
formatHover,
|
||||
formatLocations,
|
||||
LSP_OPERATIONS,
|
||||
parseLspArgs,
|
||||
presentLspCall,
|
||||
} from './render.ts'
|
||||
import { sessionCwd } from './session-cwd.ts'
|
||||
|
||||
export {
|
||||
DEFAULT_MAX_LOCATIONS,
|
||||
DEFAULT_MAX_RESULT_CHARS,
|
||||
formatHover,
|
||||
formatLocations,
|
||||
LSP_OPERATIONS,
|
||||
parseLspArgs,
|
||||
presentLspCall,
|
||||
renderUri,
|
||||
} from './render.ts'
|
||||
export { sessionCwd } from './session-cwd.ts'
|
||||
|
||||
/** Cordis plugin name for loader diagnostics. */
|
||||
export const name = 'tool-lsp'
|
||||
|
||||
/** Services required by this plugin. */
|
||||
export const inject = ['tools', 'lsp', 'systemPrompt']
|
||||
|
||||
/** Default tool-call timeout budget (ms), covering the queued open/query/close lifecycle. */
|
||||
export const DEFAULT_LSP_TOOL_TIMEOUT_MS = 60_000
|
||||
|
||||
/** The stable system-prompt guidance positioning LSP as a precision aid. */
|
||||
export const LSP_PROMPT_TEXT =
|
||||
'Use search/read for ordinary navigation. Use lsp when textual matches are ambiguous or before a change requires precise definitions, implementations, or references. Positions are one-based line and character (UTF-16) at the cursor; an off-symbol position may return no results. findReferences always includes the declaration.'
|
||||
|
||||
/** Plugin configuration: result caps and the timeout budget. */
|
||||
export interface Config {
|
||||
/** Largest number of rendered locations before an omission marker (default 100). */
|
||||
maxLocations?: number
|
||||
/** Largest complete rendered result in characters, including truncation metadata (default 16000). */
|
||||
maxResultChars?: number
|
||||
/** Tool-call timeout budget in ms (default 60000). */
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
maxLocations: z.number().default(DEFAULT_MAX_LOCATIONS),
|
||||
maxResultChars: z.number().default(DEFAULT_MAX_RESULT_CHARS),
|
||||
timeoutMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_LSP_TOOL_TIMEOUT_MS),
|
||||
})
|
||||
|
||||
type ResolvedConfig = Required<Config>
|
||||
|
||||
/**
|
||||
* Register the `lsp` tool and its system-prompt guidance.
|
||||
* @param ctx - the plugin context (must inject `tools`, `lsp`, `systemPrompt`).
|
||||
* @param config - the resolved plugin configuration.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const resolved = config as ResolvedConfig
|
||||
assertPositiveInteger('maxLocations', resolved.maxLocations)
|
||||
assertPositiveInteger('maxResultChars', resolved.maxResultChars)
|
||||
assertTimer('timeoutMs', resolved.timeoutMs)
|
||||
|
||||
ctx.systemPrompt.section({ name: 'tool:lsp', order: 112, text: LSP_PROMPT_TEXT })
|
||||
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'lsp',
|
||||
description:
|
||||
'Query a language server for precise code navigation. operation is one of goToDefinition, findReferences, goToImplementation, hover. line and character are one-based UTF-16 cursor coordinates. findReferences includes the declaration.',
|
||||
parameters: {
|
||||
operation: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
enum: [...LSP_OPERATIONS],
|
||||
description: 'goToDefinition, findReferences, goToImplementation, or hover.',
|
||||
},
|
||||
file_path: { type: 'string', required: true, description: 'The source file to query, relative to the workspace or absolute.' },
|
||||
line: { type: 'number', required: true, description: 'One-based line of the cursor.' },
|
||||
character: { type: 'number', required: true, description: 'One-based UTF-16 column of the cursor.' },
|
||||
},
|
||||
timeoutMs: resolved.timeoutMs,
|
||||
async execute(args, exec): Promise<ContentBlock[]> {
|
||||
const input = parseLspArgs(args)
|
||||
const workspaceRoot = sessionCwd(exec)
|
||||
if (workspaceRoot === undefined) {
|
||||
throw new LspError('the lsp tool requires a session workspace cwd', 'LSP_WORKSPACE_REQUIRED')
|
||||
}
|
||||
const result = await ctx.lsp.query({
|
||||
operation: input.operation,
|
||||
filePath: input.filePath,
|
||||
position: input.position,
|
||||
workspaceRoot,
|
||||
}, exec.signal)
|
||||
switch (result.kind) {
|
||||
case 'locations':
|
||||
// Relativize against the provider's canonical workspace root (which its file: URIs are
|
||||
// relative to), not the session cwd: a symlinked cwd would otherwise misclassify every
|
||||
// in-workspace location as external and render it as an absolute path.
|
||||
return [{ type: 'text', text: formatLocations(result.locations, result.resolvedWorkspaceRoot, resolved.maxLocations, resolved.maxResultChars) }]
|
||||
case 'hover':
|
||||
return [{ type: 'text', text: formatHover(result.hover, resolved.maxResultChars) }]
|
||||
/* v8 ignore next -- exhaustive over the closed LspQueryResult union; unreachable. */
|
||||
default:
|
||||
return assertNever(result, 'tool-lsp result')
|
||||
}
|
||||
},
|
||||
presentCall: presentLspCall,
|
||||
}))
|
||||
}
|
||||
|
||||
/** Reject a non-positive-integer config value at load, so misconfiguration fails loud. */
|
||||
function assertPositiveInteger(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`tool-lsp: ${name} must be a positive integer`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Reject a timer value Node would clamp instead of scheduling as configured. */
|
||||
function assertTimer(name: string, value: number): void {
|
||||
if (!Number.isInteger(value) || value < 1 || value > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`tool-lsp: ${name} must be a positive integer no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
}
|
||||
30
packages/lsp/tool-lsp/src/invariant.ts
Normal file
30
packages/lsp/tool-lsp/src/invariant.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-lsp`.
|
||||
* @module @deepseek-ai/dsh-tool-lsp/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-lsp'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'tool-lsp-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: this stateless adapter contributes one tool and prompt section, while query
|
||||
* lifecycle and result relations remain owned by the tool and LSP seams it composes.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
168
packages/lsp/tool-lsp/src/render.ts
Normal file
168
packages/lsp/tool-lsp/src/render.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Pure formatting and coordinate conversion for the `lsp` tool: one-based↔zero-based UTF-16 cursor
|
||||
* conversion, workspace-grouped location rendering with `file:`-URI resolution, complete-result
|
||||
* capping, and ACP presentation. No I/O — a UI may call the presenter on live streaming and on
|
||||
* replay, so it depends only on the tool arguments.
|
||||
* @module @deepseek-ai/dsh-tool-lsp/render
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { isAbsolute, relative, sep } from 'node:path'
|
||||
import type { GenericCallView } from '@deepseek-ai/dsh-tools'
|
||||
import type { LspHover, LspLocation, LspOperation, LspPosition } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
/** The four operations the tool exposes, as a runtime tuple for schema enum + validation. */
|
||||
export const LSP_OPERATIONS: readonly LspOperation[] = ['goToDefinition', 'findReferences', 'goToImplementation', 'hover']
|
||||
|
||||
/** Default cap on rendered locations before an omission marker is appended. */
|
||||
export const DEFAULT_MAX_LOCATIONS = 100
|
||||
|
||||
/** Default cap on the complete rendered tool result, including truncation metadata. */
|
||||
export const DEFAULT_MAX_RESULT_CHARS = 16_000
|
||||
|
||||
/** Validated `lsp` arguments after coordinate checks. */
|
||||
export interface LspToolInput {
|
||||
readonly operation: LspOperation
|
||||
readonly filePath: string
|
||||
/** Zero-based UTF-16 position converted from the one-based model coordinates. */
|
||||
readonly position: LspPosition
|
||||
}
|
||||
|
||||
/** The raw, schema-typed argument shape. */
|
||||
export interface LspToolArgs {
|
||||
readonly operation: string
|
||||
readonly file_path: string
|
||||
readonly line: number
|
||||
readonly character: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and convert model arguments: `operation` must be one of the four; `line`/`character` are
|
||||
* positive one-based integers converted to the seam's zero-based position.
|
||||
* @param args - the schema-validated raw arguments.
|
||||
* @returns the validated input with a zero-based position.
|
||||
* @throws Error when the operation is unknown or a coordinate is not a positive integer.
|
||||
*/
|
||||
export function parseLspArgs(args: LspToolArgs): LspToolInput {
|
||||
if (!isOperation(args.operation)) {
|
||||
throw new Error(`operation must be one of ${LSP_OPERATIONS.join(', ')}`)
|
||||
}
|
||||
if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string')
|
||||
const line = oneBased(args.line, 'line')
|
||||
const character = oneBased(args.character, 'character')
|
||||
return {
|
||||
operation: args.operation,
|
||||
filePath: args.file_path,
|
||||
// The model counts from 1; the seam (and protocol) count from 0.
|
||||
position: { line: line - 1, character: character - 1 },
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a string is one of the four operations. */
|
||||
function isOperation(value: string): value is LspOperation {
|
||||
return (LSP_OPERATIONS as readonly string[]).includes(value)
|
||||
}
|
||||
|
||||
/** Validate a one-based coordinate is a positive integer. */
|
||||
function oneBased(value: number, name: string): number {
|
||||
if (!Number.isInteger(value) || value < 1) {
|
||||
throw new Error(`${name} must be a positive integer (one-based)`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a locations result grouped by file, converting each zero-based location back to a one-based
|
||||
* `path:line:character` entry. A `file:` URI inside the workspace becomes a workspace-relative path;
|
||||
* outside it, an absolute path; a non-`file:` URI is kept verbatim. Applies `maxLocations` and
|
||||
* appends an omission marker when it truncates by count, then applies the complete result cap.
|
||||
* @param locations - the seam's locations (possibly empty).
|
||||
* @param workspaceRoot - the canonical workspace root for relativizing `file:` paths.
|
||||
* @param maxLocations - the cap before truncation.
|
||||
* @param maxResultChars - the complete rendered-text cap, including truncation metadata.
|
||||
* @returns the rendered text; a distinct no-result line when there are none.
|
||||
*/
|
||||
export function formatLocations(
|
||||
locations: readonly LspLocation[],
|
||||
workspaceRoot: string,
|
||||
maxLocations: number,
|
||||
maxResultChars: number,
|
||||
): string {
|
||||
if (locations.length === 0) return boundResult('No results.', maxResultChars, 'locations')
|
||||
const shown = locations.slice(0, maxLocations)
|
||||
const omitted = locations.length - shown.length
|
||||
const grouped = new Map<string, string[]>()
|
||||
for (const location of shown) {
|
||||
const path = renderUri(location.uri, workspaceRoot)
|
||||
const line = location.range.start.line + 1
|
||||
const character = location.range.start.character + 1
|
||||
const entries = grouped.get(path) ?? []
|
||||
entries.push(`${path}:${line}:${character}`)
|
||||
grouped.set(path, entries)
|
||||
}
|
||||
const lines: string[] = []
|
||||
for (const entries of grouped.values()) lines.push(...entries)
|
||||
if (omitted > 0) {
|
||||
lines.push(`… ${omitted} more location${omitted === 1 ? '' : 's'} omitted (limit ${maxLocations}).`)
|
||||
}
|
||||
return boundResult(lines.join('\n'), maxResultChars, 'locations')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a hover result, applying `maxResultChars` last and keeping its marker within the cap.
|
||||
* @param hover - the normalized hover, or `null` for no hover.
|
||||
* @param maxResultChars - the complete rendered-text cap, including truncation metadata.
|
||||
* @returns the rendered hover text; a distinct no-result line for `null`.
|
||||
*/
|
||||
export function formatHover(hover: LspHover | null, maxResultChars: number): string {
|
||||
const text = hover === null ? 'No hover information.' : hover.contents
|
||||
return boundResult(text, maxResultChars, 'hover')
|
||||
}
|
||||
|
||||
/** Bound a complete rendered result, including the truncation notice itself. */
|
||||
function boundResult(text: string, maxChars: number, label: string): string {
|
||||
if (text.length <= maxChars) return text
|
||||
const notice = `\n… ${label} truncated (limit ${maxChars} characters).`
|
||||
if (notice.length >= maxChars) return notice.slice(0, maxChars)
|
||||
return `${text.slice(0, maxChars - notice.length)}${notice}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a location URI to a display path. A `file:` URI accepted by Node becomes workspace-relative
|
||||
* (inside) or absolute (outside); any other URI is returned verbatim.
|
||||
* @param uri - the target URI from the seam.
|
||||
* @param workspaceRoot - the canonical workspace root.
|
||||
* @returns the display path or the verbatim URI.
|
||||
*/
|
||||
export function renderUri(uri: string, workspaceRoot: string): string {
|
||||
if (!uri.startsWith('file:')) return uri
|
||||
let absolute: string
|
||||
try {
|
||||
absolute = fileURLToPath(uri)
|
||||
} catch {
|
||||
// A malformed file: URI is not a path we can resolve; show it verbatim.
|
||||
return uri
|
||||
}
|
||||
const rel = relative(workspaceRoot, absolute)
|
||||
if (rel === '') return '.'
|
||||
// A leading `..` SEGMENT (or an absolute rel) means outside the workspace; guard against a false
|
||||
// positive on an in-workspace path whose first component merely starts with dots (e.g. `..gen/x`).
|
||||
const outside = rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)
|
||||
return outside ? absolute : rel.split(sep).join('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP presentation for a pending `lsp` call. Uses a generic search card; the title carries the
|
||||
* operation and one-based cursor, and `locations` focuses the queried line (ACP `FileLocation` has
|
||||
* no character, so the title preserves the column).
|
||||
* @param args - the raw tool arguments.
|
||||
* @returns the generic call view.
|
||||
*/
|
||||
export function presentLspCall(args: LspToolArgs): GenericCallView {
|
||||
return {
|
||||
card: 'generic',
|
||||
kind: 'search',
|
||||
title: `LSP ${args.operation} ${args.file_path}:${args.line}:${args.character}`,
|
||||
locations: [{ path: args.file_path, line: args.line }],
|
||||
}
|
||||
}
|
||||
19
packages/lsp/tool-lsp/src/session-cwd.ts
Normal file
19
packages/lsp/tool-lsp/src/session-cwd.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Derive the workspace root an `lsp` call resolves against: the calling agent's per-session
|
||||
* workspace (`exec.agent.session.header.cwd`), mirroring how the filesystem tools resolve paths.
|
||||
* Unlike those tools, LSP has NO provider fallback — a missing cwd fails the call as
|
||||
* `LSP_WORKSPACE_REQUIRED`, because the local provider must canonicalize a real workspace before it
|
||||
* can start a server.
|
||||
* @module @deepseek-ai/dsh-tool-lsp/session-cwd
|
||||
*/
|
||||
|
||||
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
|
||||
|
||||
/**
|
||||
* The session workspace cwd for this call, or `undefined` when none applies.
|
||||
* @param exec - the tool-execution context; only its optional `agent` is read.
|
||||
* @returns the calling agent's session cwd, or undefined for a non-agent caller.
|
||||
*/
|
||||
export function sessionCwd(exec: ToolExecution): string | undefined {
|
||||
return exec.agent?.session.header.cwd
|
||||
}
|
||||
94
packages/lsp/tool-lsp/tests/integration.spec.ts
Normal file
94
packages/lsp/tool-lsp/tests/integration.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import Lsp from '@deepseek-ai/dsh-lsp'
|
||||
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
|
||||
import * as TimeoutPolicy from '@deepseek-ai/dsh-timeout-policy'
|
||||
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
|
||||
|
||||
/**
|
||||
* Focused in-process integration of the model-facing tool, seam, local provider, and timeout policy.
|
||||
* The `lsp-definition` ACP snapshot owns the shipped Loader/app entry path.
|
||||
*/
|
||||
|
||||
let root: string
|
||||
let ws: string
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await realpath(await mkdtemp(join(tmpdir(), 'lsp-tool-int-')))
|
||||
ws = join(root, 'ws')
|
||||
await mkdir(ws)
|
||||
await writeFile(join(ws, 'a.ts'), 'const x = 1\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
/** An inline stdio server that answers initialize + definition; `hang` makes textDocument/* stall. */
|
||||
function serverScript(hang: boolean): string {
|
||||
const definition = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } })
|
||||
return 'let b=Buffer.alloc(0);'
|
||||
+ `const DEF=${definition};`
|
||||
+ 'const fr=(o)=>{const x=Buffer.from(JSON.stringify({jsonrpc:"2.0",...o}));return Buffer.concat([Buffer.from(`Content-Length: ${x.length}\\r\\n\\r\\n`),x]);};'
|
||||
+ 'process.stdin.on("data",c=>{b=Buffer.concat([b,c]);for(;;){const s=b.indexOf("\\r\\n\\r\\n");if(s<0)break;const len=Number(/(\\d+)/.exec(b.toString("ascii",0,s))[1]);if(b.length<s+4+len)break;const m=JSON.parse(b.toString("utf8",s+4,s+4+len));b=b.subarray(s+4+len);'
|
||||
+ 'if(m.method==="initialize")process.stdout.write(fr({id:m.id,result:{capabilities:{positionEncoding:"utf-16",textDocumentSync:1,definitionProvider:true}}}));'
|
||||
+ `else if(m.method==="textDocument/definition"){${hang ? '' : 'process.stdout.write(fr({id:m.id,result:DEF}));'}}`
|
||||
+ 'else if(m.method==="shutdown")process.stdout.write(fr({id:m.id,result:null}));'
|
||||
+ 'else if(m.method==="exit")process.exit(0);'
|
||||
+ '}});'
|
||||
}
|
||||
|
||||
async function mount(hang: boolean, timeoutMs?: number): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(Lsp)
|
||||
await ctx.plugin(LspLocal, {
|
||||
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 } : {})
|
||||
return ctx
|
||||
}
|
||||
|
||||
let seq = 0
|
||||
function call(ctx: Context, args: unknown) {
|
||||
return ctx.tools.execute({
|
||||
callId: `int-${++seq}` as never,
|
||||
name: 'lsp',
|
||||
arguments: args,
|
||||
agent: { session: { header: { cwd: ws } } } as never,
|
||||
})
|
||||
}
|
||||
|
||||
describe('tool-lsp integration', () => {
|
||||
it('round-trips a definition query through the real provider and renders a location', async () => {
|
||||
const ctx = await mount(false)
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 })
|
||||
expect(result.isError).toBe(false)
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
|
||||
await ctx.fiber.dispose()
|
||||
}, 30_000)
|
||||
|
||||
it('enforces the TOOL_TIMEOUT budget when the server hangs', async () => {
|
||||
const ctx = await mount(true, 300)
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 7 })
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('TOOL_TIMEOUT')
|
||||
await ctx.fiber.dispose()
|
||||
}, 30_000)
|
||||
})
|
||||
24
packages/lsp/tool-lsp/tests/load-path.spec.ts
Normal file
24
packages/lsp/tool-lsp/tests/load-path.spec.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Loader export-shape guard for @deepseek-ai/dsh-tool-lsp. It is a NAMESPACE plugin with `inject`, so a
|
||||
* stray `export default apply` would make the Loader's `unwrapExports` collapse the module to the
|
||||
* bare `apply`, dropping `inject` (postmortem 0001). This verifies the namespace survives
|
||||
* `Loader.prototype.unwrapExports`; the `lsp-definition` ACP snapshot owns full app composition.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import * as toolLsp from '@deepseek-ai/dsh-tool-lsp'
|
||||
|
||||
describe('dsh-tool-lsp Loader export-shape guard', () => {
|
||||
it('has no default export and keeps name/inject/Config through unwrapExports', () => {
|
||||
expect('default' in toolLsp).toBe(false)
|
||||
|
||||
const loader = Object.create(Loader.prototype) as Loader
|
||||
const unwrapped = loader.unwrapExports(toolLsp) as Record<string, unknown>
|
||||
expect(unwrapped).toBe(toolLsp)
|
||||
expect(unwrapped.name).toBe('tool-lsp')
|
||||
expect(unwrapped.inject).toEqual(['tools', 'lsp', 'systemPrompt'])
|
||||
expect(typeof unwrapped.apply).toBe('function')
|
||||
expect(unwrapped.Config).toBeDefined()
|
||||
})
|
||||
})
|
||||
142
packages/lsp/tool-lsp/tests/render.spec.ts
Normal file
142
packages/lsp/tool-lsp/tests/render.spec.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
DEFAULT_MAX_LOCATIONS,
|
||||
DEFAULT_MAX_RESULT_CHARS,
|
||||
formatHover,
|
||||
formatLocations,
|
||||
LSP_OPERATIONS,
|
||||
parseLspArgs,
|
||||
presentLspCall,
|
||||
renderUri,
|
||||
} from '@deepseek-ai/dsh-tool-lsp'
|
||||
import type { LspLocation } from '@deepseek-ai/dsh-lsp'
|
||||
|
||||
const WS = '/home/u/proj'
|
||||
|
||||
function loc(uri: string, line: number, character = 0): LspLocation {
|
||||
return { uri, range: { start: { line, character }, end: { line, character: character + 1 } } }
|
||||
}
|
||||
|
||||
describe('parseLspArgs', () => {
|
||||
it('accepts the four operations and converts one-based to zero-based', () => {
|
||||
for (const operation of LSP_OPERATIONS) {
|
||||
const input = parseLspArgs({ operation, file_path: 'a.ts', line: 3, character: 5 })
|
||||
expect(input.operation).toBe(operation)
|
||||
expect(input.position).toEqual({ line: 2, character: 4 })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects an unknown operation', () => {
|
||||
expect(() => parseLspArgs({ operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }))
|
||||
.toThrow(/operation must be one of/)
|
||||
})
|
||||
|
||||
it('rejects a blank file_path', () => {
|
||||
expect(() => parseLspArgs({ operation: 'hover', file_path: ' ', line: 1, character: 1 }))
|
||||
.toThrow(/file_path/)
|
||||
})
|
||||
|
||||
it('rejects non-positive or non-integer coordinates', () => {
|
||||
expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 0, character: 1 })).toThrow(/line/)
|
||||
expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1, character: 0 })).toThrow(/character/)
|
||||
expect(() => parseLspArgs({ operation: 'hover', file_path: 'a.ts', line: 1.5, character: 1 })).toThrow(/line/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderUri', () => {
|
||||
it('relativizes a file: URI inside the workspace with forward slashes', () => {
|
||||
const uri = pathToFileURL(join(WS, 'src', 'a.ts')).href
|
||||
expect(renderUri(uri, WS)).toBe('src/a.ts')
|
||||
})
|
||||
|
||||
it('returns an absolute path for a file: URI outside the workspace', () => {
|
||||
const uri = pathToFileURL('/other/lib/b.ts').href
|
||||
expect(renderUri(uri, WS)).toBe('/other/lib/b.ts')
|
||||
})
|
||||
|
||||
it('renders the workspace root itself as "."', () => {
|
||||
expect(renderUri(pathToFileURL(WS).href, WS)).toBe('.')
|
||||
})
|
||||
|
||||
it('keeps an in-workspace path whose first segment starts with dots relative', () => {
|
||||
// `..generated` is a real in-workspace dir, not a parent escape; only a `..` segment is external.
|
||||
const uri = pathToFileURL(join(WS, '..generated', 'a.ts')).href
|
||||
expect(renderUri(uri, WS)).toBe('..generated/a.ts')
|
||||
})
|
||||
|
||||
it('keeps a non-file URI verbatim', () => {
|
||||
expect(renderUri('untitled:Untitled-1', WS)).toBe('untitled:Untitled-1')
|
||||
expect(renderUri('jdt://contents/Foo.class', WS)).toBe('jdt://contents/Foo.class')
|
||||
})
|
||||
|
||||
it('keeps a malformed file: URI verbatim when it cannot be parsed to a path', () => {
|
||||
// A file: URI with a host that fileURLToPath rejects falls through to the verbatim path.
|
||||
expect(renderUri('file://host/notlocal', WS)).toBe('file://host/notlocal')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatLocations', () => {
|
||||
it('renders a no-result line for an empty list', () => {
|
||||
expect(formatLocations([], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)).toBe('No results.')
|
||||
})
|
||||
|
||||
it('renders one-based path:line:character grouped by file', () => {
|
||||
const a = pathToFileURL(join(WS, 'a.ts')).href
|
||||
const text = formatLocations([loc(a, 0, 0), loc(a, 4, 2)], WS, DEFAULT_MAX_LOCATIONS, DEFAULT_MAX_RESULT_CHARS)
|
||||
expect(text).toBe('a.ts:1:1\na.ts:5:3')
|
||||
})
|
||||
|
||||
it('caps at maxLocations and marks the omission', () => {
|
||||
const a = pathToFileURL(join(WS, 'a.ts')).href
|
||||
const many = Array.from({ length: 5 }, (_, i) => loc(a, i))
|
||||
const text = formatLocations(many, WS, 2, DEFAULT_MAX_RESULT_CHARS)
|
||||
expect(text).toContain('a.ts:1:1')
|
||||
expect(text).toContain('3 more locations omitted (limit 2).')
|
||||
})
|
||||
|
||||
it('uses the singular omission marker for exactly one extra', () => {
|
||||
const a = pathToFileURL(join(WS, 'a.ts')).href
|
||||
const text = formatLocations([loc(a, 0), loc(a, 1)], WS, 1, DEFAULT_MAX_RESULT_CHARS)
|
||||
expect(text).toContain('1 more location omitted (limit 1).')
|
||||
})
|
||||
|
||||
it('caps the complete location text even when one URI is enormous', () => {
|
||||
const maxResultChars = 80
|
||||
const text = formatLocations([loc(`custom:${'x'.repeat(1_000_000)}`, 0)], WS, 1, maxResultChars)
|
||||
expect(text).toHaveLength(maxResultChars)
|
||||
expect(text).toContain('locations truncated')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatHover', () => {
|
||||
it('renders a no-result line for null', () => {
|
||||
expect(formatHover(null, DEFAULT_MAX_RESULT_CHARS)).toBe('No hover information.')
|
||||
})
|
||||
|
||||
it('returns short hover verbatim', () => {
|
||||
expect(formatHover({ contents: '```ts\nx: number\n```' }, DEFAULT_MAX_RESULT_CHARS)).toBe('```ts\nx: number\n```')
|
||||
})
|
||||
|
||||
it('caps the complete hover text including its truncation marker', () => {
|
||||
const text = formatHover({ contents: 'a'.repeat(100) }, 60)
|
||||
expect(text).toHaveLength(60)
|
||||
expect(text).toContain('hover truncated (limit 60 characters).')
|
||||
})
|
||||
|
||||
it('still honors a cap smaller than the truncation marker', () => {
|
||||
expect(formatHover({ contents: 'a'.repeat(100) }, 10)).toHaveLength(10)
|
||||
})
|
||||
})
|
||||
|
||||
describe('presentLspCall', () => {
|
||||
it('is a generic search card with an operation/cursor title and a line location', () => {
|
||||
expect(presentLspCall({ operation: 'findReferences', file_path: 'a.ts', line: 3, character: 7 })).toEqual({
|
||||
card: 'generic',
|
||||
kind: 'search',
|
||||
title: 'LSP findReferences a.ts:3:7',
|
||||
locations: [{ path: 'a.ts', line: 3 }],
|
||||
})
|
||||
})
|
||||
})
|
||||
193
packages/lsp/tool-lsp/tests/tool-lsp.spec.ts
Normal file
193
packages/lsp/tool-lsp/tests/tool-lsp.spec.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import Lsp, { LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
|
||||
import * as ToolLsp from '@deepseek-ai/dsh-tool-lsp'
|
||||
import { DEFAULT_LSP_TOOL_TIMEOUT_MS, LSP_PROMPT_TEXT } from '@deepseek-ai/dsh-tool-lsp'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
|
||||
/** A scripted provider recording queries; `respond` yields the result or throws. */
|
||||
function stubProvider(
|
||||
respond: (request: LspProviderQuery) => LspQueryResult,
|
||||
extensionToLanguage: Record<string, string> = { '.ts': 'typescript' },
|
||||
): LspProvider & { seen: LspProviderQuery[] } {
|
||||
const seen: LspProviderQuery[] = []
|
||||
return {
|
||||
id: LspProviderId('stub'),
|
||||
extensionToLanguage,
|
||||
seen,
|
||||
query(request) {
|
||||
seen.push(request)
|
||||
return Promise.resolve(respond(request))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Mount the real tool stack over a real seam plus one stub provider. */
|
||||
async function mount(
|
||||
provider?: LspProvider,
|
||||
config: ToolLsp.Config = {},
|
||||
): Promise<{ ctx: Context }> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(Lsp)
|
||||
if (provider) (ctx.lsp as Lsp).registerProvider(provider)
|
||||
await ctx.plugin(ToolLsp, config)
|
||||
return { ctx }
|
||||
}
|
||||
|
||||
let seq = 0
|
||||
/** `cwd: null` means "no agent" (tests LSP_WORKSPACE_REQUIRED); a string is the session cwd. */
|
||||
function call(ctx: Context, args: unknown, cwd: string | null = '/ws') {
|
||||
return ctx.tools.execute({
|
||||
callId: `c-${++seq}` as never,
|
||||
name: 'lsp',
|
||||
arguments: args,
|
||||
...cwd !== null ? { agent: { session: { header: { cwd } } } as never } : {},
|
||||
})
|
||||
}
|
||||
|
||||
const okLocations: LspQueryResult = {
|
||||
kind: 'locations',
|
||||
locations: [{ uri: 'file:///ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: '/ws',
|
||||
}
|
||||
|
||||
describe('tool-lsp registration', () => {
|
||||
it('registers the lsp tool and its prompt section', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
expect(ctx.tools.get('lsp')).toBeDefined()
|
||||
const prompt = await ctx.systemPrompt.assemble()
|
||||
const text = prompt.sections.map(s => s.text).join('\n')
|
||||
expect(text).toContain(LSP_PROMPT_TEXT)
|
||||
})
|
||||
|
||||
it('attaches the default timeout budget to the tool definition', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
expect(ctx.tools.get('lsp')?.timeoutMs).toBe(DEFAULT_LSP_TOOL_TIMEOUT_MS)
|
||||
})
|
||||
|
||||
it('honors a configured timeout override', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations), { timeoutMs: 5000 })
|
||||
expect(ctx.tools.get('lsp')?.timeoutMs).toBe(5000)
|
||||
})
|
||||
|
||||
it('exposes exactly the four operations in the schema enum', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const schema = ctx.tools.get('lsp')?.parameters as { properties: { operation: { enum: string[] } } }
|
||||
expect(schema.properties.operation.enum).toEqual(['goToDefinition', 'findReferences', 'goToImplementation', 'hover'])
|
||||
})
|
||||
|
||||
it('has no default export (namespace plugin shape)', () => {
|
||||
expect((ToolLsp as { default?: unknown }).default).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects a non-positive config value at load', async () => {
|
||||
await expect(mount(stubProvider(() => okLocations), { maxLocations: 0 })).rejects.toThrow(/maxLocations/)
|
||||
})
|
||||
|
||||
it('rejects a timeout above Node timer range at load', async () => {
|
||||
await expect(mount(stubProvider(() => okLocations), { timeoutMs: MAX_TIMER_DELAY_MS + 1 }))
|
||||
.rejects.toThrow(/timeoutMs/)
|
||||
expect(() => {
|
||||
ToolLsp.apply(new Context(), {
|
||||
maxLocations: 100,
|
||||
maxResultChars: 16_000,
|
||||
timeoutMs: MAX_TIMER_DELAY_MS + 1,
|
||||
})
|
||||
}).toThrow(/timeoutMs/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('tool-lsp execution', () => {
|
||||
it('converts one-based coordinates and passes the session cwd as workspaceRoot', async () => {
|
||||
const provider = stubProvider(() => okLocations)
|
||||
const { ctx } = await mount(provider)
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 3, character: 5 }, '/ws')
|
||||
expect(result.isError).toBe(false)
|
||||
expect(provider.seen[0]).toMatchObject({
|
||||
operation: 'goToDefinition',
|
||||
filePath: 'a.ts',
|
||||
position: { line: 2, character: 4 },
|
||||
workspaceRoot: '/ws',
|
||||
})
|
||||
})
|
||||
|
||||
it('renders locations relative to the workspace', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const result = await call(ctx, { operation: 'findReferences', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
|
||||
})
|
||||
|
||||
it('relativizes against the provider resolvedWorkspaceRoot, not the session cwd', async () => {
|
||||
// A symlinked session cwd (`/alias`) resolves to a real path (`/real/ws`) that the provider's
|
||||
// location URIs are under. Relativizing against the alias would misclassify the location as
|
||||
// external and print an absolute path; the tool must use resolvedWorkspaceRoot.
|
||||
const provider = stubProvider(() => ({
|
||||
kind: 'locations',
|
||||
locations: [{ uri: 'file:///real/ws/a.ts', range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } } }],
|
||||
resolvedWorkspaceRoot: '/real/ws',
|
||||
}))
|
||||
const { ctx } = await mount(provider)
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/alias')
|
||||
expect(provider.seen[0]).toMatchObject({ workspaceRoot: '/alias' })
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'a.ts:1:1' })
|
||||
})
|
||||
|
||||
it('renders hover content', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => ({ kind: 'hover', hover: { contents: 'number' } })))
|
||||
const result = await call(ctx, { operation: 'hover', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
expect(result.content[0]).toEqual({ type: 'text', text: 'number' })
|
||||
})
|
||||
|
||||
it('fails LSP_WORKSPACE_REQUIRED without a session cwd', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, null)
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('LSP_WORKSPACE_REQUIRED')
|
||||
})
|
||||
|
||||
it('surfaces a structured LSP_UNAVAILABLE when no provider handles the file', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations, { '.py': 'python' }))
|
||||
const result = await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('LSP_UNAVAILABLE')
|
||||
})
|
||||
|
||||
it('returns a structured INVALID_ARGS on a bad operation', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const result = await call(ctx, { operation: 'rename', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
expect(result.isError).toBe(true)
|
||||
expect(result.error?.code).toBe('INVALID_ARGS')
|
||||
})
|
||||
|
||||
it('forwards exec.signal to the seam query', async () => {
|
||||
const seen: (AbortSignal | undefined)[] = []
|
||||
const provider: LspProvider = {
|
||||
id: LspProviderId('sig'),
|
||||
extensionToLanguage: { '.ts': 'typescript' },
|
||||
query(_request, signal) {
|
||||
seen.push(signal)
|
||||
return Promise.resolve(okLocations)
|
||||
},
|
||||
}
|
||||
const { ctx } = await mount(provider)
|
||||
await call(ctx, { operation: 'goToDefinition', file_path: 'a.ts', line: 1, character: 1 }, '/ws')
|
||||
// The timeout policy is not mounted here, so the signal is whatever the registry passes (may be
|
||||
// undefined); the point is the tool threads it through without throwing.
|
||||
expect(seen).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('presentCall renders the pending card from args', async () => {
|
||||
const { ctx } = await mount(stubProvider(() => okLocations))
|
||||
const view = ctx.tools.get('lsp')?.presentCall?.({ operation: 'hover', file_path: 'a.ts', line: 2, character: 3 })
|
||||
expect(view).toEqual({
|
||||
card: 'generic',
|
||||
kind: 'search',
|
||||
title: 'LSP hover a.ts:2:3',
|
||||
locations: [{ path: 'a.ts', line: 2 }],
|
||||
})
|
||||
})
|
||||
})
|
||||
39
packages/lsp/tool-lsp/tsconfig.json
Normal file
39
packages/lsp/tool-lsp/tsconfig.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/tools"
|
||||
},
|
||||
{
|
||||
"path": "../../core/system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
},
|
||||
{
|
||||
"path": "../lsp"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user