/** * 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_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 } 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 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() 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': { // LSP_FAKE_ECHO_ENV names a variable whose VALUE becomes the hover // contents — a test can assert exactly what env reached this process. const echoName = process.env.LSP_FAKE_ECHO_ENV if (echoName !== undefined) return { contents: process.env[echoName] ?? `<${echoName} unset>` } 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), }, }, }) 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() return } if (method === 'textDocument/didClose') return if (method?.startsWith('textDocument/')) { if (hang) return const reply = (): void => { 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): 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) { setInterval(() => {}, 1000) }