client.ts 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. import { spawn } from "child_process";
  2. import path from "path";
  3. import {
  4. createMessageConnection,
  5. Disposable,
  6. StreamMessageReader,
  7. StreamMessageWriter,
  8. } from "vscode-jsonrpc/node";
  9. import { App } from "../app";
  10. import { Log } from "../util/log";
  11. import { LANGUAGE_EXTENSIONS } from "./language";
  12. import { Bus } from "../bus";
  13. import z from "zod/v4";
  14. export namespace LSPClient {
  15. const log = Log.create({ service: "lsp.client" });
  16. export type Info = Awaited<ReturnType<typeof create>>;
  17. export const Event = {
  18. Diagnostics: Bus.event(
  19. "lsp.client.diagnostics",
  20. z.object({
  21. path: z.string(),
  22. }),
  23. ),
  24. };
  25. export async function create(input: { cmd: string[] }) {
  26. log.info("starting client", input);
  27. let version = 0;
  28. const app = await App.use();
  29. const [command, ...args] = input.cmd;
  30. const server = spawn(command, args, {
  31. stdio: ["pipe", "pipe", "pipe"],
  32. cwd: app.root,
  33. });
  34. const connection = createMessageConnection(
  35. new StreamMessageReader(server.stdout),
  36. new StreamMessageWriter(server.stdin),
  37. );
  38. const diagnostics = new Map<string, any>();
  39. connection.onNotification("textDocument/publishDiagnostics", (params) => {
  40. const path = new URL(params.uri).pathname;
  41. log.info("textDocument/publishDiagnostics", {
  42. path,
  43. });
  44. diagnostics.set(path, params.diagnostics);
  45. Bus.publish(Event.Diagnostics, { path });
  46. });
  47. connection.listen();
  48. await connection.sendRequest("initialize", {
  49. processId: server.pid,
  50. initializationOptions: {
  51. workspaceFolders: [
  52. {
  53. name: "workspace",
  54. uri: "file://" + app.root,
  55. },
  56. ],
  57. tsserver: {
  58. path: require.resolve("typescript/lib/tsserver.js"),
  59. },
  60. },
  61. capabilities: {
  62. workspace: {
  63. configuration: true,
  64. didChangeConfiguration: {
  65. dynamicRegistration: true,
  66. },
  67. didChangeWatchedFiles: {
  68. dynamicRegistration: true,
  69. relativePatternSupport: true,
  70. },
  71. },
  72. textDocument: {
  73. synchronization: {
  74. dynamicRegistration: true,
  75. didSave: true,
  76. },
  77. completion: {
  78. completionItem: {},
  79. },
  80. codeLens: {
  81. dynamicRegistration: true,
  82. },
  83. documentSymbol: {},
  84. codeAction: {
  85. codeActionLiteralSupport: {
  86. codeActionKind: {
  87. valueSet: [],
  88. },
  89. },
  90. },
  91. publishDiagnostics: {
  92. versionSupport: true,
  93. },
  94. semanticTokens: {
  95. requests: {
  96. range: {},
  97. full: {},
  98. },
  99. tokenTypes: [],
  100. tokenModifiers: [],
  101. formats: [],
  102. },
  103. },
  104. window: {},
  105. },
  106. });
  107. await connection.sendNotification("initialized", {});
  108. log.info("initialized");
  109. const result = {
  110. get connection() {
  111. return connection;
  112. },
  113. notify: {
  114. async open(input: { path: string }) {
  115. log.info("textDocument/didOpen", input);
  116. diagnostics.delete(input.path);
  117. const text = await Bun.file(input.path).text();
  118. const languageId = LANGUAGE_EXTENSIONS[path.extname(input.path)];
  119. await connection.sendNotification("textDocument/didOpen", {
  120. textDocument: {
  121. uri: `file://` + input.path,
  122. languageId,
  123. version: 1,
  124. text: text,
  125. },
  126. });
  127. },
  128. async change(input: { path: string }) {
  129. log.info("textDocument/didChange", input);
  130. diagnostics.delete(input.path);
  131. const text = await Bun.file(input.path).text();
  132. version++;
  133. await connection.sendNotification("textDocument/didChange", {
  134. textDocument: {
  135. uri: `file://` + input.path,
  136. version: Date.now(),
  137. },
  138. contentChanges: [
  139. {
  140. text,
  141. },
  142. ],
  143. });
  144. },
  145. },
  146. get diagnostics() {
  147. return diagnostics;
  148. },
  149. async refreshDiagnostics(input: { path: string }) {
  150. log.info("refreshing diagnostics", input);
  151. let unsub: () => void;
  152. let timeout: NodeJS.Timeout;
  153. return await Promise.race([
  154. new Promise<void>(async (resolve) => {
  155. unsub = Bus.subscribe(Event.Diagnostics, (event) => {
  156. if (event.properties.path === input.path) {
  157. log.info("refreshed diagnostics", input);
  158. clearTimeout(timeout);
  159. unsub?.();
  160. resolve();
  161. }
  162. });
  163. await result.notify.change(input);
  164. }),
  165. new Promise<void>((resolve) => {
  166. timeout = setTimeout(() => {
  167. unsub?.();
  168. resolve();
  169. }, 5000);
  170. }),
  171. ]);
  172. },
  173. };
  174. return result;
  175. }
  176. }