mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
feat: add experimental lsp tool (#5886)
This commit is contained in:
parent
ac4b8d62e3
commit
345f4801e8
5 changed files with 223 additions and 21 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
packages/opencode/src/tool/lsp.ts
Normal file
87
packages/opencode/src/tool/lsp.ts
Normal file
|
|
@ -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
packages/opencode/src/tool/lsp.txt
Normal file
19
packages/opencode/src/tool/lsp.txt
Normal file
|
|
@ -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.
|
||||
|
|
@ -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,
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue