Przeglądaj źródła

fix(lsp): handle optional requests to avoid MethodNotFound (-32601) with MATLAB Language Server (#4007)

Christopher Sacca 9 miesięcy temu
rodzic
commit
271b679058

+ 28 - 5
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<void>((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()

+ 77 - 0
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
+  }
+}

+ 95 - 0
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()
+  })
+})