From 345f4801e8fb67bf8a27b053187989aff47979b2 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 21 Dec 2025 22:34:21 -0800 Subject: [PATCH] feat: add experimental lsp tool (#5886) --- packages/opencode/src/flag/flag.ts | 1 + packages/opencode/src/lsp/index.ts | 135 +++++++++++++++++++++---- packages/opencode/src/tool/lsp.ts | 87 ++++++++++++++++ packages/opencode/src/tool/lsp.txt | 19 ++++ packages/opencode/src/tool/registry.ts | 2 + 5 files changed, 223 insertions(+), 21 deletions(-) create mode 100644 packages/opencode/src/tool/lsp.ts create mode 100644 packages/opencode/src/tool/lsp.txt 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 = {} - 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(input: (client: LSPClient.Info) => Promise): Promise { + 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(input: (client: LSPClient.Info) => Promise): Promise { const clients = await state().then((x) => x.clients) const tasks = clients.map((x) => input(x)) return Promise.all(tasks) } + async function run(file: string, input: (client: LSPClient.Info) => Promise): Promise { + 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, ]