Merge latest master into invariant service seam
This commit is contained in:
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')
|
||||
}
|
||||
Reference in New Issue
Block a user