Skip to content

Commit 345f480

Browse files
authored
feat: add experimental lsp tool (anomalyco#5886)
1 parent ac4b8d6 commit 345f480

File tree

5 files changed

+223
-21
lines changed

5 files changed

+223
-21
lines changed

packages/opencode/src/flag/flag.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ export namespace Flag {
3030
export const OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX = number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX")
3131
export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT")
3232
export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
33+
export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
3334

3435
function truthy(key: string) {
3536
const value = process.env[key]?.toLowerCase()

packages/opencode/src/lsp/index.ts

Lines changed: 114 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -261,23 +261,36 @@ export namespace LSP {
261261
return result
262262
}
263263

264+
export async function hasClients(file: string) {
265+
const s = await state()
266+
const extension = path.parse(file).ext || file
267+
for (const server of Object.values(s.servers)) {
268+
if (server.extensions.length && !server.extensions.includes(extension)) continue
269+
const root = await server.root(file)
270+
if (!root) continue
271+
if (s.broken.has(root + server.id)) continue
272+
return true
273+
}
274+
return false
275+
}
276+
264277
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
265278
log.info("touching file", { file: input })
266279
const clients = await getClients(input)
267-
await run(async (client) => {
268-
if (!clients.includes(client)) return
269-
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
270-
await client.notify.open({ path: input })
271-
272-
return wait
273-
}).catch((err) => {
280+
await Promise.all(
281+
clients.map(async (client) => {
282+
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
283+
await client.notify.open({ path: input })
284+
return wait
285+
}),
286+
).catch((err) => {
274287
log.error("failed to touch file", { err, file: input })
275288
})
276289
}
277290

278291
export async function diagnostics() {
279292
const results: Record<string, LSPClient.Diagnostic[]> = {}
280-
for (const result of await run(async (client) => client.diagnostics)) {
293+
for (const result of await runAll(async (client) => client.diagnostics)) {
281294
for (const [path, diagnostics] of result.entries()) {
282295
const arr = results[path] || []
283296
arr.push(...diagnostics)
@@ -288,16 +301,18 @@ export namespace LSP {
288301
}
289302

290303
export async function hover(input: { file: string; line: number; character: number }) {
291-
return run((client) => {
292-
return client.connection.sendRequest("textDocument/hover", {
293-
textDocument: {
294-
uri: pathToFileURL(input.file).href,
295-
},
296-
position: {
297-
line: input.line,
298-
character: input.character,
299-
},
300-
})
304+
return run(input.file, (client) => {
305+
return client.connection
306+
.sendRequest("textDocument/hover", {
307+
textDocument: {
308+
uri: pathToFileURL(input.file).href,
309+
},
310+
position: {
311+
line: input.line,
312+
character: input.character,
313+
},
314+
})
315+
.catch(() => null)
301316
})
302317
}
303318

@@ -342,7 +357,7 @@ export namespace LSP {
342357
]
343358

344359
export async function workspaceSymbol(query: string) {
345-
return run((client) =>
360+
return runAll((client) =>
346361
client.connection
347362
.sendRequest("workspace/symbol", {
348363
query,
@@ -354,7 +369,8 @@ export namespace LSP {
354369
}
355370

356371
export async function documentSymbol(uri: string) {
357-
return run((client) =>
372+
const file = new URL(uri).pathname
373+
return run(file, (client) =>
358374
client.connection
359375
.sendRequest("textDocument/documentSymbol", {
360376
textDocument: {
@@ -367,12 +383,89 @@ export namespace LSP {
367383
.then((result) => result.filter(Boolean))
368384
}
369385

370-
async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
386+
export async function definition(input: { file: string; line: number; character: number }) {
387+
return run(input.file, (client) =>
388+
client.connection
389+
.sendRequest("textDocument/definition", {
390+
textDocument: { uri: pathToFileURL(input.file).href },
391+
position: { line: input.line, character: input.character },
392+
})
393+
.catch(() => null),
394+
).then((result) => result.flat().filter(Boolean))
395+
}
396+
397+
export async function references(input: { file: string; line: number; character: number }) {
398+
return run(input.file, (client) =>
399+
client.connection
400+
.sendRequest("textDocument/references", {
401+
textDocument: { uri: pathToFileURL(input.file).href },
402+
position: { line: input.line, character: input.character },
403+
context: { includeDeclaration: true },
404+
})
405+
.catch(() => []),
406+
).then((result) => result.flat().filter(Boolean))
407+
}
408+
409+
export async function implementation(input: { file: string; line: number; character: number }) {
410+
return run(input.file, (client) =>
411+
client.connection
412+
.sendRequest("textDocument/implementation", {
413+
textDocument: { uri: pathToFileURL(input.file).href },
414+
position: { line: input.line, character: input.character },
415+
})
416+
.catch(() => null),
417+
).then((result) => result.flat().filter(Boolean))
418+
}
419+
420+
export async function prepareCallHierarchy(input: { file: string; line: number; character: number }) {
421+
return run(input.file, (client) =>
422+
client.connection
423+
.sendRequest("textDocument/prepareCallHierarchy", {
424+
textDocument: { uri: pathToFileURL(input.file).href },
425+
position: { line: input.line, character: input.character },
426+
})
427+
.catch(() => []),
428+
).then((result) => result.flat().filter(Boolean))
429+
}
430+
431+
export async function incomingCalls(input: { file: string; line: number; character: number }) {
432+
return run(input.file, async (client) => {
433+
const items = (await client.connection
434+
.sendRequest("textDocument/prepareCallHierarchy", {
435+
textDocument: { uri: pathToFileURL(input.file).href },
436+
position: { line: input.line, character: input.character },
437+
})
438+
.catch(() => [])) as any[]
439+
if (!items?.length) return []
440+
return client.connection.sendRequest("callHierarchy/incomingCalls", { item: items[0] }).catch(() => [])
441+
}).then((result) => result.flat().filter(Boolean))
442+
}
443+
444+
export async function outgoingCalls(input: { file: string; line: number; character: number }) {
445+
return run(input.file, async (client) => {
446+
const items = (await client.connection
447+
.sendRequest("textDocument/prepareCallHierarchy", {
448+
textDocument: { uri: pathToFileURL(input.file).href },
449+
position: { line: input.line, character: input.character },
450+
})
451+
.catch(() => [])) as any[]
452+
if (!items?.length) return []
453+
return client.connection.sendRequest("callHierarchy/outgoingCalls", { item: items[0] }).catch(() => [])
454+
}).then((result) => result.flat().filter(Boolean))
455+
}
456+
457+
async function runAll<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
371458
const clients = await state().then((x) => x.clients)
372459
const tasks = clients.map((x) => input(x))
373460
return Promise.all(tasks)
374461
}
375462

463+
async function run<T>(file: string, input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
464+
const clients = await getClients(file)
465+
const tasks = clients.map((x) => input(x))
466+
return Promise.all(tasks)
467+
}
468+
376469
export namespace Diagnostic {
377470
export function pretty(diagnostic: LSPClient.Diagnostic) {
378471
const severityMap = {

packages/opencode/src/tool/lsp.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import z from "zod"
2+
import { Tool } from "./tool"
3+
import path from "path"
4+
import { LSP } from "../lsp"
5+
import DESCRIPTION from "./lsp.txt"
6+
import { Instance } from "../project/instance"
7+
import { pathToFileURL } from "url"
8+
9+
const operations = [
10+
"goToDefinition",
11+
"findReferences",
12+
"hover",
13+
"documentSymbol",
14+
"workspaceSymbol",
15+
"goToImplementation",
16+
"prepareCallHierarchy",
17+
"incomingCalls",
18+
"outgoingCalls",
19+
] as const
20+
21+
export const LspTool = Tool.define("lsp", {
22+
description: DESCRIPTION,
23+
parameters: z.object({
24+
operation: z.enum(operations).describe("The LSP operation to perform"),
25+
filePath: z.string().describe("The absolute or relative path to the file"),
26+
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
27+
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
28+
}),
29+
execute: async (args) => {
30+
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
31+
const uri = pathToFileURL(file).href
32+
const position = {
33+
file,
34+
line: args.line - 1,
35+
character: args.character - 1,
36+
}
37+
38+
const relPath = path.relative(Instance.worktree, file)
39+
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
40+
41+
const exists = await Bun.file(file).exists()
42+
if (!exists) {
43+
throw new Error(`File not found: ${file}`)
44+
}
45+
46+
const available = await LSP.hasClients(file)
47+
if (!available) {
48+
throw new Error("No LSP server available for this file type.")
49+
}
50+
51+
await LSP.touchFile(file, true)
52+
53+
const result: unknown[] = await (async () => {
54+
switch (args.operation) {
55+
case "goToDefinition":
56+
return LSP.definition(position)
57+
case "findReferences":
58+
return LSP.references(position)
59+
case "hover":
60+
return LSP.hover(position)
61+
case "documentSymbol":
62+
return LSP.documentSymbol(uri)
63+
case "workspaceSymbol":
64+
return LSP.workspaceSymbol("")
65+
case "goToImplementation":
66+
return LSP.implementation(position)
67+
case "prepareCallHierarchy":
68+
return LSP.prepareCallHierarchy(position)
69+
case "incomingCalls":
70+
return LSP.incomingCalls(position)
71+
case "outgoingCalls":
72+
return LSP.outgoingCalls(position)
73+
}
74+
})()
75+
76+
const output = (() => {
77+
if (result.length === 0) return `No results found for ${args.operation}`
78+
return JSON.stringify(result, null, 2)
79+
})()
80+
81+
return {
82+
title,
83+
metadata: { result },
84+
output,
85+
}
86+
},
87+
})

packages/opencode/src/tool/lsp.txt

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

packages/opencode/src/tool/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { WebSearchTool } from "./websearch"
2222
import { CodeSearchTool } from "./codesearch"
2323
import { Flag } from "@/flag/flag"
2424
import { Log } from "@/util/log"
25+
import { LspTool } from "./lsp"
2526

2627
export namespace ToolRegistry {
2728
const log = Log.create({ service: "tool.registry" })
@@ -102,6 +103,7 @@ export namespace ToolRegistry {
102103
TodoReadTool,
103104
WebSearchTool,
104105
CodeSearchTool,
106+
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
105107
...(config.experimental?.batch_tool === true ? [BatchTool] : []),
106108
...custom,
107109
]

0 commit comments

Comments
 (0)