summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorLuke Parker <[email protected]>2026-04-23 09:24:11 +1000
committerAiden Cline <[email protected]>2026-04-23 00:25:40 -0400
commite383df4b17eecd6e6718e43c17438aa2eb818ee9 (patch)
tree4eb767f03d5ce5b522c5ffa172cb8f590e04c409 /packages
parent58db41b4b9fac2bfcf1f935cc114b3e4a069eade (diff)
downloadopencode-e383df4b17eecd6e6718e43c17438aa2eb818ee9.tar.gz
opencode-e383df4b17eecd6e6718e43c17438aa2eb818ee9.zip
feat: support pull diagnostics in the LSP client (C#, Kotlin, etc) (#23771)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/src/cli/cmd/debug/lsp.ts3
-rw-r--r--packages/opencode/src/lsp/client.ts574
-rw-r--r--packages/opencode/src/lsp/lsp.ts16
-rw-r--r--packages/opencode/src/lsp/server.ts2
-rw-r--r--packages/opencode/src/npm/index.ts15
-rw-r--r--packages/opencode/src/tool/apply_patch.ts2
-rw-r--r--packages/opencode/src/tool/edit.ts2
-rw-r--r--packages/opencode/src/tool/lsp.ts2
-rw-r--r--packages/opencode/src/tool/read.ts2
-rw-r--r--packages/opencode/src/tool/write.ts2
-rw-r--r--packages/opencode/test/fixture/lsp/fake-lsp-server.js220
-rw-r--r--packages/opencode/test/lsp/client.test.ts404
12 files changed, 1123 insertions, 121 deletions
diff --git a/packages/opencode/src/cli/cmd/debug/lsp.ts b/packages/opencode/src/cli/cmd/debug/lsp.ts
index 185cab9c7..47db6358b 100644
--- a/packages/opencode/src/cli/cmd/debug/lsp.ts
+++ b/packages/opencode/src/cli/cmd/debug/lsp.ts
@@ -23,8 +23,7 @@ const DiagnosticsCommand = cmd({
const out = await AppRuntime.runPromise(
LSP.Service.use((lsp) =>
Effect.gen(function* () {
- yield* lsp.touchFile(args.file, true)
- yield* Effect.sleep(1000)
+ yield* lsp.touchFile(args.file, "full")
return yield* lsp.diagnostics()
}),
),
diff --git a/packages/opencode/src/lsp/client.ts b/packages/opencode/src/lsp/client.ts
index b20e8ae7f..b0418ca3f 100644
--- a/packages/opencode/src/lsp/client.ts
+++ b/packages/opencode/src/lsp/client.ts
@@ -14,6 +14,16 @@ import { withTimeout } from "../util/timeout"
import { Filesystem } from "../util"
const DIAGNOSTICS_DEBOUNCE_MS = 150
+const DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS = 5_000
+const DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS = 10_000
+const DIAGNOSTICS_REQUEST_TIMEOUT_MS = 3_000
+
+const INITIALIZE_TIMEOUT_MS = 45_000
+
+// LSP spec constants
+const FILE_CHANGE_CREATED = 1
+const FILE_CHANGE_CHANGED = 2
+const TEXT_DOCUMENT_SYNC_INCREMENTAL = 2
const log = Log.create({ service: "lsp.client" })
@@ -38,48 +48,194 @@ export const Event = {
),
}
+type DocumentDiagnosticReport = {
+ items?: Diagnostic[]
+ relatedDocuments?: Record<string, DocumentDiagnosticReport>
+}
+
+type WorkspaceDiagnosticReport = {
+ items?: {
+ uri?: string
+ items?: Diagnostic[]
+ }[]
+}
+
+type DiagnosticRequestResult = {
+ handled: boolean
+ matched: boolean
+ byFile: Map<string, Diagnostic[]>
+}
+
+type CapabilityRegistration = {
+ id: string
+ method: string
+ registerOptions?: {
+ identifier?: string
+ workspaceDiagnostics?: boolean
+ }
+}
+
+type ServerCapabilities = {
+ textDocumentSync?:
+ | number
+ | {
+ change?: number
+ }
+ diagnosticProvider?: unknown
+ [key: string]: unknown
+}
+
+function getFilePath(uri: string) {
+ if (!uri.startsWith("file://")) return
+ return Filesystem.normalizePath(fileURLToPath(uri))
+}
+
+function getSyncKind(capabilities?: ServerCapabilities) {
+ if (!capabilities) return
+ const sync = capabilities.textDocumentSync
+ if (typeof sync === "number") return sync
+ return sync?.change
+}
+
+function endPosition(text: string) {
+ const lines = text.split(/\r\n|\r|\n/)
+ return {
+ line: lines.length - 1,
+ character: lines.at(-1)?.length ?? 0,
+ }
+}
+
+function dedupeDiagnostics(items: Diagnostic[]) {
+ const seen = new Set<string>()
+ return items.filter((item) => {
+ const key = JSON.stringify({
+ code: item.code,
+ severity: item.severity,
+ message: item.message,
+ source: item.source,
+ range: item.range,
+ })
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+}
+
+function configurationValue(settings: unknown, section?: string) {
+ if (!section) return settings ?? null
+ const result = section.split(".").reduce<unknown>((acc, key) => {
+ if (!acc || typeof acc !== "object" || !(key in acc)) return undefined
+ return (acc as Record<string, unknown>)[key]
+ }, settings)
+ return result ?? null
+}
+
+// TypeScript's built-in LSP pushes diagnostics aggressively on first open.
+// We seed the push cache on the very first publish so waitForFreshPush can
+// resolve immediately instead of waiting for a second debounced push.
+function shouldSeedDiagnosticsOnFirstPush(serverID: string) {
+ return serverID === "typescript"
+}
+
export async function create(input: { serverID: string; server: LSPServer.Handle; root: string; directory: string }) {
- const l = log.clone().tag("serverID", input.serverID)
- l.info("starting client")
+ const logger = log.clone().tag("serverID", input.serverID)
+ logger.info("starting client")
const connection = createMessageConnection(
new StreamMessageReader(input.server.process.stdout as any),
new StreamMessageWriter(input.server.process.stdin as any),
)
+ // Server stderr can contain both real errors and routine informational logs,
+ // which is normal stderr practice for some tools. Keep the raw stream at
+ // debug so users can opt in with --print-logs --log-level DEBUG without
+ // polluting normal logs.
+ input.server.process.stderr?.on("data", (data: Buffer) => {
+ const text = data.toString().trim()
+ if (text) logger.debug("server stderr", { text: text.slice(0, 1000) })
+ })
+
+ // --- Connection state ---
+
+ const pushDiagnostics = new Map<string, Diagnostic[]>()
+ const pullDiagnostics = new Map<string, Diagnostic[]>()
+ const published = new Map<string, { at: number; version?: number }>()
+ const diagnosticRegistrations = new Map<string, CapabilityRegistration>()
+ const registrationListeners = new Set<() => void>()
+ const mergedDiagnostics = (filePath: string) =>
+ dedupeDiagnostics([...(pushDiagnostics.get(filePath) ?? []), ...(pullDiagnostics.get(filePath) ?? [])])
+ const updatePushDiagnostics = (filePath: string, next: Diagnostic[]) => {
+ pushDiagnostics.set(filePath, next)
+ Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
+ }
+ const updatePullDiagnostics = (filePath: string, next: Diagnostic[]) => {
+ pullDiagnostics.set(filePath, next)
+ }
+ const emitRegistrationChange = () => {
+ for (const listener of [...registrationListeners]) listener()
+ }
+
+ // --- LSP connection handlers ---
- const diagnostics = new Map<string, Diagnostic[]>()
connection.onNotification("textDocument/publishDiagnostics", (params) => {
- const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
- l.info("textDocument/publishDiagnostics", {
+ const filePath = getFilePath(params.uri)
+ if (!filePath) return
+ logger.info("textDocument/publishDiagnostics", {
path: filePath,
count: params.diagnostics.length,
+ version: params.version,
})
- const exists = diagnostics.has(filePath)
- diagnostics.set(filePath, params.diagnostics)
- if (!exists && input.serverID === "typescript") return
- Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
+ published.set(filePath, {
+ at: Date.now(),
+ version: typeof params.version === "number" ? params.version : undefined,
+ })
+ if (shouldSeedDiagnosticsOnFirstPush(input.serverID) && !pushDiagnostics.has(filePath)) {
+ pushDiagnostics.set(filePath, params.diagnostics)
+ return
+ }
+ updatePushDiagnostics(filePath, params.diagnostics)
})
connection.onRequest("window/workDoneProgress/create", (params) => {
- l.info("window/workDoneProgress/create", params)
+ logger.info("window/workDoneProgress/create", params)
return null
})
- connection.onRequest("workspace/configuration", async () => {
- // Return server initialization options
- return [input.server.initialization ?? {}]
+ connection.onRequest("workspace/configuration", async (params) => {
+ const items = (params as { items?: { section?: string }[] }).items ?? []
+ return items.map((item) => configurationValue(input.server.initialization, item.section))
+ })
+ connection.onRequest("client/registerCapability", async (params) => {
+ const registrations = (params as { registrations?: CapabilityRegistration[] }).registrations ?? []
+ let changed = false
+ for (const registration of registrations) {
+ if (registration.method !== "textDocument/diagnostic") continue
+ diagnosticRegistrations.set(registration.id, registration)
+ changed = true
+ }
+ if (changed) emitRegistrationChange()
+ })
+ connection.onRequest("client/unregisterCapability", async (params) => {
+ const registrations = (params as { unregisterations?: { id: string; method: string }[] }).unregisterations ?? []
+ let changed = false
+ for (const registration of registrations) {
+ if (registration.method !== "textDocument/diagnostic") continue
+ diagnosticRegistrations.delete(registration.id)
+ changed = true
+ }
+ if (changed) emitRegistrationChange()
})
- connection.onRequest("client/registerCapability", async () => {})
- connection.onRequest("client/unregisterCapability", async () => {})
connection.onRequest("workspace/workspaceFolders", async () => [
{
name: "workspace",
uri: pathToFileURL(input.root).href,
},
])
+ connection.onRequest("workspace/diagnostic/refresh", async () => null)
connection.listen()
- l.info("sending initialize")
- await withTimeout(
- connection.sendRequest("initialize", {
+ // --- Initialize handshake ---
+
+ logger.info("sending initialize")
+ const initialized = await withTimeout(
+ connection.sendRequest<{ capabilities?: ServerCapabilities }>("initialize", {
rootUri: pathToFileURL(input.root).href,
processId: input.server.process.pid,
workspaceFolders: [
@@ -100,21 +256,28 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
didChangeWatchedFiles: {
dynamicRegistration: true,
},
+ diagnostics: {
+ refreshSupport: false,
+ },
},
textDocument: {
synchronization: {
didOpen: true,
didChange: true,
},
+ diagnostic: {
+ dynamicRegistration: true,
+ relatedDocumentSupport: true,
+ },
publishDiagnostics: {
- versionSupport: true,
+ versionSupport: false,
},
},
},
}),
- 45_000,
+ INITIALIZE_TIMEOUT_MS,
).catch((err) => {
- l.error("initialize error", { error: err })
+ logger.error("initialize error", { error: err })
throw new InitializeError(
{ serverID: input.serverID },
{
@@ -123,6 +286,9 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
)
})
+ const syncKind = getSyncKind(initialized.capabilities)
+ const hasStaticPullDiagnostics = Boolean(initialized.capabilities?.diagnosticProvider)
+
await connection.sendNotification("initialized", {})
if (input.server.initialization) {
@@ -131,9 +297,271 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
})
}
- const files: {
- [path: string]: number
- } = {}
+ const files: Record<string, { version: number; text: string }> = {}
+
+ // --- Diagnostic helpers ---
+
+ const mergeResults = (filePath: string, results: DiagnosticRequestResult[]) => {
+ const handled = results.some((result) => result.handled)
+ const matched = results.some((result) => result.matched)
+ if (!handled) return { handled: false, matched: false }
+
+ const merged = new Map<string, Diagnostic[]>()
+ for (const result of results) {
+ for (const [target, items] of result.byFile.entries()) {
+ const existing = merged.get(target) ?? []
+ merged.set(target, existing.concat(items))
+ }
+ }
+
+ if (matched && !merged.has(filePath)) merged.set(filePath, [])
+ for (const [target, items] of merged.entries()) {
+ updatePullDiagnostics(target, dedupeDiagnostics(items))
+ }
+
+ return { handled, matched }
+ }
+
+ async function requestDiagnosticReport(filePath: string, identifier?: string): Promise<DiagnosticRequestResult> {
+ const report = await withTimeout(
+ connection.sendRequest<DocumentDiagnosticReport | null>("textDocument/diagnostic", {
+ ...(identifier ? { identifier } : {}),
+ textDocument: {
+ uri: pathToFileURL(filePath).href,
+ },
+ }),
+ DIAGNOSTICS_REQUEST_TIMEOUT_MS,
+ ).catch(() => null)
+ if (!report) return { handled: false, matched: false, byFile: new Map<string, Diagnostic[]>() }
+
+ const byFile = new Map<string, Diagnostic[]>()
+ const push = (target: string, items: Diagnostic[]) => {
+ const existing = byFile.get(target) ?? []
+ byFile.set(target, existing.concat(items))
+ }
+
+ let handled = false
+ let matched = false
+ if (Array.isArray(report.items)) {
+ push(filePath, report.items)
+ handled = true
+ matched = true
+ }
+ for (const [uri, related] of Object.entries(report.relatedDocuments ?? {})) {
+ const relatedPath = getFilePath(uri)
+ if (!relatedPath || !Array.isArray(related.items)) continue
+ push(relatedPath, related.items)
+ handled = true
+ matched = matched || relatedPath === filePath
+ }
+
+ return { handled, matched, byFile }
+ }
+
+ async function requestWorkspaceDiagnosticReport(filePath: string, identifier?: string): Promise<DiagnosticRequestResult> {
+ const report = await withTimeout(
+ connection.sendRequest<WorkspaceDiagnosticReport | null>("workspace/diagnostic", {
+ ...(identifier ? { identifier } : {}),
+ previousResultIds: [],
+ }),
+ DIAGNOSTICS_REQUEST_TIMEOUT_MS,
+ ).catch(() => null)
+ if (!report) return { handled: false, matched: false, byFile: new Map<string, Diagnostic[]>() }
+
+ const byFile = new Map<string, Diagnostic[]>()
+ let matched = false
+ for (const item of report.items ?? []) {
+ const relatedPath = item.uri ? getFilePath(item.uri) : undefined
+ if (!relatedPath || !Array.isArray(item.items)) continue
+ const existing = byFile.get(relatedPath) ?? []
+ byFile.set(relatedPath, existing.concat(item.items))
+ matched = matched || relatedPath === filePath
+ }
+
+ return { handled: true, matched, byFile }
+ }
+
+ function documentPullState() {
+ const documentRegistrations = [...diagnosticRegistrations.values()].filter(
+ (registration) => registration.registerOptions?.workspaceDiagnostics !== true,
+ )
+ return {
+ documentIdentifiers: [...new Set(documentRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? []))],
+ supported: hasStaticPullDiagnostics || documentRegistrations.length > 0,
+ }
+ }
+
+ function workspacePullState() {
+ const workspaceRegistrations = [...diagnosticRegistrations.values()].filter(
+ (registration) => registration.registerOptions?.workspaceDiagnostics === true,
+ )
+ return {
+ workspaceIdentifiers: [...new Set(workspaceRegistrations.flatMap((registration) => registration.registerOptions?.identifier ?? []))],
+ supported: workspaceRegistrations.length > 0,
+ }
+ }
+
+ const hasCurrentFileDiagnostics = (filePath: string, results: DiagnosticRequestResult[]) =>
+ results.some((result) => (result.byFile.get(filePath)?.length ?? 0) > 0)
+
+ async function requestDiagnostics(
+ filePath: string,
+ requests: Promise<DiagnosticRequestResult>[],
+ done: (results: DiagnosticRequestResult[]) => boolean,
+ ) {
+ if (!requests.length) return { handled: false, matched: false }
+
+ const results: DiagnosticRequestResult[] = []
+ return new Promise<{ handled: boolean; matched: boolean }>((resolve) => {
+ let pending = requests.length
+ let resolved = false
+ const finish = (merged: { handled: boolean; matched: boolean }, force = false) => {
+ if (resolved) return
+ if (!force && !done(results)) return
+ resolved = true
+ resolve(merged)
+ }
+
+ for (const request of requests) {
+ request.then((result) => {
+ results.push(result)
+ pending -= 1
+ const merged = mergeResults(filePath, results)
+ finish(merged)
+ if (pending === 0) finish(merged, true)
+ })
+ }
+ })
+ }
+
+ // LATENCY-CRITICAL: dispatch identifier pulls in parallel and unblock once one
+ // batch already produced diagnostics for the current file. Let slower pulls keep
+ // merging in the background; do not sequence identifier-by-identifier, and do
+ // not add a post-match settle/debounce delay. See PR #23771.
+ async function requestDocumentDiagnostics(filePath: string) {
+ const state = documentPullState()
+ if (!state.supported) return { handled: false, matched: false }
+ return requestDiagnostics(
+ filePath,
+ [
+ requestDiagnosticReport(filePath),
+ ...state.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)),
+ ],
+ (results) => hasCurrentFileDiagnostics(filePath, results),
+ )
+ }
+
+ async function requestFullDiagnostics(filePath: string) {
+ const documentState = documentPullState()
+ const workspaceState = workspacePullState()
+ if (!documentState.supported && !workspaceState.supported) return { handled: false, matched: false }
+ return mergeResults(
+ filePath,
+ await Promise.all([
+ ...(documentState.supported ? [requestDiagnosticReport(filePath)] : []),
+ ...documentState.documentIdentifiers.map((identifier) => requestDiagnosticReport(filePath, identifier)),
+ ...(workspaceState.supported ? [requestWorkspaceDiagnosticReport(filePath)] : []),
+ ...workspaceState.workspaceIdentifiers.map((identifier) => requestWorkspaceDiagnosticReport(filePath, identifier)),
+ ]),
+ )
+ }
+
+ function waitForRegistrationChange(timeout: number) {
+ if (timeout <= 0) return Promise.resolve(false)
+ return new Promise<boolean>((resolve) => {
+ let finished = false
+ let timer: ReturnType<typeof setTimeout> | undefined
+ const finish = (result: boolean) => {
+ if (finished) return
+ finished = true
+ if (timer) clearTimeout(timer)
+ registrationListeners.delete(listener)
+ resolve(result)
+ }
+ const listener = () => finish(true)
+ registrationListeners.add(listener)
+ timer = setTimeout(() => finish(false), timeout)
+ })
+ }
+
+ function waitForFreshPush(request: { path: string; version: number; after: number; timeout: number }) {
+ if (request.timeout <= 0) return Promise.resolve(false)
+ return new Promise<boolean>((resolve) => {
+ let finished = false
+ let debounceTimer: ReturnType<typeof setTimeout> | undefined
+ let timeoutTimer: ReturnType<typeof setTimeout> | undefined
+ let unsub: (() => void) | undefined
+ const finish = (result: boolean) => {
+ if (finished) return
+ finished = true
+ if (debounceTimer) clearTimeout(debounceTimer)
+ if (timeoutTimer) clearTimeout(timeoutTimer)
+ unsub?.()
+ resolve(result)
+ }
+ const schedule = () => {
+ const hit = published.get(request.path)
+ if (!hit) return
+ if (typeof hit.version === "number" && hit.version !== request.version) return
+ if (hit.at < request.after && hit.version !== request.version) return
+ if (debounceTimer) clearTimeout(debounceTimer)
+ debounceTimer = setTimeout(() => finish(true), Math.max(0, DIAGNOSTICS_DEBOUNCE_MS - (Date.now() - hit.at)))
+ }
+
+ timeoutTimer = setTimeout(() => finish(false), request.timeout)
+ unsub = Bus.subscribe(Event.Diagnostics, (event) => {
+ if (event.properties.path !== request.path || event.properties.serverID !== input.serverID) return
+ schedule()
+ })
+ schedule()
+ })
+ }
+
+ async function waitForDocumentDiagnostics(request: { path: string; version: number; after?: number }) {
+ const startedAt = request.after ?? Date.now()
+ const pushWait = waitForFreshPush({
+ path: request.path,
+ version: request.version,
+ after: startedAt,
+ timeout: DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS,
+ })
+
+ while (Date.now() - startedAt < DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS) {
+ const result = await requestDocumentDiagnostics(request.path)
+ if (result.matched) return
+ const remaining = DIAGNOSTICS_DOCUMENT_WAIT_TIMEOUT_MS - (Date.now() - startedAt)
+ if (remaining <= 0) return
+ const next = await Promise.race([
+ pushWait.then((ready) => (ready ? "push" : "timeout" as const)),
+ waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : "timeout" as const)),
+ ])
+ if (next !== "registration") return
+ }
+ }
+
+ async function waitForFullDiagnostics(request: { path: string; version: number; after?: number }) {
+ const startedAt = request.after ?? Date.now()
+ const pushWait = waitForFreshPush({
+ path: request.path,
+ version: request.version,
+ after: startedAt,
+ timeout: DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS,
+ })
+
+ while (Date.now() - startedAt < DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS) {
+ const result = await requestFullDiagnostics(request.path)
+ if (result.handled || result.matched) return
+ const remaining = DIAGNOSTICS_FULL_WAIT_TIMEOUT_MS - (Date.now() - startedAt)
+ if (remaining <= 0) return
+ const next = await Promise.race([
+ pushWait.then((ready) => (ready ? "push" : "timeout" as const)),
+ waitForRegistrationChange(remaining).then((changed) => (changed ? "registration" : "timeout" as const)),
+ ])
+ if (next !== "registration") return
+ }
+ }
+
+ // --- Public API ---
const result = {
root: input.root,
@@ -145,26 +573,32 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
},
notify: {
async open(request: { path: string }) {
- request.path = path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path)
+ request.path = Filesystem.normalizePath(
+ path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
+ )
const text = await Filesystem.readText(request.path)
const extension = path.extname(request.path)
const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
- const version = files[request.path]
- if (version !== undefined) {
- log.info("workspace/didChangeWatchedFiles", request)
+ const document = files[request.path]
+ if (document !== undefined) {
+ // Do not wipe diagnostics on didChange. Some servers (e.g. clangd) only
+ // re-emit diagnostics when the content actually changes, so clearing
+ // here would lose errors for no-op touchFile calls. Let the server's
+ // next push/pull overwrite naturally.
+ logger.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(request.path).href,
- type: 2, // Changed
+ type: FILE_CHANGE_CHANGED,
},
],
})
- const next = version + 1
- files[request.path] = next
- log.info("textDocument/didChange", {
+ const next = document.version + 1
+ files[request.path] = { version: next, text }
+ logger.info("textDocument/didChange", {
path: request.path,
version: next,
})
@@ -173,23 +607,35 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
uri: pathToFileURL(request.path).href,
version: next,
},
- contentChanges: [{ text }],
+ contentChanges:
+ syncKind === TEXT_DOCUMENT_SYNC_INCREMENTAL
+ ? [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: endPosition(document.text),
+ },
+ text,
+ },
+ ]
+ : [{ text }],
})
- return
+ return next
}
- log.info("workspace/didChangeWatchedFiles", request)
+ logger.info("workspace/didChangeWatchedFiles", request)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(request.path).href,
- type: 1, // Created
+ type: FILE_CHANGE_CREATED,
},
],
})
- log.info("textDocument/didOpen", request)
- diagnostics.delete(request.path)
+ logger.info("textDocument/didOpen", request)
+ pushDiagnostics.delete(request.path)
+ pullDiagnostics.delete(request.path)
await connection.sendNotification("textDocument/didOpen", {
textDocument: {
uri: pathToFileURL(request.path).href,
@@ -198,52 +644,42 @@ export async function create(input: { serverID: string; server: LSPServer.Handle
text,
},
})
- files[request.path] = 0
- return
+ files[request.path] = { version: 0, text }
+ return 0
},
},
get diagnostics() {
- return diagnostics
+ const result = new Map<string, Diagnostic[]>()
+ for (const key of new Set([...pushDiagnostics.keys(), ...pullDiagnostics.keys()])) {
+ result.set(key, mergedDiagnostics(key))
+ }
+ return result
},
- async waitForDiagnostics(request: { path: string }) {
+ async waitForDiagnostics(request: { path: string; version: number; mode?: "document" | "full"; after?: number }) {
const normalizedPath = Filesystem.normalizePath(
path.isAbsolute(request.path) ? request.path : path.resolve(input.directory, request.path),
)
- log.info("waiting for diagnostics", { path: normalizedPath })
- let unsub: () => void
- let debounceTimer: ReturnType<typeof setTimeout> | undefined
- return await withTimeout(
- new Promise<void>((resolve) => {
- unsub = Bus.subscribe(Event.Diagnostics, (event) => {
- if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) {
- // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax)
- if (debounceTimer) clearTimeout(debounceTimer)
- debounceTimer = setTimeout(() => {
- log.info("got diagnostics", { path: normalizedPath })
- unsub?.()
- resolve()
- }, DIAGNOSTICS_DEBOUNCE_MS)
- }
- })
- }),
- 3000,
- )
- .catch(() => {})
- .finally(() => {
- if (debounceTimer) clearTimeout(debounceTimer)
- unsub?.()
- })
+ logger.info("waiting for diagnostics", {
+ path: normalizedPath,
+ mode: request.mode ?? "full",
+ version: request.version,
+ })
+ if (request.mode === "document") {
+ await waitForDocumentDiagnostics({ path: normalizedPath, version: request.version, after: request.after })
+ return
+ }
+ await waitForFullDiagnostics({ path: normalizedPath, version: request.version, after: request.after })
},
async shutdown() {
- l.info("shutting down")
+ logger.info("shutting down")
connection.end()
connection.dispose()
await Process.stop(input.server.process)
- l.info("shutdown")
+ logger.info("shutdown")
},
}
- l.info("initialized")
+ logger.info("initialized")
return result
}
diff --git a/packages/opencode/src/lsp/lsp.ts b/packages/opencode/src/lsp/lsp.ts
index 833285e7b..4c46cd9aa 100644
--- a/packages/opencode/src/lsp/lsp.ts
+++ b/packages/opencode/src/lsp/lsp.ts
@@ -136,7 +136,7 @@ export interface Interface {
readonly init: () => Effect.Effect<void>
readonly status: () => Effect.Effect<Status[]>
readonly hasClients: (file: string) => Effect.Effect<boolean>
- readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect<void>
+ readonly touchFile: (input: string, diagnostics?: "document" | "full") => Effect.Effect<void>
readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
readonly hover: (input: LocInput) => Effect.Effect<any>
readonly definition: (input: LocInput) => Effect.Effect<any[]>
@@ -358,15 +358,21 @@ export const layer = Layer.effect(
})
})
- const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) {
+ const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, diagnostics?: "document" | "full") {
log.info("touching file", { file: input })
const clients = yield* getClients(input)
yield* Effect.promise(() =>
Promise.all(
clients.map(async (client) => {
- const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
- await client.notify.open({ path: input })
- return wait
+ const after = Date.now()
+ const version = await client.notify.open({ path: input })
+ if (!diagnostics) return
+ return client.waitForDiagnostics({
+ path: input,
+ version,
+ mode: diagnostics,
+ after,
+ })
}),
).catch((err) => {
log.error("failed to touch file", { err, file: input })
diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts
index 8bb70a511..a0cb8fe38 100644
--- a/packages/opencode/src/lsp/server.ts
+++ b/packages/opencode/src/lsp/server.ts
@@ -490,7 +490,7 @@ export const Pyright: Info = {
const args = []
if (!binary) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
- const resolved = await Npm.which("pyright")
+ const resolved = await Npm.which("pyright", "pyright-langserver")
if (!resolved) return
binary = resolved
}
diff --git a/packages/opencode/src/npm/index.ts b/packages/opencode/src/npm/index.ts
index 477e99e06..fc8497d20 100644
--- a/packages/opencode/src/npm/index.ts
+++ b/packages/opencode/src/npm/index.ts
@@ -34,7 +34,7 @@ export interface Interface {
},
) => Effect.Effect<void, EffectFlock.LockError | InstallFailedError>
readonly outdated: (pkg: string, cachedVersion: string) => Effect.Effect<boolean>
- readonly which: (pkg: string) => Effect.Effect<Option.Option<string>>
+ readonly which: (pkg: string, bin?: string) => Effect.Effect<Option.Option<string>>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Npm") {}
@@ -207,7 +207,7 @@ export const layer = Layer.effect(
return
}, Effect.scoped)
- const which = Effect.fn("Npm.which")(function* (pkg: string) {
+ const which = Effect.fn("Npm.which")(function* (pkg: string, bin?: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
@@ -215,6 +215,9 @@ export const layer = Layer.effect(
const files = yield* fs.readDirectory(binDir).pipe(Effect.catch(() => Effect.succeed([] as string[])))
if (files.length === 0) return Option.none<string>()
+ // Caller picked a specific bin (e.g. pyright exposes both `pyright` and
+ // `pyright-langserver`); trust the hint if the package provides it.
+ if (bin) return files.includes(bin) ? Option.some(bin) : Option.none<string>()
if (files.length === 1) return Option.some(files[0])
const pkgJson = yield* afs.readJson(path.join(dir, "node_modules", pkg, "package.json")).pipe(Effect.option)
@@ -223,11 +226,11 @@ export const layer = Layer.effect(
const parsed = pkgJson.value as { bin?: string | Record<string, string> }
if (parsed?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
- const bin = parsed.bin
- if (typeof bin === "string") return Option.some(unscoped)
- const keys = Object.keys(bin)
+ const parsedBin = parsed.bin
+ if (typeof parsedBin === "string") return Option.some(unscoped)
+ const keys = Object.keys(parsedBin)
if (keys.length === 1) return Option.some(keys[0])
- return bin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
+ return parsedBin[unscoped] ? Option.some(unscoped) : Option.some(keys[0])
}
}
diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts
index a4cf1e853..33112c43c 100644
--- a/packages/opencode/src/tool/apply_patch.ts
+++ b/packages/opencode/src/tool/apply_patch.ts
@@ -258,7 +258,7 @@ export const ApplyPatchTool = Tool.define(
for (const change of fileChanges) {
if (change.type === "delete") continue
const target = change.movePath ?? change.filePath
- yield* lsp.touchFile(target, true)
+ yield* lsp.touchFile(target, "document")
}
const diagnostics = yield* lsp.diagnostics()
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index 858d14e04..35dd85b47 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/packages/opencode/src/tool/edit.ts
@@ -186,7 +186,7 @@ export const EditTool = Tool.define(
})
let output = "Edit applied successfully."
- yield* lsp.touchFile(filePath, true)
+ yield* lsp.touchFile(filePath, "document")
const diagnostics = yield* lsp.diagnostics()
const normalizedFilePath = AppFileSystem.normalizePath(filePath)
const block = LSP.Diagnostic.report(filePath, diagnostics[normalizedFilePath] ?? [])
diff --git a/packages/opencode/src/tool/lsp.ts b/packages/opencode/src/tool/lsp.ts
index 263bfe81d..0a0edc61e 100644
--- a/packages/opencode/src/tool/lsp.ts
+++ b/packages/opencode/src/tool/lsp.ts
@@ -55,7 +55,7 @@ export const LspTool = Tool.define(
const available = yield* lsp.hasClients(file)
if (!available) throw new Error("No LSP server available for this file type.")
- yield* lsp.touchFile(file, true)
+ yield* lsp.touchFile(file, "document")
const result: unknown[] = yield* (() => {
switch (args.operation) {
diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts
index c9b304862..a9b95346a 100644
--- a/packages/opencode/src/tool/read.ts
+++ b/packages/opencode/src/tool/read.ts
@@ -75,7 +75,7 @@ export const ReadTool = Tool.define(
})
const warm = Effect.fn("ReadTool.warm")(function* (filepath: string) {
- yield* lsp.touchFile(filepath, false).pipe(Effect.ignore, Effect.forkIn(scope))
+ yield* lsp.touchFile(filepath).pipe(Effect.ignore, Effect.forkIn(scope))
})
const readSample = Effect.fn("ReadTool.readSample")(function* (
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index 79ed58519..80198f455 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/packages/opencode/src/tool/write.ts
@@ -67,7 +67,7 @@ export const WriteTool = Tool.define(
})
let output = "Wrote file successfully."
- yield* lsp.touchFile(filepath, true)
+ yield* lsp.touchFile(filepath, "document")
const diagnostics = yield* lsp.diagnostics()
const normalizedFilepath = AppFileSystem.normalizePath(filepath)
let projectDiagnosticsCount = 0
diff --git a/packages/opencode/test/fixture/lsp/fake-lsp-server.js b/packages/opencode/test/fixture/lsp/fake-lsp-server.js
index be62f96f3..e6818009e 100644
--- a/packages/opencode/test/fixture/lsp/fake-lsp-server.js
+++ b/packages/opencode/test/fixture/lsp/fake-lsp-server.js
@@ -1,7 +1,23 @@
// Simple JSON-RPC 2.0 LSP-like fake server over stdio
-// Implements a minimal LSP handshake and triggers a request upon notification
let nextId = 1
+let readBuffer = Buffer.alloc(0)
+let lastChange = null
+let initializeParams = null
+let diagnosticRequestCount = 0
+let registeredCapability = false
+const pendingClientRequests = new Map()
+let pullConfig = {
+ delayMs: 0,
+ registerOn: undefined,
+ registrations: [],
+ documentDiagnostics: [],
+ documentDiagnosticsByIdentifier: {},
+ documentDelayMsByIdentifier: {},
+ workspaceDiagnostics: [],
+ workspaceDiagnosticsByIdentifier: {},
+ workspaceDelayMsByIdentifier: {},
+}
function encode(message) {
const json = JSON.stringify(message)
@@ -14,29 +30,19 @@ function decodeFrames(buffer) {
let idx
while ((idx = buffer.indexOf("\r\n\r\n")) !== -1) {
const header = buffer.slice(0, idx).toString("utf8")
- const m = /Content-Length:\s*(\d+)/i.exec(header)
- const len = m ? parseInt(m[1], 10) : 0
+ const match = /Content-Length:\s*(\d+)/i.exec(header)
+ const length = match ? parseInt(match[1], 10) : 0
const bodyStart = idx + 4
- const bodyEnd = bodyStart + len
+ const bodyEnd = bodyStart + length
if (buffer.length < bodyEnd) break
- const body = buffer.slice(bodyStart, bodyEnd).toString("utf8")
- results.push(body)
+ results.push(buffer.slice(bodyStart, bodyEnd).toString("utf8"))
buffer = buffer.slice(bodyEnd)
}
return { messages: results, rest: buffer }
}
-let readBuffer = Buffer.alloc(0)
-
-process.stdin.on("data", (chunk) => {
- readBuffer = Buffer.concat([readBuffer, chunk])
- const { messages, rest } = decodeFrames(readBuffer)
- readBuffer = rest
- for (const m of messages) handle(m)
-})
-
-function send(msg) {
- process.stdout.write(encode(msg))
+function send(message) {
+ process.stdout.write(encode(message))
}
function sendRequest(method, params) {
@@ -45,6 +51,50 @@ function sendRequest(method, params) {
return id
}
+function sendResponse(id, result) {
+ send({ jsonrpc: "2.0", id, result })
+}
+
+function sendNotification(method, params) {
+ send({ jsonrpc: "2.0", method, params })
+}
+
+function maybeRegister(method) {
+ if (pullConfig.registerOn !== method || registeredCapability) return
+ registeredCapability = true
+ sendRequest("client/registerCapability", {
+ registrations: pullConfig.registrations.map((registration, index) => ({
+ id: registration.id ?? `pull-${index}`,
+ method: registration.method ?? "textDocument/diagnostic",
+ registerOptions: registration.registerOptions ?? registration,
+ })),
+ })
+}
+
+function delayed(id, result, delayMs = pullConfig.delayMs) {
+ if (!delayMs) {
+ sendResponse(id, result)
+ return
+ }
+ setTimeout(() => sendResponse(id, result), delayMs)
+}
+
+function diagnosticsForIdentifier(identifier) {
+ return pullConfig.documentDiagnosticsByIdentifier[identifier] ?? pullConfig.documentDiagnostics
+}
+
+function workspaceDiagnosticsForIdentifier(identifier) {
+ return pullConfig.workspaceDiagnosticsByIdentifier[identifier] ?? pullConfig.workspaceDiagnostics
+}
+
+function documentDelayForIdentifier(identifier) {
+ return pullConfig.documentDelayMsByIdentifier[identifier] ?? pullConfig.delayMs
+}
+
+function workspaceDelayForIdentifier(identifier) {
+ return pullConfig.workspaceDelayMsByIdentifier[identifier] ?? pullConfig.delayMs
+}
+
function handle(raw) {
let data
try {
@@ -52,24 +102,148 @@ function handle(raw) {
} catch {
return
}
+
+ if (typeof data.method === "undefined" && typeof data.id !== "undefined") {
+ const pending = pendingClientRequests.get(data.id)
+ if (!pending) return
+ pendingClientRequests.delete(data.id)
+ sendResponse(pending, data.result ?? null)
+ return
+ }
+
if (data.method === "initialize") {
- send({ jsonrpc: "2.0", id: data.id, result: { capabilities: {} } })
+ initializeParams = data.params
+ sendResponse(data.id, {
+ capabilities: {
+ textDocumentSync: {
+ change: 2,
+ },
+ },
+ })
return
}
- if (data.method === "initialized") {
+
+ if (data.method === "test/get-initialize-params") {
+ sendResponse(data.id, initializeParams)
return
}
- if (data.method === "workspace/didChangeConfiguration") {
+
+ if (data.method === "test/request-configuration") {
+ const id = sendRequest("workspace/configuration", data.params)
+ pendingClientRequests.set(id, data.id)
+ return
+ }
+
+ if (data.method === "initialized" || data.method === "workspace/didChangeConfiguration") {
return
}
+
+ if (data.method === "textDocument/didOpen") {
+ maybeRegister("didOpen")
+ return
+ }
+
+ if (data.method === "textDocument/didChange") {
+ lastChange = data.params
+ maybeRegister("didChange")
+ return
+ }
+
if (data.method === "test/trigger") {
const method = data.params && data.params.method
+ if (method === "client/registerCapability") {
+ sendRequest(method, {
+ registrations: [
+ {
+ id: "test-diagnostic-registration",
+ method: "textDocument/diagnostic",
+ registerOptions: { identifier: "syntax" },
+ },
+ ],
+ })
+ return
+ }
+ if (method === "client/unregisterCapability") {
+ sendRequest(method, {
+ unregisterations: [{ id: "test-diagnostic-registration", method: "textDocument/diagnostic" }],
+ })
+ return
+ }
if (method) sendRequest(method, {})
return
}
- if (typeof data.id !== "undefined") {
- // Respond OK to any request from client to keep transport flowing
- send({ jsonrpc: "2.0", id: data.id, result: null })
+
+ if (data.method === "test/configure-pull-diagnostics") {
+ pullConfig = {
+ delayMs: data.params?.delayMs ?? 0,
+ registerOn: data.params?.registerOn,
+ registrations: data.params?.registrations ?? [],
+ documentDiagnostics: data.params?.documentDiagnostics ?? [],
+ documentDiagnosticsByIdentifier: data.params?.documentDiagnosticsByIdentifier ?? {},
+ documentDelayMsByIdentifier: data.params?.documentDelayMsByIdentifier ?? {},
+ workspaceDiagnostics: data.params?.workspaceDiagnostics ?? [],
+ workspaceDiagnosticsByIdentifier: data.params?.workspaceDiagnosticsByIdentifier ?? {},
+ workspaceDelayMsByIdentifier: data.params?.workspaceDelayMsByIdentifier ?? {},
+ }
+ registeredCapability = false
+ sendResponse(data.id, null)
+ return
+ }
+
+ if (data.method === "test/register-configured-pull-diagnostics") {
+ maybeRegister(undefined)
+ sendResponse(data.id, null)
+ return
+ }
+
+ if (data.method === "test/publish-diagnostics") {
+ sendNotification("textDocument/publishDiagnostics", data.params)
+ return
+ }
+
+ if (data.method === "test/get-last-change") {
+ sendResponse(data.id, lastChange)
return
}
+
+ if (data.method === "test/get-diagnostic-request-count") {
+ sendResponse(data.id, diagnosticRequestCount)
+ return
+ }
+
+ if (data.method === "textDocument/diagnostic") {
+ diagnosticRequestCount += 1
+ delayed(
+ data.id,
+ {
+ kind: "full",
+ items: diagnosticsForIdentifier(data.params?.identifier ?? ""),
+ },
+ documentDelayForIdentifier(data.params?.identifier ?? ""),
+ )
+ return
+ }
+
+ if (data.method === "workspace/diagnostic") {
+ diagnosticRequestCount += 1
+ delayed(
+ data.id,
+ {
+ items: workspaceDiagnosticsForIdentifier(data.params?.identifier ?? ""),
+ },
+ workspaceDelayForIdentifier(data.params?.identifier ?? ""),
+ )
+ return
+ }
+
+ if (typeof data.id !== "undefined") {
+ sendResponse(data.id, null)
+ }
}
+
+process.stdin.on("data", (chunk) => {
+ readBuffer = Buffer.concat([readBuffer, chunk])
+ const { messages, rest } = decodeFrames(readBuffer)
+ readBuffer = rest
+ for (const message of messages) handle(message)
+})
diff --git a/packages/opencode/test/lsp/client.test.ts b/packages/opencode/test/lsp/client.test.ts
index d6eaa317f..4862f6839 100644
--- a/packages/opencode/test/lsp/client.test.ts
+++ b/packages/opencode/test/lsp/client.test.ts
@@ -1,11 +1,12 @@
-import { describe, expect, test, beforeEach } from "bun:test"
+import { beforeEach, describe, expect, test } from "bun:test"
import path from "path"
+import { pathToFileURL } from "url"
+import { tmpdir } from "../fixture/fixture"
import { LSPClient } from "../../src/lsp"
import { LSPServer } from "../../src/lsp"
import { Instance } from "../../src/project/instance"
import { Log } from "../../src/util"
-// Minimal fake LSP server that speaks JSON-RPC over stdio
function spawnFakeServer() {
const { spawn } = require("child_process")
const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
@@ -39,10 +40,8 @@ describe("LSPClient interop", () => {
method: "workspace/workspaceFolders",
})
- await new Promise((r) => setTimeout(r, 100))
-
+ await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
-
await client.shutdown()
})
@@ -64,10 +63,8 @@ describe("LSPClient interop", () => {
method: "client/registerCapability",
})
- await new Promise((r) => setTimeout(r, 100))
-
+ await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
-
await client.shutdown()
})
@@ -89,10 +86,397 @@ describe("LSPClient interop", () => {
method: "client/unregisterCapability",
})
- await new Promise((r) => setTimeout(r, 100))
-
+ await new Promise((resolve) => setTimeout(resolve, 100))
expect(client.connection).toBeDefined()
+ await client.shutdown()
+ })
+
+ test("initialize does not overclaim unsupported diagnostics capabilities", async () => {
+ const handle = spawnFakeServer() as any
+
+ const client = await Instance.provide({
+ directory: process.cwd(),
+ fn: () =>
+ LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: process.cwd(),
+ directory: process.cwd(),
+ }),
+ })
+
+ const params = await client.connection.sendRequest<any>("test/get-initialize-params", {})
+ expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false)
+ expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false)
await client.shutdown()
})
+
+ test("workspace/configuration returns one result per requested item", async () => {
+ const handle = spawnFakeServer() as any
+ const initialization = {
+ alpha: {
+ beta: 1,
+ },
+ gamma: true,
+ }
+
+ const client = await Instance.provide({
+ directory: process.cwd(),
+ fn: () =>
+ LSPClient.create({
+ serverID: "fake",
+ server: {
+ ...(handle as unknown as LSPServer.Handle),
+ initialization,
+ },
+ root: process.cwd(),
+ directory: process.cwd(),
+ }),
+ })
+
+ const response = await client.connection.sendRequest<any[]>("test/request-configuration", {
+ items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}],
+ })
+
+ expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
+
+ await client.shutdown()
+ })
+
+ test("sends ranged didChange for incremental sync servers", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "client.ts")
+ await Bun.write(file, "first\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ })
+
+ await client.notify.open({ path: file })
+ await Bun.write(file, "second\nthird\n")
+ await client.notify.open({ path: file })
+
+ const change = await client.connection.sendRequest<{
+ textDocument: { version: number }
+ contentChanges: {
+ range?: { start: { line: number; character: number }; end: { line: number; character: number } }
+ text: string
+ }[]
+ }>("test/get-last-change", {})
+ expect(change.textDocument.version).toBe(1)
+ expect(change.contentChanges).toEqual([
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 1, character: 0 },
+ },
+ text: "second\nthird\n",
+ },
+ ])
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("document mode falls back to push diagnostics", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "client.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ })
+
+ const version = await client.notify.open({ path: file })
+ const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
+ await client.connection.sendNotification("test/publish-diagnostics", {
+ uri: pathToFileURL(file).href,
+ version,
+ diagnostics: [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 5 },
+ },
+ message: "push diagnostic",
+ severity: 1,
+ },
+ ],
+ })
+ await wait
+
+ const diagnostics = client.diagnostics.get(file) ?? []
+ expect(diagnostics).toHaveLength(1)
+ expect(diagnostics[0]?.message).toBe("push diagnostic")
+
+ const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
+ expect(count).toBe(0)
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("document mode accepts matching push diagnostics published before waiting", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "client.ts")
+ await Bun.write(file, "const x = 1\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ })
+
+ const version = await client.notify.open({ path: file })
+ await client.connection.sendNotification("test/publish-diagnostics", {
+ uri: pathToFileURL(file).href,
+ version,
+ diagnostics: [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 5 },
+ },
+ message: "push diagnostic",
+ severity: 1,
+ },
+ ],
+ })
+
+ for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) {
+ await new Promise((resolve) => setTimeout(resolve, 25))
+ }
+
+ expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic")
+
+ const started = Date.now()
+ await client.waitForDiagnostics({ path: file, version, mode: "document" })
+ expect(Date.now() - started).toBeLessThan(1_000)
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("document mode waits for pull diagnostics", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "client.cs")
+ await Bun.write(file, "class C {}\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ })
+
+ await client.connection.sendRequest("test/configure-pull-diagnostics", {
+ registerOn: "didOpen",
+ registrations: [{ identifier: "DocumentCompilerSemantic" }],
+ documentDiagnosticsByIdentifier: {
+ DocumentCompilerSemantic: [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 5 },
+ },
+ message: "pull diagnostic",
+ severity: 1,
+ },
+ ],
+ },
+ })
+
+ const version = await client.notify.open({ path: file })
+ await client.waitForDiagnostics({ path: file, version, mode: "document" })
+
+ const diagnostics = client.diagnostics.get(file) ?? []
+ expect(diagnostics).toHaveLength(1)
+ expect(diagnostics[0]?.message).toBe("pull diagnostic")
+
+ const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
+ expect(count).toBeGreaterThan(0)
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "client.cs")
+ await Bun.write(file, "class C {}\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ })
+
+ await client.connection.sendRequest("test/configure-pull-diagnostics", {
+ registrations: [{ identifier: "fast" }, { identifier: "slow" }],
+ documentDiagnosticsByIdentifier: {
+ fast: [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 5 },
+ },
+ message: "fast diagnostic",
+ severity: 1,
+ },
+ ],
+ slow: [],
+ },
+ documentDelayMsByIdentifier: {
+ slow: 2_500,
+ },
+ })
+
+ const version = await client.notify.open({ path: file })
+ await client.connection.sendRequest("test/register-configured-pull-diagnostics", {})
+ await new Promise((resolve) => setTimeout(resolve, 100))
+ const started = Date.now()
+ await client.waitForDiagnostics({ path: file, version, mode: "document" })
+
+ expect(Date.now() - started).toBeLessThan(1_000)
+ expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic")
+ expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1)
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("full mode includes workspace pull diagnostics", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "client.cs")
+ const related = path.join(tmp.path, "other.cs")
+ await Bun.write(file, "class C {}\n")
+ await Bun.write(related, "class D {}\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ })
+
+ await client.connection.sendRequest("test/configure-pull-diagnostics", {
+ registerOn: "didOpen",
+ registrations: [
+ { identifier: "DocumentCompilerSemantic" },
+ { identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true },
+ ],
+ documentDiagnosticsByIdentifier: {
+ DocumentCompilerSemantic: [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 5 },
+ },
+ message: "current file",
+ severity: 1,
+ },
+ ],
+ },
+ workspaceDiagnosticsByIdentifier: {
+ WorkspaceDocumentsAndProject: [
+ {
+ uri: pathToFileURL(related).href,
+ items: [
+ {
+ range: {
+ start: { line: 0, character: 0 },
+ end: { line: 0, character: 5 },
+ },
+ message: "workspace file",
+ severity: 1,
+ },
+ ],
+ },
+ ],
+ },
+ })
+
+ const version = await client.notify.open({ path: file })
+ await client.waitForDiagnostics({ path: file, version, mode: "full" })
+
+ expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file")
+ expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file")
+
+ await client.shutdown()
+ },
+ })
+ })
+
+ test("full mode treats an empty workspace pull response as handled", async () => {
+ const handle = spawnFakeServer() as any
+ await using tmp = await tmpdir()
+ const file = path.join(tmp.path, "client.cs")
+ await Bun.write(file, "class C {}\n")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const client = await LSPClient.create({
+ serverID: "fake",
+ server: handle as unknown as LSPServer.Handle,
+ root: tmp.path,
+ directory: tmp.path,
+ })
+
+ await client.connection.sendRequest("test/configure-pull-diagnostics", {
+ registerOn: "didOpen",
+ registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }],
+ workspaceDiagnosticsByIdentifier: {
+ WorkspaceDocumentsAndProject: [],
+ },
+ })
+
+ const version = await client.notify.open({ path: file })
+ const started = Date.now()
+ await client.waitForDiagnostics({ path: file, version, mode: "full" })
+
+ expect(Date.now() - started).toBeLessThan(1_000)
+
+ await client.shutdown()
+ },
+ })
+ })
})