summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--packages/opencode/src/flag/flag.ts1
-rw-r--r--packages/opencode/src/lsp/index.ts135
-rw-r--r--packages/opencode/src/tool/lsp.ts87
-rw-r--r--packages/opencode/src/tool/lsp.txt19
-rw-r--r--packages/opencode/src/tool/registry.ts2
5 files changed, 223 insertions, 21 deletions
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index 412377693..805da33cc 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/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()
diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts
index 1d52aefcb..0fd3b69df 100644
--- a/packages/opencode/src/lsp/index.ts
+++ b/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 = {
diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts
new file mode 100644
index 000000000..2a15ed7e3
--- /dev/null
+++ b/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,
+ }
+ },
+})
diff --git a/packages/opencode/src/tool/lsp.txt b/packages/opencode/src/tool/lsp.txt
new file mode 100644
index 000000000..5a50a571b
--- /dev/null
+++ b/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.
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 647c74267..3a695f45f 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/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,
]