Просмотр исходного кода

feat: add experimental lsp tool (#5886)

Aiden Cline 2 месяцев назад
Родитель
Сommit
345f4801e8

+ 1 - 0
packages/opencode/src/flag/flag.ts

@@ -30,6 +30,7 @@ export namespace Flag {
   export const OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX = number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX")
   export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT")
   export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
+  export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
 
   function truthy(key: string) {
     const value = process.env[key]?.toLowerCase()

+ 114 - 21
packages/opencode/src/lsp/index.ts

@@ -261,23 +261,36 @@ export namespace LSP {
     return result
   }
 
+  export async function hasClients(file: string) {
+    const s = await state()
+    const extension = path.parse(file).ext || file
+    for (const server of Object.values(s.servers)) {
+      if (server.extensions.length && !server.extensions.includes(extension)) continue
+      const root = await server.root(file)
+      if (!root) continue
+      if (s.broken.has(root + server.id)) continue
+      return true
+    }
+    return false
+  }
+
   export async function touchFile(input: string, waitForDiagnostics?: boolean) {
     log.info("touching file", { file: input })
     const clients = await getClients(input)
-    await run(async (client) => {
-      if (!clients.includes(client)) return
-      const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
-      await client.notify.open({ path: input })
-
-      return wait
-    }).catch((err) => {
+    await Promise.all(
+      clients.map(async (client) => {
+        const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
+        await client.notify.open({ path: input })
+        return wait
+      }),
+    ).catch((err) => {
       log.error("failed to touch file", { err, file: input })
     })
   }
 
   export async function diagnostics() {
     const results: Record<string, LSPClient.Diagnostic[]> = {}
-    for (const result of await run(async (client) => client.diagnostics)) {
+    for (const result of await runAll(async (client) => client.diagnostics)) {
       for (const [path, diagnostics] of result.entries()) {
         const arr = results[path] || []
         arr.push(...diagnostics)
@@ -288,16 +301,18 @@ export namespace LSP {
   }
 
   export async function hover(input: { file: string; line: number; character: number }) {
-    return run((client) => {
-      return client.connection.sendRequest("textDocument/hover", {
-        textDocument: {
-          uri: pathToFileURL(input.file).href,
-        },
-        position: {
-          line: input.line,
-          character: input.character,
-        },
-      })
+    return run(input.file, (client) => {
+      return client.connection
+        .sendRequest("textDocument/hover", {
+          textDocument: {
+            uri: pathToFileURL(input.file).href,
+          },
+          position: {
+            line: input.line,
+            character: input.character,
+          },
+        })
+        .catch(() => null)
     })
   }
 
@@ -342,7 +357,7 @@ export namespace LSP {
   ]
 
   export async function workspaceSymbol(query: string) {
-    return run((client) =>
+    return runAll((client) =>
       client.connection
         .sendRequest("workspace/symbol", {
           query,
@@ -354,7 +369,8 @@ export namespace LSP {
   }
 
   export async function documentSymbol(uri: string) {
-    return run((client) =>
+    const file = new URL(uri).pathname
+    return run(file, (client) =>
       client.connection
         .sendRequest("textDocument/documentSymbol", {
           textDocument: {
@@ -367,12 +383,89 @@ export namespace LSP {
       .then((result) => result.filter(Boolean))
   }
 
-  async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
+  export async function definition(input: { file: string; line: number; character: number }) {
+    return run(input.file, (client) =>
+      client.connection
+        .sendRequest("textDocument/definition", {
+          textDocument: { uri: pathToFileURL(input.file).href },
+          position: { line: input.line, character: input.character },
+        })
+        .catch(() => null),
+    ).then((result) => result.flat().filter(Boolean))
+  }
+
+  export async function references(input: { file: string; line: number; character: number }) {
+    return run(input.file, (client) =>
+      client.connection
+        .sendRequest("textDocument/references", {
+          textDocument: { uri: pathToFileURL(input.file).href },
+          position: { line: input.line, character: input.character },
+          context: { includeDeclaration: true },
+        })
+        .catch(() => []),
+    ).then((result) => result.flat().filter(Boolean))
+  }
+
+  export async function implementation(input: { file: string; line: number; character: number }) {
+    return run(input.file, (client) =>
+      client.connection
+        .sendRequest("textDocument/implementation", {
+          textDocument: { uri: pathToFileURL(input.file).href },
+          position: { line: input.line, character: input.character },
+        })
+        .catch(() => null),
+    ).then((result) => result.flat().filter(Boolean))
+  }
+
+  export async function prepareCallHierarchy(input: { file: string; line: number; character: number }) {
+    return run(input.file, (client) =>
+      client.connection
+        .sendRequest("textDocument/prepareCallHierarchy", {
+          textDocument: { uri: pathToFileURL(input.file).href },
+          position: { line: input.line, character: input.character },
+        })
+        .catch(() => []),
+    ).then((result) => result.flat().filter(Boolean))
+  }
+
+  export async function incomingCalls(input: { file: string; line: number; character: number }) {
+    return run(input.file, async (client) => {
+      const items = (await client.connection
+        .sendRequest("textDocument/prepareCallHierarchy", {
+          textDocument: { uri: pathToFileURL(input.file).href },
+          position: { line: input.line, character: input.character },
+        })
+        .catch(() => [])) as any[]
+      if (!items?.length) return []
+      return client.connection.sendRequest("callHierarchy/incomingCalls", { item: items[0] }).catch(() => [])
+    }).then((result) => result.flat().filter(Boolean))
+  }
+
+  export async function outgoingCalls(input: { file: string; line: number; character: number }) {
+    return run(input.file, async (client) => {
+      const items = (await client.connection
+        .sendRequest("textDocument/prepareCallHierarchy", {
+          textDocument: { uri: pathToFileURL(input.file).href },
+          position: { line: input.line, character: input.character },
+        })
+        .catch(() => [])) as any[]
+      if (!items?.length) return []
+      return client.connection.sendRequest("callHierarchy/outgoingCalls", { item: items[0] }).catch(() => [])
+    }).then((result) => result.flat().filter(Boolean))
+  }
+
+  async function runAll<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
     const clients = await state().then((x) => x.clients)
     const tasks = clients.map((x) => input(x))
     return Promise.all(tasks)
   }
 
+  async function run<T>(file: string, input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
+    const clients = await getClients(file)
+    const tasks = clients.map((x) => input(x))
+    return Promise.all(tasks)
+  }
+
   export namespace Diagnostic {
     export function pretty(diagnostic: LSPClient.Diagnostic) {
       const severityMap = {

+ 87 - 0
packages/opencode/src/tool/lsp.ts

@@ -0,0 +1,87 @@
+import z from "zod"
+import { Tool } from "./tool"
+import path from "path"
+import { LSP } from "../lsp"
+import DESCRIPTION from "./lsp.txt"
+import { Instance } from "../project/instance"
+import { pathToFileURL } from "url"
+
+const operations = [
+  "goToDefinition",
+  "findReferences",
+  "hover",
+  "documentSymbol",
+  "workspaceSymbol",
+  "goToImplementation",
+  "prepareCallHierarchy",
+  "incomingCalls",
+  "outgoingCalls",
+] as const
+
+export const LspTool = Tool.define("lsp", {
+  description: DESCRIPTION,
+  parameters: z.object({
+    operation: z.enum(operations).describe("The LSP operation to perform"),
+    filePath: z.string().describe("The absolute or relative path to the file"),
+    line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
+    character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
+  }),
+  execute: async (args) => {
+    const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
+    const uri = pathToFileURL(file).href
+    const position = {
+      file,
+      line: args.line - 1,
+      character: args.character - 1,
+    }
+
+    const relPath = path.relative(Instance.worktree, file)
+    const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
+
+    const exists = await Bun.file(file).exists()
+    if (!exists) {
+      throw new Error(`File not found: ${file}`)
+    }
+
+    const available = await LSP.hasClients(file)
+    if (!available) {
+      throw new Error("No LSP server available for this file type.")
+    }
+
+    await LSP.touchFile(file, true)
+
+    const result: unknown[] = await (async () => {
+      switch (args.operation) {
+        case "goToDefinition":
+          return LSP.definition(position)
+        case "findReferences":
+          return LSP.references(position)
+        case "hover":
+          return LSP.hover(position)
+        case "documentSymbol":
+          return LSP.documentSymbol(uri)
+        case "workspaceSymbol":
+          return LSP.workspaceSymbol("")
+        case "goToImplementation":
+          return LSP.implementation(position)
+        case "prepareCallHierarchy":
+          return LSP.prepareCallHierarchy(position)
+        case "incomingCalls":
+          return LSP.incomingCalls(position)
+        case "outgoingCalls":
+          return LSP.outgoingCalls(position)
+      }
+    })()
+
+    const output = (() => {
+      if (result.length === 0) return `No results found for ${args.operation}`
+      return JSON.stringify(result, null, 2)
+    })()
+
+    return {
+      title,
+      metadata: { result },
+      output,
+    }
+  },
+})

+ 19 - 0
packages/opencode/src/tool/lsp.txt

@@ -0,0 +1,19 @@
+Interact with Language Server Protocol (LSP) servers to get code intelligence features.
+
+Supported operations:
+- goToDefinition: Find where a symbol is defined
+- findReferences: Find all references to a symbol
+- hover: Get hover information (documentation, type info) for a symbol
+- documentSymbol: Get all symbols (functions, classes, variables) in a document
+- workspaceSymbol: Search for symbols across the entire workspace
+- goToImplementation: Find implementations of an interface or abstract method
+- prepareCallHierarchy: Get call hierarchy item at a position (functions/methods)
+- incomingCalls: Find all functions/methods that call the function at a position
+- outgoingCalls: Find all functions/methods called by the function at a position
+
+All operations require:
+- filePath: The file to operate on
+- line: The line number (1-based, as shown in editors)
+- character: The character offset (1-based, as shown in editors)
+
+Note: LSP servers must be configured for the file type. If no server is available, an error will be returned.

+ 2 - 0
packages/opencode/src/tool/registry.ts

@@ -22,6 +22,7 @@ import { WebSearchTool } from "./websearch"
 import { CodeSearchTool } from "./codesearch"
 import { Flag } from "@/flag/flag"
 import { Log } from "@/util/log"
+import { LspTool } from "./lsp"
 
 export namespace ToolRegistry {
   const log = Log.create({ service: "tool.registry" })
@@ -102,6 +103,7 @@ export namespace ToolRegistry {
       TodoReadTool,
       WebSearchTool,
       CodeSearchTool,
+      ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
       ...(config.experimental?.batch_tool === true ? [BatchTool] : []),
       ...custom,
     ]