client.ts 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. import { BusEvent } from "@/bus/bus-event"
  2. import { Bus } from "@/bus"
  3. import path from "path"
  4. import { pathToFileURL, fileURLToPath } from "url"
  5. import { createMessageConnection, StreamMessageReader, StreamMessageWriter } from "vscode-jsonrpc/node"
  6. import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
  7. import { Log } from "../util/log"
  8. import { LANGUAGE_EXTENSIONS } from "./language"
  9. import z from "zod"
  10. import type { LSPServer } from "./server"
  11. import { NamedError } from "@opencode-ai/util/error"
  12. import { withTimeout } from "../util/timeout"
  13. import { Instance } from "../project/instance"
  14. import { Filesystem } from "../util/filesystem"
  15. const DIAGNOSTICS_DEBOUNCE_MS = 150
  16. export namespace LSPClient {
  17. const log = Log.create({ service: "lsp.client" })
  18. export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
  19. export type Diagnostic = VSCodeDiagnostic
  20. export const InitializeError = NamedError.create(
  21. "LSPInitializeError",
  22. z.object({
  23. serverID: z.string(),
  24. }),
  25. )
  26. export const Event = {
  27. Diagnostics: BusEvent.define(
  28. "lsp.client.diagnostics",
  29. z.object({
  30. serverID: z.string(),
  31. path: z.string(),
  32. }),
  33. ),
  34. }
  35. export async function create(input: { serverID: string; server: LSPServer.Handle; root: string }) {
  36. const l = log.clone().tag("serverID", input.serverID)
  37. l.info("starting client")
  38. const connection = createMessageConnection(
  39. new StreamMessageReader(input.server.process.stdout as any),
  40. new StreamMessageWriter(input.server.process.stdin as any),
  41. )
  42. const diagnostics = new Map<string, Diagnostic[]>()
  43. connection.onNotification("textDocument/publishDiagnostics", (params) => {
  44. const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
  45. l.info("textDocument/publishDiagnostics", {
  46. path: filePath,
  47. count: params.diagnostics.length,
  48. })
  49. const exists = diagnostics.has(filePath)
  50. diagnostics.set(filePath, params.diagnostics)
  51. if (!exists && input.serverID === "typescript") return
  52. Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
  53. })
  54. connection.onRequest("window/workDoneProgress/create", (params) => {
  55. l.info("window/workDoneProgress/create", params)
  56. return null
  57. })
  58. connection.onRequest("workspace/configuration", async () => {
  59. // Return server initialization options
  60. return [input.server.initialization ?? {}]
  61. })
  62. connection.onRequest("client/registerCapability", async () => {})
  63. connection.onRequest("client/unregisterCapability", async () => {})
  64. connection.onRequest("workspace/workspaceFolders", async () => [
  65. {
  66. name: "workspace",
  67. uri: pathToFileURL(input.root).href,
  68. },
  69. ])
  70. connection.listen()
  71. l.info("sending initialize")
  72. await withTimeout(
  73. connection.sendRequest("initialize", {
  74. rootUri: pathToFileURL(input.root).href,
  75. processId: input.server.process.pid,
  76. workspaceFolders: [
  77. {
  78. name: "workspace",
  79. uri: pathToFileURL(input.root).href,
  80. },
  81. ],
  82. initializationOptions: {
  83. ...input.server.initialization,
  84. },
  85. capabilities: {
  86. window: {
  87. workDoneProgress: true,
  88. },
  89. workspace: {
  90. configuration: true,
  91. didChangeWatchedFiles: {
  92. dynamicRegistration: true,
  93. },
  94. },
  95. textDocument: {
  96. synchronization: {
  97. didOpen: true,
  98. didChange: true,
  99. },
  100. publishDiagnostics: {
  101. versionSupport: true,
  102. },
  103. },
  104. },
  105. }),
  106. 45_000,
  107. ).catch((err) => {
  108. l.error("initialize error", { error: err })
  109. throw new InitializeError(
  110. { serverID: input.serverID },
  111. {
  112. cause: err,
  113. },
  114. )
  115. })
  116. await connection.sendNotification("initialized", {})
  117. if (input.server.initialization) {
  118. await connection.sendNotification("workspace/didChangeConfiguration", {
  119. settings: input.server.initialization,
  120. })
  121. }
  122. const files: {
  123. [path: string]: number
  124. } = {}
  125. const result = {
  126. root: input.root,
  127. get serverID() {
  128. return input.serverID
  129. },
  130. get connection() {
  131. return connection
  132. },
  133. notify: {
  134. async open(input: { path: string }) {
  135. input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
  136. const file = Bun.file(input.path)
  137. const text = await file.text()
  138. const extension = path.extname(input.path)
  139. const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
  140. const version = files[input.path]
  141. if (version !== undefined) {
  142. log.info("workspace/didChangeWatchedFiles", input)
  143. await connection.sendNotification("workspace/didChangeWatchedFiles", {
  144. changes: [
  145. {
  146. uri: pathToFileURL(input.path).href,
  147. type: 2, // Changed
  148. },
  149. ],
  150. })
  151. const next = version + 1
  152. files[input.path] = next
  153. log.info("textDocument/didChange", {
  154. path: input.path,
  155. version: next,
  156. })
  157. await connection.sendNotification("textDocument/didChange", {
  158. textDocument: {
  159. uri: pathToFileURL(input.path).href,
  160. version: next,
  161. },
  162. contentChanges: [{ text }],
  163. })
  164. return
  165. }
  166. log.info("workspace/didChangeWatchedFiles", input)
  167. await connection.sendNotification("workspace/didChangeWatchedFiles", {
  168. changes: [
  169. {
  170. uri: pathToFileURL(input.path).href,
  171. type: 1, // Created
  172. },
  173. ],
  174. })
  175. log.info("textDocument/didOpen", input)
  176. diagnostics.delete(input.path)
  177. await connection.sendNotification("textDocument/didOpen", {
  178. textDocument: {
  179. uri: pathToFileURL(input.path).href,
  180. languageId,
  181. version: 0,
  182. text,
  183. },
  184. })
  185. files[input.path] = 0
  186. return
  187. },
  188. },
  189. get diagnostics() {
  190. return diagnostics
  191. },
  192. async waitForDiagnostics(input: { path: string }) {
  193. const normalizedPath = Filesystem.normalizePath(
  194. path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
  195. )
  196. log.info("waiting for diagnostics", { path: normalizedPath })
  197. let unsub: () => void
  198. let debounceTimer: ReturnType<typeof setTimeout> | undefined
  199. return await withTimeout(
  200. new Promise<void>((resolve) => {
  201. unsub = Bus.subscribe(Event.Diagnostics, (event) => {
  202. if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) {
  203. // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax)
  204. if (debounceTimer) clearTimeout(debounceTimer)
  205. debounceTimer = setTimeout(() => {
  206. log.info("got diagnostics", { path: normalizedPath })
  207. unsub?.()
  208. resolve()
  209. }, DIAGNOSTICS_DEBOUNCE_MS)
  210. }
  211. })
  212. }),
  213. 3000,
  214. )
  215. .catch(() => {})
  216. .finally(() => {
  217. if (debounceTimer) clearTimeout(debounceTimer)
  218. unsub?.()
  219. })
  220. },
  221. async shutdown() {
  222. l.info("shutting down")
  223. connection.end()
  224. connection.dispose()
  225. input.server.process.kill()
  226. l.info("shutdown")
  227. },
  228. }
  229. l.info("initialized")
  230. return result
  231. }
  232. }