Skip to content

Commit e2852eb

Browse files
committed
fix: honor incremental LSP didChange sync mode
1 parent 8043cfa commit e2852eb

3 files changed

Lines changed: 273 additions & 28 deletions

File tree

packages/opencode/src/lsp/client.ts

Lines changed: 205 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ import { withTimeout } from "../util/timeout"
1414
import { Filesystem } from "../util"
1515

1616
const DIAGNOSTICS_DEBOUNCE_MS = 150
17+
const DIAGNOSTICS_POLL_MS = 500
18+
const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 2_000
19+
const DIAGNOSTICS_WAIT_TIMEOUT_MS = 10_000
20+
const DIAGNOSTICS_SETTLE_MS = 1_500
1721

1822
const log = Log.create({ service: "lsp.client" })
1923

@@ -38,6 +42,65 @@ export const Event = {
3842
),
3943
}
4044

45+
type DocumentDiagnosticReport = {
46+
items?: Diagnostic[]
47+
relatedDocuments?: Record<string, DocumentDiagnosticReport>
48+
}
49+
50+
type CapabilityRegistration = {
51+
id: string
52+
method: string
53+
registerOptions?: {
54+
identifier?: string
55+
workspaceDiagnostics?: boolean
56+
}
57+
}
58+
59+
type ServerCapabilities = {
60+
textDocumentSync?:
61+
| number
62+
| {
63+
change?: number
64+
}
65+
[key: string]: unknown
66+
}
67+
68+
function getFilePath(uri: string) {
69+
if (!uri.startsWith("file://")) return
70+
return Filesystem.normalizePath(fileURLToPath(uri))
71+
}
72+
73+
function getSyncKind(capabilities?: ServerCapabilities) {
74+
if (!capabilities) return
75+
const sync = capabilities.textDocumentSync
76+
if (typeof sync === "number") return sync
77+
return sync?.change
78+
}
79+
80+
function endPosition(text: string) {
81+
const lines = text.split(/\r\n|\r|\n/)
82+
return {
83+
line: lines.length - 1,
84+
character: lines.at(-1)?.length ?? 0,
85+
}
86+
}
87+
88+
function dedupeDiagnostics(items: Diagnostic[]) {
89+
const seen = new Set<string>()
90+
return items.filter((item) => {
91+
const key = JSON.stringify({
92+
code: item.code,
93+
severity: item.severity,
94+
message: item.message,
95+
source: item.source,
96+
range: item.range,
97+
})
98+
if (seen.has(key)) return false
99+
seen.add(key)
100+
return true
101+
})
102+
}
103+
41104
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) {
42105
const l = log.clone().tag("serverID", input.serverID)
43106
l.info("starting client")
@@ -48,16 +111,21 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
48111
)
49112

