client.ts 5.5 KB

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