client.ts 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. import path from "path"
  2. import {
  3. createMessageConnection,
  4. StreamMessageReader,
  5. StreamMessageWriter,
  6. } from "vscode-jsonrpc/node"
  7. import type { Diagnostic as VSCodeDiagnostic } from "vscode-languageserver-types"
  8. import { App } from "../app/app"
  9. import { Log } from "../util/log"
  10. import { LANGUAGE_EXTENSIONS } from "./language"
  11. import { Bus } from "../bus"
  12. import z from "zod"
  13. import type { LSPServer } from "./server"
  14. import { NamedError } from "../util/error"
  15. export namespace LSPClient {
  16. const log = Log.create({ service: "lsp.client" })
  17. export type Info = NonNullable<Awaited<ReturnType<typeof create>>>
  18. export type Diagnostic = VSCodeDiagnostic
  19. export const InitializeError = NamedError.create(
  20. "LSPInitializeError",
  21. z.object({
  22. serverID: z.string(),
  23. }),
  24. )
  25. export const Event = {
  26. Diagnostics: Bus.event(
  27. "lsp.client.diagnostics",
  28. z.object({
  29. serverID: z.string(),
  30. path: z.string(),
  31. }),
  32. ),
  33. }
  34. export async function create(serverID: string, server: LSPServer.Handle) {
  35. const app = App.info()
  36. log.info("starting client", { id: serverID })
  37. const connection = createMessageConnection(
  38. new StreamMessageReader(server.process.stdout),
  39. new StreamMessageWriter(server.process.stdin),
  40. )
  41. const diagnostics = new Map<string, Diagnostic[]>()
  42. connection.onNotification("textDocument/publishDiagnostics", (params) => {
  43. const path = new URL(params.uri).pathname
  44. log.info("textDocument/publishDiagnostics", {
  45. path,
  46. })
  47. diagnostics.set(path, params.diagnostics)
  48. Bus.publish(Event.Diagnostics, { path, serverID })
  49. })
  50. connection.onRequest("workspace/configuration", async () => {
  51. return [{}]
  52. })
  53. connection.listen()
  54. log.info("sending initialize", { id: serverID })
  55. await Promise.race([
  56. connection.sendRequest("initialize", {
  57. processId: server.process.pid,
  58. workspaceFolders: [
  59. {
  60. name: "workspace",
  61. uri: "file://" + app.path.cwd,
  62. },
  63. ],
  64. initializationOptions: {
  65. ...server.initialization,
  66. },
  67. capabilities: {
  68. workspace: {
  69. configuration: true,
  70. },
  71. textDocument: {
  72. synchronization: {
  73. didOpen: true,
  74. didChange: true,
  75. },
  76. publishDiagnostics: {
  77. versionSupport: true,
  78. },
  79. },
  80. },
  81. }),
  82. new Promise((_, reject) => {
  83. setTimeout(() => {
  84. reject(new InitializeError({ serverID }))
  85. }, 5_000)
  86. }),
  87. ])
  88. await connection.sendNotification("initialized", {})
  89. log.info("initialized")
  90. const files: {
  91. [path: string]: number
  92. } = {}
  93. const result = {
  94. get serverID() {
  95. return serverID
  96. },
  97. get connection() {
  98. return connection
  99. },
  100. notify: {
  101. async open(input: { path: string }) {
  102. input.path = path.isAbsolute(input.path)
  103. ? input.path
  104. : path.resolve(app.path.cwd, input.path)
  105. const file = Bun.file(input.path)
  106. const text = await file.text()
  107. const version = files[input.path]
  108. if (version === undefined) {
  109. log.info("textDocument/didOpen", input)
  110. diagnostics.delete(input.path)
  111. const extension = path.extname(input.path)
  112. const languageId = LANGUAGE_EXTENSIONS[extension] ?? "plaintext"
  113. await connection.sendNotification("textDocument/didOpen", {
  114. textDocument: {
  115. uri: `file://` + input.path,
  116. languageId,
  117. version: 0,
  118. text,
  119. },
  120. })
  121. files[input.path] = 0
  122. return
  123. }
  124. log.info("textDocument/didChange", input)
  125. diagnostics.delete(input.path)
  126. await connection.sendNotification("textDocument/didChange", {
  127. textDocument: {
  128. uri: `file://` + input.path,
  129. version: ++files[input.path],
  130. },
  131. contentChanges: [
  132. {
  133. text,
  134. },
  135. ],
  136. })
  137. },
  138. },
  139. get diagnostics() {
  140. return diagnostics
  141. },
  142. async waitForDiagnostics(input: { path: string }) {
  143. input.path = path.isAbsolute(input.path)
  144. ? input.path
  145. : path.resolve(app.path.cwd, input.path)
  146. log.info("waiting for diagnostics", input)
  147. let unsub: () => void
  148. let timeout: NodeJS.Timeout
  149. return await Promise.race([
  150. new Promise<void>(async (resolve) => {
  151. unsub = Bus.subscribe(Event.Diagnostics, (event) => {
  152. if (
  153. event.properties.path === input.path &&
  154. event.properties.serverID === result.serverID
  155. ) {
  156. log.info("got diagnostics", input)
  157. clearTimeout(timeout)
  158. unsub?.()
  159. resolve()
  160. }
  161. })
  162. }),
  163. new Promise<void>((resolve) => {
  164. timeout = setTimeout(() => {
  165. log.info("timed out refreshing diagnostics", input)
  166. unsub?.()
  167. resolve()
  168. }, 5000)
  169. }),
  170. ])
  171. },
  172. async shutdown() {
  173. log.info("shutting down")
  174. connection.end()
  175. connection.dispose()
  176. server.process.kill("SIGKILL")
  177. },
  178. }
  179. return result
  180. }
  181. }