From 271b679058473537099b9b333aa25fa45863efd1 Mon Sep 17 00:00:00 2001 From: Christopher Sacca Date: Sat, 8 Nov 2025 17:31:39 -0500 Subject: [PATCH] fix(lsp): handle optional requests to avoid MethodNotFound (-32601) with MATLAB Language Server (#4007) --- packages/opencode/src/lsp/client.ts | 33 ++++++- .../test/fixture/lsp/fake-lsp-server.js | 77 +++++++++++++++ packages/opencode/test/lsp/client.test.ts | 95 +++++++++++++++++++ 3 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/test/fixture/lsp/fake-lsp-server.js create mode 100644 packages/opencode/test/lsp/client.test.ts diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts index 1a6e2cb7..9ab2fee6 100644 --- a/packages/opencode/src/lsp/client.ts +++ b/packages/opencode/src/lsp/client.ts @@ -1,5 +1,9 @@ import path from "path" -import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node" +import { + createMessageConnection, + StreamMessageReader, + StreamMessageWriter, +} from "vscode-jsonrpc/node" import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types" import { Log } from "../util/log" import { LANGUAGE_EXTENSIONS } from "./language" @@ -34,7 +38,11 @@ export namespace LSPClient { ), } - export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) { + export async function create(input: { + serverID: string + server: LSPServer.Handle + root: string + }) { const l = log.clone().tag("serverID", input.serverID) l.info("starting client") @@ -62,6 +70,14 @@ export namespace LSPClient { // Return server initialization options return [input.server.initialization ?? {}] }) + connection.onRequest("client/registerCapability", async () => {}) + connection.onRequest("client/unregisterCapability", async () => {}) + connection.onRequest("workspace/workspaceFolders", async () => [ + { + name: "workspace", + uri: "file://" + input.root, + }, + ]) connection.listen() l.info("sending initialize") @@ -129,7 +145,9 @@ export namespace LSPClient { }, notify: { async open(input: { path: string }) { - input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) + input.path = path.isAbsolute(input.path) + ? input.path + : path.resolve(Instance.directory, input.path) const file = Bun.file(input.path) const text = await file.text() const extension = path.extname(input.path) @@ -171,13 +189,18 @@ export namespace LSPClient { return diagnostics }, async waitForDiagnostics(input: { path: string }) { - input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path) + input.path = path.isAbsolute(input.path) + ? input.path + : path.resolve(Instance.directory, input.path) log.info("waiting for diagnostics", input) let unsub: () => void return await withTimeout( new Promise((resolve) => { unsub = Bus.subscribe(Event.Diagnostics, (event) => { - if (event.properties.path === input.path && event.properties.serverID === result.serverID) { + if ( + event.properties.path === input.path && + event.properties.serverID === result.serverID + ) { log.info("got diagnostics", input) unsub?.() resolve() diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js new file mode 100644 index 00000000..39e57880 --- /dev/null +++ b/packages/opencode/test/fixture/lsp/fake-lsp-server.js @@ -0,0 +1,77 @@ +// Simple JSON-RPC 2.0 LSP-like fake server over stdio +// Implements a minimal LSP handshake and triggers a request upon notification + +const net = require("net") + +let nextId = 1 + +function encode(message) { + const json = JSON.stringify(message) + const header = `Content-Length: ${Buffer.byteLength(json, "utf8")}\r\n\r\n` + return Buffer.concat([Buffer.from(header, "utf8"), Buffer.from(json, "utf8")]) +} + +function decodeFrames(buffer) { + const results = [] + 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 bodyStart = idx + 4 + const bodyEnd = bodyStart + len + if (buffer.length < bodyEnd) break + const body = buffer.slice(bodyStart, bodyEnd).toString("utf8") + results.push(body) + 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 sendRequest(method, params) { + const id = nextId++ + send({ jsonrpc: "2.0", id, method, params }) + return id +} + +function handle(raw) { + let data + try { + data = JSON.parse(raw) + } catch { + return + } + if (data.method === "initialize") { + send({ jsonrpc: "2.0", id: data.id, result: { capabilities: {} } }) + return + } + if (data.method === "initialized") { + return + } + if (data.method === "workspace/didChangeConfiguration") { + return + } + if (data.method === "test/trigger") { + const method = data.params && data.params.method + 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 }) + return + } +} diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts new file mode 100644 index 00000000..c2ba3ac5 --- /dev/null +++ b/packages/opencode/test/lsp/client.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import path from "path" +import { LSPClient } from "../../src/lsp/client" +import { LSPServer } from "../../src/lsp/server" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" + +// Minimal fake LSP server that speaks JSON-RPC over stdio +function spawnFakeServer() { + const { spawn } = require("child_process") + const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js") + return { + process: spawn(process.execPath, [serverPath], { + stdio: "pipe", + }), + } +} + +describe("LSPClient interop", () => { + beforeEach(async () => { + await Log.init({ print: true }) + }) + + test("handles workspace/workspaceFolders request", async () => { + const handle = spawnFakeServer() as any + + const client = await Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }), + }) + + await client.connection.sendNotification("test/trigger", { + method: "workspace/workspaceFolders", + }) + + await new Promise((r) => setTimeout(r, 100)) + + expect(client.connection).toBeDefined() + + await client.shutdown() + }) + + test("handles client/registerCapability request", async () => { + const handle = spawnFakeServer() as any + + const client = await Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }), + }) + + await client.connection.sendNotification("test/trigger", { + method: "client/registerCapability", + }) + + await new Promise((r) => setTimeout(r, 100)) + + expect(client.connection).toBeDefined() + + await client.shutdown() + }) + + test("handles client/unregisterCapability request", async () => { + const handle = spawnFakeServer() as any + + const client = await Instance.provide({ + directory: process.cwd(), + fn: () => + LSPClient.create({ + serverID: "fake", + server: handle as unknown as LSPServer.Handle, + root: process.cwd(), + }), + }) + + await client.connection.sendNotification("test/trigger", { + method: "client/unregisterCapability", + }) + + await new Promise((r) => setTimeout(r, 100)) + + expect(client.connection).toBeDefined() + + await client.shutdown() + }) +})