Skip to content

Commit a507a9d

Browse files
committed
Fix bug #23075: LSP workspaceSymbol is always send with empty query
1 parent d2181e9 commit a507a9d

2 files changed

Lines changed: 88 additions & 22 deletions

File tree

packages/opencode/src/lsp/client.ts

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -130,10 +130,27 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
130130
settings: input.server.initialization,
131131
})
132132
}
133+
134+
const files: Record<string, {
135+
version: number;
136+
lineCount: number;
137+
lastLineLength: number;
138+
}> = {};
133139

134-
const files: {
135-
[path: string]: number
136-
} = {}
140+
function getMeta(str: string) {
141+
let lineCount = 0;
142+
let lastIndex = -1;
143+
while (true) {
144+
const matchIndex = str.indexOf('\n', lastIndex + 1);
145+
if (matchIndex === -1) break;
146+
lineCount++;
147+
lastIndex = matchIndex;
148+
}
149+
return {
150+
lineCount: lineCount + 1,
151+
lastLineLength: str.length - lastIndex - 1
152+
};
153+
}
137154

138155
const result = {
139156
root: input.root,
@@ -150,8 +167,8 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
150167
const extension = path.extname(request.path)
151168
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
152169

153-
const version = files[request.path]
154-
if (version !== undefined) {
170+
const fileInfo = files[request.path]
171+
if (fileInfo !== undefined) {
155172
log.info("workspace/didChangeWatchedFiles", request)
156173
await connection.sendNotification("workspace/didChangeWatchedFiles", {
157174
changes: [
@@ -162,8 +179,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
162179
],
163180
})
164181

165-
const next = version + 1
166-
files[request.path] = next
182+
const next = fileInfo.version + 1
167183
log.info("textDocument/didChange", {
168184
path: request.path,
169185
version: next,
@@ -173,8 +189,25 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
173189
uri: pathToFileURL(request.path).href,
174190
version: next,
175191
},
176-
contentChanges: [{ text }],
192+
contentChanges: [{
193+
// Explicitly range-replace the entire document to satisfy LSP servers
194+
// that require Incremental sync (like Roslyn) while we are sending full text.
195+
range: {
196+
start: { line: 0, character: 0 },
197+
end: {
198+
line: fileInfo.lineCount - 1,
199+
character: fileInfo.lastLineLength
200+
}
201+
},
202+
text: text
203+
}],
177204
})
205+
206+
const meta = getMeta(text)
207+
files[request.path] = {
208+
version: next,
209+
...meta
210+
}
178211
return
179212
}
180213

@@ -198,7 +231,12 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
198231
text,
199232
},
200233
})
201-
files[request.path] = 0
234+
235+
const meta = getMeta(text)
236+
files[request.path] = {
237+
version: 0,
238+
...meta
239+
}
202240
return
203241
},
204242
},

packages/opencode/src/tool/lsp.ts

Lines changed: 41 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,24 +31,34 @@ export const LspTool = Tool.define(
3131
description: DESCRIPTION,
3232
parameters: z.object({
3333
operation: z.enum(operations).describe("The LSP operation to perform"),
34-
filePath: z.string().describe("The absolute or relative path to the file"),
35-
line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
36-
character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
34+
filePath: z.string().optional().describe("The absolute or relative path to the file. Required for all operations except workspaceSymbol."),
35+
line: z.number().int().min(1).optional().describe("The line number (1-based, as shown in editors). Required for: goToDefinition, findReferences, hover, goToImplementation, prepareCallHierarchy, incomingCalls, outgoingCalls."),
36+
character: z.number().int().min(1).optional().describe("The character offset (1-based, as shown in editors). Required for: goToDefinition, findReferences, hover, goToImplementation, prepareCallHierarchy, incomingCalls, outgoingCalls."),
37+
query: z.string().optional().describe("Search query. Required for workspaceSymbol operation."),
3738
}),
3839
execute: (
39-
args: { operation: (typeof operations)[number]; filePath: string; line: number; character: number },
40+
args: { operation: (typeof operations)[number]; filePath?: string; line?: number; character?: number; query?: string },
4041
ctx: Tool.Context,
4142
) =>
4243
Effect.gen(function* () {
44+
// Handle workspaceSymbol without file validation
45+
if (args.operation === "workspaceSymbol") {
46+
const result: unknown[] = yield* lsp.workspaceSymbol(args.query || "")
47+
return {
48+
title: `workspaceSymbol "${args.query || ''}"`,
49+
metadata: { result },
50+
output: result.length === 0 ? `No workspace symbols found matching query "${args.query || ''}"` : JSON.stringify(result, null, 2),
51+
}
52+
}
53+
54+
if (!args.filePath) {
55+
throw new Error(`filePath is required for operation '${args.operation}'`)
56+
}
57+
4358
const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
4459
yield* assertExternalDirectoryEffect(ctx, file)
4560
yield* ctx.ask({ permission: "lsp", patterns: ["*"], always: ["*"], metadata: {} })
4661

47-
const uri = pathToFileURL(file).href
48-
const position = { file, line: args.line - 1, character: args.character - 1 }
49-
const relPath = path.relative(Instance.worktree, file)
50-
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
51-
5262
const exists = yield* fs.existsSafe(file)
5363
if (!exists) throw new Error(`File not found: ${file}`)
5464

@@ -57,6 +67,26 @@ export const LspTool = Tool.define(
5767

5868
yield* lsp.touchFile(file, true)
5969

70+
const uri = pathToFileURL(file).href
71+
const relPath = path.relative(Instance.worktree, file)
72+
73+
// Handle documentSymbol without line/character validation
74+
if (args.operation === "documentSymbol") {
75+
const result: unknown[] = yield* lsp.documentSymbol(uri)
76+
return {
77+
title: `documentSymbol ${relPath}`,
78+
metadata: { result },
79+
output: result.length === 0 ? `No document symbols found` : JSON.stringify(result, null, 2),
80+
}
81+
}
82+
83+
if (args.line === undefined || args.character === undefined) {
84+
throw new Error(`line and character are required for operation '${args.operation}'`)
85+
}
86+
87+
const position = { file, line: args.line - 1, character: args.character - 1 }
88+
const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
89+
6090
const result: unknown[] = yield* (() => {
6191
switch (args.operation) {
6292
case "goToDefinition":
@@ -65,10 +95,6 @@ export const LspTool = Tool.define(
6595
return lsp.references(position)
6696
case "hover":
6797
return lsp.hover(position)
68-
case "documentSymbol":
69-
return lsp.documentSymbol(uri)
70-
case "workspaceSymbol":
71-
return lsp.workspaceSymbol("")
7298
case "goToImplementation":
7399
return lsp.implementation(position)
74100
case "prepareCallHierarchy":
@@ -77,6 +103,8 @@ export const LspTool = Tool.define(
77103
return lsp.incomingCalls(position)
78104
case "outgoingCalls":
79105
return lsp.outgoingCalls(position)
106+
default:
107+
throw new Error(`Unknown operation: ${args.operation}`)
80108
}
81109
})()
82110

0 commit comments

Comments
 (0)