| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252 |
- import { BusEvent } from "@/bus/bus-event"
- import { Bus } from "@/bus"
- import path from "path"
- import { pathToFileURL, fileURLToPath } from "url"
- import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
- import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
- import { Log } from "../util/log"
- import { LANGUAGE_EXTENSIONS } from "./language"
- import z from "zod"
- import type { LSPServer } from "./server"
- import { NamedError } from "@opencode-ai/util/error"
- import { withTimeout } from "../util/timeout"
- import { Instance } from "../project/instance"
- import { Filesystem } from "../util/filesystem"
- const DIAGNOSTICS_DEBOUNCE_MS = 150
- export namespace LSPClient {
- const log = Log.create({ service: "lsp.client" })
- export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
- export type Diagnostic = VSCodeDiagnostic
- export const InitializeError = NamedError.create(
- "LSPInitializeError",
- z.object({
- serverID: z.string(),
- }),
- )
- export const Event = {
- Diagnostics: BusEvent.define(
- "lsp.client.diagnostics",
- z.object({
- serverID: z.string(),
- path: z.string(),
- }),
- ),
- }
- export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
- const l = log.clone().tag("serverID", input.serverID)
- l.info("starting client")
- const connection = createMessageConnection(
- new StreamMessageReader(input.server.process.stdout as any),
- new StreamMessageWriter(input.server.process.stdin as any),
- )
- const diagnostics = new Map<string, Diagnostic[]>()
- connection.onNotification("textDocument/publishDiagnostics", (params) => {
- const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
- l.info("textDocument/publishDiagnostics", {
- path: filePath,
- count: params.diagnostics.length,
- })
- 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 })
- })
- connection.onRequest("window/workDoneProgress/create", (params) => {
- l.info("window/workDoneProgress/create", params)
- return null
- })
- connection.onRequest("workspace/configuration", async () => {
- // Return server initialization options
- return [input.server.initialization ?? {}]
- })
- connection.onRequest("client/registerCapability", async () => {})
- connection.onRequest("client/unregisterCapability", async () => {})
- connection.onRequest("workspace/workspaceFolders", async () => [
- {
- name: "workspace",
- uri: pathToFileURL(input.root).href,
- },
- ])
- connection.listen()
- l.info("sending initialize")
- await withTimeout(
- connection.sendRequest("initialize", {
- rootUri: pathToFileURL(input.root).href,
- processId: input.server.process.pid,
- workspaceFolders: [
- {
- name: "workspace",
- uri: pathToFileURL(input.root).href,
- },
- ],
- initializationOptions: {
- ...input.server.initialization,
- },
- capabilities: {
- window: {
- workDoneProgress: true,
- },
- workspace: {
- configuration: true,
- didChangeWatchedFiles: {
- dynamicRegistration: true,
- },
- },
- textDocument: {
- synchronization: {
- didOpen: true,
- didChange: true,
- },
- publishDiagnostics: {
- versionSupport: true,
- },
- },
- },
- }),
- 45_000,
- ).catch((err) => {
- l.error("initialize error", { error: err })
- throw new InitializeError(
- { serverID: input.serverID },
- {
- cause: err,
- },
- )
- })
- await connection.sendNotification("initialized", {})
- if (input.server.initialization) {
- await connection.sendNotification("workspace/didChangeConfiguration", {
- settings: input.server.initialization,
- })
- }
- const files: {
- [path: string]: number
- } = {}
- const result = {
- root: input.root,
- get serverID() {
- return input.serverID
- },
- get connection() {
- return connection
- },
- notify: {
- async open(input: { path: string }) {
- input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
- const file = Bun.file(input.path)
- const text = await file.text()
- const extension = path.extname(input.path)
- const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
- const version = files[input.path]
- if (version !== undefined) {
- log.info("workspace/didChangeWatchedFiles", input)
- await connection.sendNotification("workspace/didChangeWatchedFiles", {
- changes: [
- {
- uri: pathToFileURL(input.path).href,
- type: 2, // Changed
- },
- ],
- })
- const next = version + 1
- files[input.path] = next
- log.info("textDocument/didChange", {
- path: input.path,
- version: next,
- })
- await connection.sendNotification("textDocument/didChange", {
- textDocument: {
- uri: pathToFileURL(input.path).href,
- version: next,
- },
- contentChanges: [{ text }],
- })
- return
- }
- log.info("workspace/didChangeWatchedFiles", input)
- await connection.sendNotification("workspace/didChangeWatchedFiles", {
- changes: [
- {
- uri: pathToFileURL(input.path).href,
- type: 1, // Created
- },
- ],
- })
- log.info("textDocument/didOpen", input)
- diagnostics.delete(input.path)
- await connection.sendNotification("textDocument/didOpen", {
- textDocument: {
- uri: pathToFileURL(input.path).href,
- languageId,
- version: 0,
- text,
- },
- })
- files[input.path] = 0
- return
- },
- },
- get diagnostics() {
- return diagnostics
- },
- async waitForDiagnostics(input: { path: string }) {
- const normalizedPath = Filesystem.normalizePath(
- path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.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?.()
- })
- },
- async shutdown() {
- l.info("shutting down")
- connection.end()
- connection.dispose()
- input.server.process.kill()
- l.info("shutdown")
- },
- }
- l.info("initialized")
- return result
- }
- }
|