GDScriptLanguageClient.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. import EventEmitter from "node:events";
  2. import * as path from "node:path";
  3. import * as vscode from "vscode";
  4. import {
  5. LanguageClient,
  6. MessageSignature,
  7. type LanguageClientOptions,
  8. type NotificationMessage,
  9. type RequestMessage,
  10. type ResponseMessage,
  11. type ServerOptions,
  12. } from "vscode-languageclient/node";
  13. import { globals } from "../extension";
  14. import { createLogger, get_configuration, get_project_dir } from "../utils";
  15. import { MessageIO } from "./MessageIO";
  16. const log = createLogger("lsp.client", { output: "Godot LSP" });
  17. export enum ClientStatus {
  18. PENDING = 0,
  19. DISCONNECTED = 1,
  20. CONNECTED = 2,
  21. REJECTED = 3,
  22. }
  23. export enum TargetLSP {
  24. HEADLESS = 0,
  25. EDITOR = 1,
  26. }
  27. export type Target = {
  28. host: string;
  29. port: number;
  30. type: TargetLSP;
  31. };
  32. type HoverResult = {
  33. contents: {
  34. kind: string;
  35. value: string;
  36. };
  37. range: {
  38. end: {
  39. character: number;
  40. line: number;
  41. };
  42. start: {
  43. character: number;
  44. line: number;
  45. };
  46. };
  47. };
  48. type HoverResponseMesssage = {
  49. id: number;
  50. jsonrpc: string;
  51. result: HoverResult;
  52. };
  53. type ChangeWorkspaceNotification = {
  54. method: string;
  55. params: {
  56. path: string;
  57. };
  58. };
  59. type DocumentLinkResult = {
  60. range: {
  61. end: {
  62. character: number;
  63. line: number;
  64. };
  65. start: {
  66. character: number;
  67. line: number;
  68. };
  69. };
  70. target: string;
  71. };
  72. type DocumentLinkResponseMessage = {
  73. id: number;
  74. jsonrpc: string;
  75. result: DocumentLinkResult[];
  76. };
  77. export default class GDScriptLanguageClient extends LanguageClient {
  78. public io: MessageIO = new MessageIO();
  79. public target: TargetLSP = TargetLSP.EDITOR;
  80. public port = -1;
  81. public lastPortTried = -1;
  82. public sentMessages = new Map();
  83. private initMessage: RequestMessage;
  84. private rejected = false;
  85. events = new EventEmitter();
  86. private _status: ClientStatus;
  87. public set status(v: ClientStatus) {
  88. this._status = v;
  89. this.events.emit("status", this._status);
  90. }
  91. constructor() {
  92. const serverOptions: ServerOptions = () => {
  93. return new Promise((resolve, reject) => {
  94. resolve({ reader: this.io.reader, writer: this.io.writer });
  95. });
  96. };
  97. const clientOptions: LanguageClientOptions = {
  98. documentSelector: [
  99. { scheme: "file", language: "gdscript" },
  100. { scheme: "untitled", language: "gdscript" },
  101. ],
  102. };
  103. super("GDScriptLanguageClient", serverOptions, clientOptions);
  104. this.status = ClientStatus.PENDING;
  105. this.io.on("connected", this.on_connected.bind(this));
  106. this.io.on("disconnected", this.on_disconnected.bind(this));
  107. this.io.requestFilter = this.request_filter.bind(this);
  108. this.io.responseFilter = this.response_filter.bind(this);
  109. this.io.notificationFilter = this.notification_filter.bind(this);
  110. }
  111. connect(target: TargetLSP = TargetLSP.EDITOR) {
  112. this.rejected = false;
  113. this.target = target;
  114. this.status = ClientStatus.PENDING;
  115. let port = get_configuration("lsp.serverPort");
  116. if (this.port !== -1) {
  117. port = this.port;
  118. }
  119. if (this.target === TargetLSP.EDITOR) {
  120. if (port === 6005 || port === 6008) {
  121. port = 6005;
  122. }
  123. }
  124. this.lastPortTried = port;
  125. const host = get_configuration("lsp.serverHost");
  126. log.info(`attempting to connect to LSP at ${host}:${port}`);
  127. this.io.connect(host, port);
  128. }
  129. async send_request(method: string, params) {
  130. try {
  131. return this.sendRequest(method, params);
  132. } catch {
  133. log.warn("sending request failed!");
  134. }
  135. }
  136. handleFailedRequest<T>(
  137. type: MessageSignature,
  138. token: vscode.CancellationToken | undefined,
  139. error: any,
  140. defaultValue: T,
  141. showNotification?: boolean,
  142. ): T {
  143. if (type.method === "textDocument/documentSymbol") {
  144. if (
  145. error.message.includes("selectionRange must be contained in fullRange")
  146. ) {
  147. log.warn(
  148. `Request failed for method "${type.method}", suppressing notification - see issue #820`
  149. );
  150. return super.handleFailedRequest(
  151. type,
  152. token,
  153. error,
  154. defaultValue,
  155. false
  156. );
  157. }
  158. }
  159. return super.handleFailedRequest(
  160. type,
  161. token,
  162. error,
  163. defaultValue,
  164. showNotification
  165. );
  166. }
  167. private request_filter(message: RequestMessage) {
  168. if (this.rejected) {
  169. if (message.method === "shutdown") {
  170. return message;
  171. }
  172. return false;
  173. }
  174. this.sentMessages.set(message.id, message);
  175. if (!this.initMessage && message.method === "initialize") {
  176. this.initMessage = message;
  177. }
  178. // discard outgoing messages that we know aren't supported
  179. // if (message.method === "textDocument/didSave") {
  180. // return false;
  181. // }
  182. // if (message.method === "textDocument/willSaveWaitUntil") {
  183. // return false;
  184. // }
  185. if (message.method === "workspace/didChangeWatchedFiles") {
  186. return false;
  187. }
  188. if (message.method === "workspace/symbol") {
  189. return false;
  190. }
  191. return message;
  192. }
  193. private response_filter(message: ResponseMessage) {
  194. const sentMessage = this.sentMessages.get(message.id);
  195. if (sentMessage?.method === "textDocument/hover") {
  196. // fix markdown contents
  197. let value: string = (message as HoverResponseMesssage).result.contents.value;
  198. if (value) {
  199. // this is a dirty hack to fix language server sending us prerendered
  200. // markdown but not correctly stripping leading #'s, leading to
  201. // docstrings being displayed as titles
  202. value = value.replace(/\n[#]+/g, "\n");
  203. // fix bbcode line breaks
  204. value = value.replaceAll("`br`", "\n\n");
  205. // fix bbcode code boxes
  206. value = value.replace("`codeblocks`", "");
  207. value = value.replace("`/codeblocks`", "");
  208. value = value.replace("`gdscript`", "\nGDScript:\n```gdscript");
  209. value = value.replace("`/gdscript`", "```");
  210. value = value.replace("`csharp`", "\nC#:\n```csharp");
  211. value = value.replace("`/csharp`", "```");
  212. (message as HoverResponseMesssage).result.contents.value = value;
  213. }
  214. } else if (sentMessage.method === "textDocument/documentLink") {
  215. const results: DocumentLinkResult[] = (
  216. message as DocumentLinkResponseMessage
  217. ).result;
  218. if (!results) {
  219. return message;
  220. }
  221. const final_result: DocumentLinkResult[] = [];
  222. // at this point, Godot's LSP server does not
  223. // return a valid path for resources identified
  224. // by "uid://""
  225. //
  226. // this is a dirty hack to remove any "uid://"
  227. // document links.
  228. //
  229. // to provide links for these, we will be relying on
  230. // the internal DocumentLinkProvider instead.
  231. for (const result of results) {
  232. if (!result.target.startsWith("uid://")) {
  233. final_result.push(result);
  234. }
  235. }
  236. (message as DocumentLinkResponseMessage).result = final_result;
  237. }
  238. return message;
  239. }
  240. private async check_workspace(message: ChangeWorkspaceNotification) {
  241. const server_path = path.normalize(message.params.path);
  242. const client_path = path.normalize(await get_project_dir());
  243. if (server_path !== client_path) {
  244. log.warn("Connected LSP is a different workspace");
  245. this.io.socket.resetAndDestroy();
  246. this.rejected = true;
  247. }
  248. }
  249. private notification_filter(message: NotificationMessage) {
  250. if (message.method === "gdscript_client/changeWorkspace") {
  251. this.check_workspace(message as ChangeWorkspaceNotification);
  252. }
  253. if (message.method === "gdscript/capabilities") {
  254. globals.docsProvider.register_capabilities(message);
  255. }
  256. // if (message.method === "textDocument/publishDiagnostics") {
  257. // for (const diagnostic of message.params.diagnostics) {
  258. // if (diagnostic.code === 6) {
  259. // log.debug("UNUSED_SIGNAL", diagnostic);
  260. // return;
  261. // }
  262. // if (diagnostic.code === 2) {
  263. // log.debug("UNUSED_VARIABLE", diagnostic);
  264. // return;
  265. // }
  266. // }
  267. // }
  268. return message;
  269. }
  270. public async get_symbol_at_position(
  271. uri: vscode.Uri,
  272. position: vscode.Position
  273. ) {
  274. const params = {
  275. textDocument: { uri: uri.toString() },
  276. position: { line: position.line, character: position.character },
  277. };
  278. const response = await this.send_request("textDocument/hover", params);
  279. return this.parse_hover_result(response as HoverResult);
  280. }
  281. private parse_hover_result(message: HoverResult) {
  282. const contents = message.contents;
  283. let decl: string;
  284. if (Array.isArray(contents)) {
  285. decl = contents[0];
  286. } else {
  287. decl = contents.value;
  288. }
  289. if (!decl) {
  290. return "";
  291. }
  292. decl = decl.split("\n")[0].trim();
  293. let match: RegExpMatchArray;
  294. let result = undefined;
  295. match = decl.match(/(?:func|const) (@?\w+)\.(\w+)/);
  296. if (match) {
  297. result = `${match[1]}.${match[2]}`;
  298. }
  299. match = decl.match(/<Native> class (\w+)/);
  300. if (match) {
  301. result = `${match[1]}`;
  302. }
  303. return result;
  304. }
  305. private on_connected() {
  306. this.status = ClientStatus.CONNECTED;
  307. const host = get_configuration("lsp.serverHost");
  308. log.info(`connected to LSP at ${host}:${this.lastPortTried}`);
  309. if (this.initMessage) {
  310. this.send_request(this.initMessage.method, this.initMessage.params);
  311. }
  312. }
  313. private on_disconnected() {
  314. if (this.rejected) {
  315. this.status = ClientStatus.REJECTED;
  316. return;
  317. }
  318. if (this.target === TargetLSP.EDITOR) {
  319. const host = get_configuration("lsp.serverHost");
  320. let port = get_configuration("lsp.serverPort");
  321. if (port === 6005 || port === 6008) {
  322. if (this.lastPortTried === 6005) {
  323. port = 6008;
  324. log.info(`attempting to connect to LSP at ${host}:${port}`);
  325. this.lastPortTried = port;
  326. this.io.connect(host, port);
  327. return;
  328. }
  329. }
  330. }
  331. this.status = ClientStatus.DISCONNECTED;
  332. }
  333. }