feat: support pull diagnostics in the LSP client (C#, Kotlin, etc) (#23771)

This commit is contained in:
Luke Parker
2026-04-23 00:25:40 -04:00
committed by Aiden Cline
parent 58db41b4b9
commit e383df4b17
12 changed files with 1123 additions and 121 deletions
@@ -1,7 +1,23 @@
// Simple JSON-RPC 2.0 LSP-like fake server over stdio
// Implements a minimal LSP handshake and triggers a request upon notification
let nextId = 1
let readBuffer = Buffer.alloc(0)
let lastChange = null
let initializeParams = null
let diagnosticRequestCount = 0
let registeredCapability = false
const pendingClientRequests = new Map()
let pullConfig = {
delayMs: 0,
registerOn: undefined,
registrations: [],
documentDiagnostics: [],
documentDiagnosticsByIdentifier: {},
documentDelayMsByIdentifier: {},
workspaceDiagnostics: [],
workspaceDiagnosticsByIdentifier: {},
workspaceDelayMsByIdentifier: {},
}
function encode(message) {
const json = JSON.stringify(message)
@@ -14,29 +30,19 @@ function decodeFrames(buffer) {
let idx
while ((idx = buffer.indexOf("\r\n\r\n")) !== -1) {
const header = buffer.slice(0, idx).toString("utf8")
const m = /Content-Length:\s*(\d+)/i.exec(header)
const len = m ? parseInt(m[1], 10) : 0
const match = /Content-Length:\s*(\d+)/i.exec(header)
const length = match ? parseInt(match[1], 10) : 0
const bodyStart = idx + 4
const bodyEnd = bodyStart + len
const bodyEnd = bodyStart + length
if (buffer.length < bodyEnd) break
const body = buffer.slice(bodyStart, bodyEnd).toString("utf8")
results.push(body)
results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8"))
buffer = buffer.slice(bodyEnd)
}
return { messages: results, rest: buffer }
}
let readBuffer = Buffer.alloc(0)
process.stdin.on("data", (chunk) => {
readBuffer = Buffer.concat([readBuffer, chunk])
const { messages, rest } = decodeFrames(readBuffer)
readBuffer = rest
for (const m of messages) handle(m)
})
function send(msg) {
process.stdout.write(encode(msg))
function send(message) {
process.stdout.write(encode(message))
}
function sendRequest(method, params) {
@@ -45,6 +51,50 @@ function sendRequest(method, params) {
return id
}
function sendResponse(id, result) {
send({ jsonrpc: "2.0", id, result })
}
function sendNotification(method, params) {
send({ jsonrpc: "2.0", method, params })
}
function maybeRegister(method) {
if (pullConfig.registerOn !== method || registeredCapability) return
registeredCapability = true
sendRequest("client/registerCapability", {
registrations: pullConfig.registrations.map((registration, index) => ({
id: registration.id ?? `pull-${index}`,
method: registration.method ?? "textDocument/diagnostic",
registerOptions: registration.registerOptions ?? registration,
})),
})
}
function delayed(id, result, delayMs = pullConfig.delayMs) {
if (!delayMs) {
sendResponse(id, result)
return
}
setTimeout(() => sendResponse(id, result), delayMs)
}
function diagnosticsForIdentifier(identifier) {
return pullConfig.documentDiagnosticsByIdentifier[identifier] ?? pullConfig.documentDiagnostics
}
function workspaceDiagnosticsForIdentifier(identifier) {
return pullConfig.workspaceDiagnosticsByIdentifier[identifier] ?? pullConfig.workspaceDiagnostics
}
function documentDelayForIdentifier(identifier) {
return pullConfig.documentDelayMsByIdentifier[identifier] ?? pullConfig.delayMs
}
function workspaceDelayForIdentifier(identifier) {
return pullConfig.workspaceDelayMsByIdentifier[identifier] ?? pullConfig.delayMs
}
function handle(raw) {
let data
try {
@@ -52,24 +102,148 @@ function handle(raw) {
} catch {
return
}
if (typeof data.method === "undefined" && typeof data.id !== "undefined") {
const pending = pendingClientRequests.get(data.id)
if (!pending) return
pendingClientRequests.delete(data.id)
sendResponse(pending, data.result ?? null)
return
}
if (data.method === "initialize") {
send({ jsonrpc: "2.0", id: data.id, result: { capabilities: {} } })
initializeParams = data.params
sendResponse(data.id, {
capabilities: {
textDocumentSync: {
change: 2,
},
},
})
return
}
if (data.method === "initialized") {
if (data.method === "test/get-initialize-params") {
sendResponse(data.id, initializeParams)
return
}
if (data.method === "workspace/didChangeConfiguration") {
if (data.method === "test/request-configuration") {
const id = sendRequest("workspace/configuration", data.params)
pendingClientRequests.set(id, data.id)
return
}
if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") {
return
}
if (data.method === "textDocument/didOpen") {
maybeRegister("didOpen")
return
}
if (data.method === "textDocument/didChange") {
lastChange = data.params
maybeRegister("didChange")
return
}
if (data.method === "test/trigger") {
const method = data.params && data.params.method
if (method === "client/registerCapability") {
sendRequest(method, {
registrations: [
{
id: "test-diagnostic-registration",
method: "textDocument/diagnostic",
registerOptions: { identifier: "syntax" },
},
],
})
return
}
if (method === "client/unregisterCapability") {
sendRequest(method, {
unregisterations: [{ id: "test-diagnostic-registration", method: "textDocument/diagnostic" }],
})
return
}
if (method) sendRequest(method, {})
return
}
if (typeof data.id !== "undefined") {
// Respond OK to any request from client to keep transport flowing
send({ jsonrpc: "2.0", id: data.id, result: null })
if (data.method === "test/configure-pull-diagnostics") {
pullConfig = {
delayMs: data.params?.delayMs ?? 0,
registerOn: data.params?.registerOn,
registrations: data.params?.registrations ?? [],
documentDiagnostics: data.params?.documentDiagnostics ?? [],
documentDiagnosticsByIdentifier: data.params?.documentDiagnosticsByIdentifier ?? {},
documentDelayMsByIdentifier: data.params?.documentDelayMsByIdentifier ?? {},
workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [],
workspaceDiagnosticsByIdentifier: data.params?.workspaceDiagnosticsByIdentifier ?? {},
workspaceDelayMsByIdentifier: data.params?.workspaceDelayMsByIdentifier ?? {},
}
registeredCapability = false
sendResponse(data.id, null)
return
}
if (data.method === "test/register-configured-pull-diagnostics") {
maybeRegister(undefined)
sendResponse(data.id, null)
return
}
if (data.method === "test/publish-diagnostics") {
sendNotification("textDocument/publishDiagnostics", data.params)
return
}
if (data.method === "test/get-last-change") {
sendResponse(data.id, lastChange)
return
}
if (data.method === "test/get-diagnostic-request-count") {
sendResponse(data.id, diagnosticRequestCount)
return
}
if (data.method === "textDocument/diagnostic") {
diagnosticRequestCount += 1
delayed(
data.id,
{
kind: "full",
items: diagnosticsForIdentifier(data.params?.identifier ?? ""),
},
documentDelayForIdentifier(data.params?.identifier ?? ""),
)
return
}
if (data.method === "workspace/diagnostic") {
diagnosticRequestCount += 1
delayed(
data.id,
{
items: workspaceDiagnosticsForIdentifier(data.params?.identifier ?? ""),
},
workspaceDelayForIdentifier(data.params?.identifier ?? ""),
)
return
}
if (typeof data.id !== "undefined") {
sendResponse(data.id, null)
}
}
process.stdin.on("data", (chunk) => {
readBuffer = Buffer.concat([readBuffer, chunk])
const { messages, rest } = decodeFrames(readBuffer)
readBuffer = rest
for (const message of messages) handle(message)
})