Implements the LSP capability seam RFC as three packages: dsh-lsp (the ctx.lsp interface — provider registry by branded id + exclusive extension mapping, per-query order-independent selection, closed request/result vocabulary, LspError taxonomy), dsh-lsp-local (a generic stdio language-server provider — Content-Length JSON-RPC framing, per-(provider, workspace) process single-flight, transient didOpen/query/didClose, an abortable per-instance queue, UTF-16 negotiation, host-namespace source reads outside ctx.fs, and bounded shutdown/kill teardown), and dsh-tool-lsp (the model-facing lsp tool — four operations, one-based UTF-16 cursor conversion, workspace-grouped location rendering, hover capping, a required session workspace, and a timeout budget). Why: an agent had text search and file reads but no way to identify a program symbol — follow an alias, connect an interface to implementations, or read an inferred type — before changing code. Splitting model contract, seam, and local subprocess behavior keeps the four semantic queries stable across future remote or sandbox-native providers without leaking a JSON-RPC escape hatch.
145 lines
5.9 KiB
TypeScript
145 lines
5.9 KiB
TypeScript
/**
|
|
* 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_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 --import tsx fixture-server.ts
|
|
*/
|
|
|
|
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 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>()
|
|
|
|
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') {
|
|
process.exit(0)
|
|
}
|
|
if (method === 'textDocument/didOpen') {
|
|
if (crashOnOpen) process.exit(1)
|
|
if (onOpen !== undefined) emitServerRequest(onOpen)
|
|
return
|
|
}
|
|
if (method === 'textDocument/didClose' || method === 'initialized') return
|
|
if (method?.startsWith('textDocument/')) {
|
|
if (hang) return
|
|
if (errorReply) { send({ id, error: { code: -32000, message: 'server refused the request' } }); return }
|
|
send({ id, result: resultFor(method) })
|
|
return
|
|
}
|
|
// Unknown request with an id: answer null so the client never stalls.
|
|
if (id !== undefined) send({ id, result: null })
|
|
}
|
|
|
|
/** 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()
|