50113
const diagnostics = new Map<string, Diagnostic[]>()
114+
const diagnosticRegistrations = new Map<string, CapabilityRegistration>()
115+
function updateDiagnostics(filePath: string, next: Diagnostic[]) {
116+
const exists = diagnostics.has(filePath)
117+
diagnostics.set(filePath, next)
118+
if (!exists && input.serverID === "typescript") return
119+
Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
120+
}
51121
connection.onNotification("textDocument/publishDiagnostics", (params) => {
52-
const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
122+
const filePath = getFilePath(params.uri)
123+
if (!filePath) return
53124
l.info("textDocument/publishDiagnostics", {
54125
path: filePath,
55126
count: params.diagnostics.length,
56127
})
57-
const exists = diagnostics.has(filePath)
58-
diagnostics.set(filePath, params.diagnostics)
59-
if (!exists && input.serverID === "typescript") return
60-
Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
128+
updateDiagnostics(filePath, params.diagnostics)
61129
})
62130
connection.onRequest("window/workDoneProgress/create", (params) => {
63131
l.info("window/workDoneProgress/create", params)
@@ -67,8 +135,20 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
67135
// Return server initialization options
68136
return [input.server.initialization ?? {}]
69137
})
70-
connection.onRequest("client/registerCapability", async () => {})
71-
connection.onRequest("client/unregisterCapability", async () => {})
138+
connection.onRequest("client/registerCapability", async (params) => {
139+
const registrations = (params as { registrations?: CapabilityRegistration[] }).registrations ?? []
140+
for (const registration of registrations) {
141+
if (registration.method !== "textDocument/diagnostic") continue
142+
diagnosticRegistrations.set(registration.id, registration)
143+
}
144+
})
145+
connection.onRequest("client/unregisterCapability", async (params) => {
146+
const registrations = (params as { unregisterations?: { id: string; method: string }[] }).unregisterations ?? []
147+
for (const registration of registrations) {
148+
if (registration.method !== "textDocument/diagnostic") continue
149+
diagnosticRegistrations.delete(registration.id)
150+
}
151+
})
72152
connection.onRequest("workspace/workspaceFolders", async () => [
73153
{
74154
name: "workspace",
@@ -78,8 +158,8 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
78158
connection.listen()
79159

80160
l.info("sending initialize")
81-
await withTimeout(
82-
connection.sendRequest("initialize", {
161+
const initialized = await withTimeout(
162+
connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", {
83163
rootUri: pathToFileURL(input.root).href,
84164
processId: input.server.process.pid,
85165
workspaceFolders: [
@@ -100,12 +180,19 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
100180
didChangeWatchedFiles: {
101181
dynamicRegistration: true,
102182
},
183+
diagnostics: {
184+
refreshSupport: true,
185+
},
103186
},
104187
textDocument: {
105188
synchronization: {
106189
didOpen: true,
107190
didChange: true,
108191
},
192+
diagnostic: {
193+
dynamicRegistration: true,
194+
relatedDocumentSupport: true,
195+
},
109196
publishDiagnostics: {
110197
versionSupport: true,
111198
},
@@ -122,6 +209,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
122209
},
123210
)
124211
})
212+
const syncKind = getSyncKind(initialized.capabilities)
125213

126214
await connection.sendNotification("initialized", {})
127215

@@ -132,9 +220,73 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
132220
}
133221

134222
const files: {
135-
[path: string]: number
223+
[path: string]: { version: number; text: string }
136224
} = {}
137225

226+
async function requestDiagnosticReport(filePath: string, identifier?: string) {
227+
const report = await withTimeout(
228+
connection.sendRequest<DocumentDiagnosticReport | null>("textDocument/diagnostic", {
229+
...(identifier ? { identifier } : {}),
230+
textDocument: {
231+
uri: pathToFileURL(filePath).href,
232+
},
233+
}),
234+
DIAGNOSTICS_REQUEST_TIMEOUT_MS,
235+
).catch(() => null)
236+
if (!report) return { handled: false, byFile: new Map<string, Diagnostic[]>() }
237+
238+
const byFile = new Map<string, Diagnostic[]>()
239+
const push = (target: string, items: Diagnostic[]) => {
240+
const existing = byFile.get(target) ?? []
241+
byFile.set(target, existing.concat(items))
242+
}
243+
244+
let handled = false
245+
if (Array.isArray(report.items)) {
246+
push(filePath, report.items)
247+
handled = true
248+
}
249+
for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) {
250+
const relatedPath = getFilePath(uri)
251+
if (!relatedPath || !Array.isArray(related.items)) continue
252+
push(relatedPath, related.items)
253+
handled = true
254+
}
255+
256+
return { handled, byFile }
257+
}
258+
259+
async function requestDiagnostics(filePath: string) {
260+
const results = [await requestDiagnosticReport(filePath)]
261+
const identifiers = new Set(
262+
[...diagnosticRegistrations.values()]
263+
.filter((registration) => registration.registerOptions?.workspaceDiagnostics !== true)
264+
.map((registration) => registration.registerOptions?.identifier)
265+
.filter((identifier): identifier is string => Boolean(identifier)),
266+
)
267+
for (const identifier of identifiers) {
268+
results.push(await requestDiagnosticReport(filePath, identifier))
269+
}
270+
271+
const handled = results.some((result) => result.handled)
272+
if (!handled) return false
273+
274+
const merged = new Map<string, Diagnostic[]>()
275+
for (const result of results) {
276+
for (const [target, items] of result.byFile.entries()) {
277+
const existing = merged.get(target) ?? []
278+
merged.set(target, existing.concat(items))
279+
}
280+
}
281+
282+
if (!merged.has(filePath)) merged.set(filePath, [])
283+
for (const [target, items] of merged.entries()) {
284+
updateDiagnostics(target, dedupeDiagnostics(items))
285+
}
286+
287+
return true
288+
}
289+
138290
const result = {
139291
root: input.root,
140292
get serverID() {
@@ -150,8 +302,8 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
150302
const extension = path.extname(request.path)
151303
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
152304

153-
const version = files[request.path]
154-
if (version !== undefined) {
305+
const document = files[request.path]
306+
if (document !== undefined) {
155307
log.info("workspace/didChangeWatchedFiles", request)
156308
await connection.sendNotification("workspace/didChangeWatchedFiles", {
157309
changes: [
@@ -162,8 +314,8 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
162314
],
163315
})
164316

165-
const next = version + 1
166-
files[request.path] = next
317+
const next = document.version + 1
318+
files[request.path] = { version: next, text }
167319
log.info("textDocument/didChange", {
168320
path: request.path,
169321
version: next,
@@ -173,7 +325,19 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
173325
uri: pathToFileURL(request.path).href,
174326
version: next,
175327
},
176-
contentChanges: [{ text }],
328+
contentChanges:
329+
syncKind === 2
330+
? [
331+
{
332+
range: {
333+
start: { line: 0, character: 0 },
334+
end: endPosition(document.text),
335+
},
336+
rangeLength: document.text.length,
337+
text,
338+
},
339+
]
340+
: [{ text }],
177341
})
178342
return
179343
}
@@ -198,7 +362,7 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
198362
text,
199363
},
200364
})
201-
files[request.path] = 0
365+
files[request.path] = { version: 0, text }
202366
return
203367
},
204368
},
@@ -212,21 +376,35 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
212376
log.info("waiting for diagnostics", { path: normalizedPath })
213377
let unsub: () => void
214378
let debounceTimer: ReturnType<typeof setTimeout> | undefined
379+
let pushed = false
380+
let firstHandledAt: number | undefined
215381
return await withTimeout(
216-
new Promise<void>((resolve) => {
382+
(async () => {
217383
unsub = Bus.subscribe(Event.Diagnostics, (event) => {
218-
if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) {
219-
// Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax)
220-
if (debounceTimer) clearTimeout(debounceTimer)
221-
debounceTimer = setTimeout(() => {
384+
if (event.properties.path !== normalizedPath || event.properties.serverID !== result.serverID) return
385+
if (debounceTimer) clearTimeout(debounceTimer)
386+
debounceTimer = setTimeout(() => {
387+
pushed = true
388+
}, DIAGNOSTICS_DEBOUNCE_MS)
389+
})
390+
await new Promise((resolve) => setTimeout(resolve, DIAGNOSTICS_DEBOUNCE_MS))
391+
while (true) {
392+
if (pushed) {
393+
log.info("got diagnostics", { path: normalizedPath })
394+
return
395+
}
396+
const handled = await requestDiagnostics(normalizedPath)
397+
if (handled) {
398+
firstHandledAt = firstHandledAt ?? Date.now()
399+
if (Date.now() - firstHandledAt >= DIAGNOSTICS_SETTLE_MS) {
222400
log.info("got diagnostics", { path: normalizedPath })
223-
unsub?.()
224-
resolve()
225-
}, DIAGNOSTICS_DEBOUNCE_MS)
401+
return
402+
}
226403
}
227-
})
228-
}),
229-
3000,
404+
await new Promise((resolve) => setTimeout(resolve, DIAGNOSTICS_POLL_MS))
405+
}
406+
})(),
407+
DIAGNOSTICS_WAIT_TIMEOUT_MS,
230408
)
231409
.catch(() => {})
232410
.finally(() => {

packages/opencode/test/fixture/lsp/fake-lsp-server.js

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Implements a minimal LSP handshake and triggers a request upon notification
33

44
let nextId = 1
5+
let lastDidChange = null
56

67
function encode(message) {
78
const json = JSON.stringify(message)
@@ -53,7 +54,18 @@ function handle(raw) {
5354
return
5455
}
5556
if (data.method === "initialize") {
56-
send({ jsonrpc: "2.0", id: data.id, result: { capabilities: {} } })
57+
send({
58+
jsonrpc: "2.0",
59+
id: data.id,
60+
result: {
61+
capabilities: {
62+
textDocumentSync: {
63+
openClose: true,
64+
change: 2,
65+
},
66+
},
67+
},
68+
})
5769
return
5870
}
5971
if (data.method === "initialized") {
@@ -62,6 +74,14 @@ function handle(raw) {
6274
if (data.method === "workspace/didChangeConfiguration") {
6375
return
6476
}
77+
if (data.method === "textDocument/didChange") {
78+
lastDidChange = data.params
79+
return
80+
}
81+
if (data.method === "test/get-last-change") {
82+
send({ jsonrpc: "2.0", id: data.id, result: lastDidChange })
83+
return
84+
}
6585
if (data.method === "test/trigger") {
6686
const method = data.params && data.params.method
6787
if (method) sendRequest(method, {})

0 commit comments

Comments
 (0)