client.ts 5.9 KB

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