From 694feea1bc9450f13164c37a9d170590dbb4f275 Mon Sep 17 00:00:00 2001 From: David Kincaid Date: Mon, 18 Nov 2024 10:53:59 -0500 Subject: [PATCH] Overhaul LSP Client (#752) * Simplify LSP Client internals * Streamline control flow between Client, IO, and Buffer classes * Create canonical, obvious place to implement filters on incoming and outgoing LSP messages * Remove legacy WS LSP support --- package.json | 15 - src/extension.ts | 2 +- src/lsp/ClientConnectionManager.ts | 23 +- src/lsp/GDScriptLanguageClient.ts | 275 ++++++++---------- src/lsp/MessageBuffer.ts | 101 +++++-- src/lsp/MessageIO.ts | 267 +++++------------ src/providers/documentation.ts | 2 +- src/providers/documentation_builder.ts | 2 +- .../documentation_types.ts} | 0 9 files changed, 288 insertions(+), 399 deletions(-) rename src/{lsp/gdscript.capabilities.ts => providers/documentation_types.ts} (100%) diff --git a/package.json b/package.json index 604ca5f..c26dae3 100644 --- a/package.json +++ b/package.json @@ -296,21 +296,6 @@ "default": false, "description": "Whether extra space should be removed from function parameter lists" }, - "godotTools.lsp.serverProtocol": { - "type": [ - "string" - ], - "enum": [ - "ws", - "tcp" - ], - "default": "tcp", - "enumDescriptions": [ - "Use the WebSocket protocol to connect to Godot 3.2 and Godot 3.2.1", - "Use the TCP protocol to connect to Godot 3.2.2 and newer versions" - ], - "description": "The server protocol of the GDScript language server.\nYou must restart VSCode after changing this value." - }, "godotTools.lsp.serverHost": { "type": "string", "default": "127.0.0.1", diff --git a/src/extension.ts b/src/extension.ts index b16e7ba..9f5eb00 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -134,7 +134,7 @@ function copy_resource_path(uri: vscode.Uri) { } async function list_classes() { - await globals.lsp.client.list_classes(); + await globals.docsProvider.list_native_classes(); } async function switch_scene_script() { diff --git a/src/lsp/ClientConnectionManager.ts b/src/lsp/ClientConnectionManager.ts index 0b988a4..27e0ddd 100644 --- a/src/lsp/ClientConnectionManager.ts +++ b/src/lsp/ClientConnectionManager.ts @@ -1,18 +1,19 @@ import * as vscode from "vscode"; -import GDScriptLanguageClient, { ClientStatus, TargetLSP } from "./GDScriptLanguageClient"; + import { + createLogger, get_configuration, get_free_port, get_project_dir, get_project_version, - set_context, register_command, set_configuration, - createLogger, + set_context, verify_godot_version, } from "../utils"; import { prompt_for_godot_executable, prompt_for_reload, select_godot_executable } from "../utils/prompts"; -import { subProcess, killSubProcesses } from "../utils/subspawn"; +import { killSubProcesses, subProcess } from "../utils/subspawn"; +import GDScriptLanguageClient, { ClientStatus, TargetLSP } from "./GDScriptLanguageClient"; const log = createLogger("lsp.manager", { output: "Godot LSP" }); @@ -38,10 +39,8 @@ export class ClientConnectionManager { private connectedVersion = ""; constructor(private context: vscode.ExtensionContext) { - this.context = context; - - this.client = new GDScriptLanguageClient(context); - this.client.watch_status(this.on_client_status_changed.bind(this)); + this.client = new GDScriptLanguageClient(); + this.client.events.on("status", this.on_client_status_changed.bind(this)); setInterval(() => { this.retry_callback(); @@ -60,7 +59,7 @@ export class ClientConnectionManager { this.start_language_server(); this.reconnectionAttempts = 0; this.target = TargetLSP.HEADLESS; - this.client.connect_to_server(this.target); + this.client.connect(this.target); }), register_command("stopLanguageServer", this.stop_language_server.bind(this)), register_command("checkStatus", this.on_status_item_click.bind(this)), @@ -81,7 +80,7 @@ export class ClientConnectionManager { } this.reconnectionAttempts = 0; - this.client.connect_to_server(this.target); + this.client.connect(this.target); } private stop_language_server() { @@ -269,7 +268,7 @@ export class ClientConnectionManager { this.reconnectionAttempts = 0; set_context("connectedToLSP", true); this.status = ManagerStatus.CONNECTED; - if (!this.client.started) { + if (this.client.needsStart()) { this.context.subscriptions.push(this.client.start()); } break; @@ -305,7 +304,7 @@ export class ClientConnectionManager { const maxAttempts = get_configuration("lsp.autoReconnect.attempts"); if (autoRetry && this.reconnectionAttempts <= maxAttempts - 1) { this.reconnectionAttempts++; - this.client.connect_to_server(this.target); + this.client.connect(this.target); this.retry = true; return; } diff --git a/src/lsp/GDScriptLanguageClient.ts b/src/lsp/GDScriptLanguageClient.ts index a4e0bab..31a09d7 100644 --- a/src/lsp/GDScriptLanguageClient.ts +++ b/src/lsp/GDScriptLanguageClient.ts @@ -1,21 +1,17 @@ +import EventEmitter from "node:events"; import * as vscode from "vscode"; import { LanguageClient, + type LanguageClientOptions, type NotificationMessage, type RequestMessage, type ResponseMessage, + type ServerOptions, } from "vscode-languageclient/node"; -import { EventEmitter } from "node:events"; -import { get_configuration, createLogger } from "../utils"; -import { - type Message, - type MessageIO, - MessageIOReader, - MessageIOWriter, - TCPMessageIO, - WebSocketMessageIO, -} from "./MessageIO"; + import { globals } from "../extension"; +import { createLogger, get_configuration } from "../utils"; +import { MessageIO } from "./MessageIO"; const log = createLogger("lsp.client", { output: "Godot LSP" }); @@ -30,80 +26,80 @@ export enum TargetLSP { EDITOR = 1, } -const CUSTOM_MESSAGE = "gdscript_client/"; +export type Target = { + host: string; + port: number; + type: TargetLSP; +}; + +type HoverResult = { + contents: { + kind: string; + value: string; + }; + range: { + end: { + character: number; + line: number; + }; + start: { + character: number; + line: number; + }; + }; +}; + +type HoverResponseMesssage = { + id: number; + jsonrpc: string; + result: HoverResult; +}; export default class GDScriptLanguageClient extends LanguageClient { - public readonly io: MessageIO = - get_configuration("lsp.serverProtocol") === "ws" ? new WebSocketMessageIO() : new TCPMessageIO(); - - private _status_changed_callbacks: ((v: ClientStatus) => void)[] = []; - private _initialize_request: Message = null; - private messageHandler: MessageHandler = null; + public io: MessageIO = new MessageIO(); public target: TargetLSP = TargetLSP.EDITOR; public port = -1; public lastPortTried = -1; public sentMessages = new Map(); - public lastSymbolHovered = ""; - private _started = false; - public get started(): boolean { - return this._started; - } + events = new EventEmitter(); private _status: ClientStatus; - public get status(): ClientStatus { - return this._status; - } + public set status(v: ClientStatus) { - if (this._status !== v) { - this._status = v; - for (const callback of this._status_changed_callbacks) { - callback(v); - } - } + this._status = v; + this.events.emit("status", this._status); } - public watch_status(callback: (v: ClientStatus) => void) { - if (this._status_changed_callbacks.indexOf(callback) === -1) { - this._status_changed_callbacks.push(callback); - } - } + constructor() { + const serverOptions: ServerOptions = () => { + return new Promise((resolve, reject) => { + resolve({ reader: this.io.reader, writer: this.io.writer }); + }); + }; - constructor(private context: vscode.ExtensionContext) { - super( - "GDScriptLanguageClient", - () => { - return new Promise((resolve, reject) => { - resolve({ reader: new MessageIOReader(this.io), writer: new MessageIOWriter(this.io) }); - }); + const clientOptions: LanguageClientOptions = { + documentSelector: [ + { scheme: "file", language: "gdscript" }, + { scheme: "untitled", language: "gdscript" }, + ], + synchronize: { + fileEvents: vscode.workspace.createFileSystemWatcher("**/*.gd"), }, - { - // Register the server for plain text documents - documentSelector: [ - { scheme: "file", language: "gdscript" }, - { scheme: "untitled", language: "gdscript" }, - ], - synchronize: { - // Notify the server about file changes to '.gd files contain in the workspace - fileEvents: vscode.workspace.createFileSystemWatcher("**/*.gd"), - }, - }, - ); + }; + + super("GDScriptLanguageClient", serverOptions, clientOptions); this.status = ClientStatus.PENDING; - this.io.on("disconnected", this.on_disconnected.bind(this)); this.io.on("connected", this.on_connected.bind(this)); - this.io.on("message", this.on_message.bind(this)); - this.io.on("send_message", this.on_send_message.bind(this)); - this.messageHandler = new MessageHandler(this.io); + this.io.on("disconnected", this.on_disconnected.bind(this)); + this.io.requestFilter = this.request_filter.bind(this); + this.io.responseFilter = this.response_filter.bind(this); + this.io.notificationFilter = this.notification_filter.bind(this); } - public async list_classes() { - await globals.docsProvider.list_native_classes(); - } - - connect_to_server(target: TargetLSP = TargetLSP.EDITOR) { + connect(target: TargetLSP = TargetLSP.EDITOR) { this.target = target; this.status = ClientStatus.PENDING; @@ -123,70 +119,74 @@ export default class GDScriptLanguageClient extends LanguageClient { const host = get_configuration("lsp.serverHost"); log.info(`attempting to connect to LSP at ${host}:${port}`); - this.io.connect_to_language_server(host, port); + this.io.connect(host, port); } - start() { - this._started = true; - return super.start(); - } - - private on_send_message(message: RequestMessage) { + private request_filter(message: RequestMessage) { this.sentMessages.set(message.id, message); - if (message.method === "initialize") { - this._initialize_request = message; + // discard outgoing messages that we know aren't supported + if (message.method === "didChangeWatchedFiles") { + return; } + if (message.method === "workspace/symbol") { + return; + } + + return message; } - private on_message(message: ResponseMessage | NotificationMessage) { - const msgString = JSON.stringify(message); + private response_filter(message: ResponseMessage) { + const sentMessage = this.sentMessages.get(message.id); + if (sentMessage?.method === "textDocument/hover") { + // fix markdown contents + let value: string = (message as HoverResponseMesssage).result.contents.value; + if (value) { + // this is a dirty hack to fix language server sending us prerendered + // markdown but not correctly stripping leading #'s, leading to + // docstrings being displayed as titles + value = value.replace(/\n[#]+/g, "\n"); - // This is a dirty hack to fix the language server sending us - // invalid file URIs - // This should be forward-compatible, meaning that it will work - // with the current broken version, AND the fixed future version. - const match = msgString.match(/"target":"file:\/\/[^\/][^"]*"/); - if (match) { - const count = (message["result"] as Array).length; - for (let i = 0; i < count; i++) { - const x: string = message["result"][i]["target"]; - message["result"][i]["target"] = x.replace("file://", "file:///"); + // fix bbcode line breaks + value = value.replaceAll("`br`", "\n\n"); + + // fix bbcode code boxes + value = value.replace("`codeblocks`", ""); + value = value.replace("`/codeblocks`", ""); + value = value.replace("`gdscript`", "\nGDScript:\n```gdscript"); + value = value.replace("`/gdscript`", "```"); + value = value.replace("`csharp`", "\nC#:\n```csharp"); + value = value.replace("`/csharp`", "```"); + + (message as HoverResponseMesssage).result.contents.value = value; } } - if ("method" in message && message.method === "gdscript/capabilities") { + return message; + } + + private notification_filter(message: NotificationMessage) { + if (message.method === "gdscript_client/changeWorkspace") { + // + } + if (message.method === "gdscript/capabilities") { globals.docsProvider.register_capabilities(message); } - if ("id" in message) { - const sentMessage = this.sentMessages.get(message.id); - if (sentMessage && sentMessage.method === "textDocument/hover") { - // fix markdown contents - let value: string = message.result["contents"]?.value; - if (value) { - // this is a dirty hack to fix language server sending us prerendered - // markdown but not correctly stripping leading #'s, leading to - // docstrings being displayed as titles - value = value.replace(/\n[#]+/g, "\n"); + // if (message.method === "textDocument/publishDiagnostics") { + // for (const diagnostic of message.params.diagnostics) { + // if (diagnostic.code === 6) { + // log.debug("UNUSED_SIGNAL", diagnostic); + // return; + // } + // if (diagnostic.code === 2) { + // log.debug("UNUSED_VARIABLE", diagnostic); + // return; + // } + // } + // } - // fix bbcode line breaks - value = value.replaceAll("`br`", "\n\n"); - - // fix bbcode code boxes - value = value.replace("`codeblocks`", ""); - value = value.replace("`/codeblocks`", ""); - value = value.replace("`gdscript`", "\nGDScript:\n```gdscript"); - value = value.replace("`/gdscript`", "```"); - value = value.replace("`csharp`", "\nC#:\n```csharp"); - value = value.replace("`/csharp`", "```"); - - message.result["contents"].value = value; - } - } - } - - this.messageHandler.on_message(message); + return message; } public async get_symbol_at_position(uri: vscode.Uri, position: vscode.Position) { @@ -194,13 +194,13 @@ export default class GDScriptLanguageClient extends LanguageClient { textDocument: { uri: uri.toString() }, position: { line: position.line, character: position.character }, }; - const response = await this.sendRequest("textDocument/hover", params); + const response: HoverResult = await this.sendRequest("textDocument/hover", params); - return this.parse_hover_response(response); + return this.parse_hover_result(response); } - private parse_hover_response(message) { - const contents = message["contents"]; + private parse_hover_result(message: HoverResult) { + const contents = message.contents; let decl: string; if (Array.isArray(contents)) { @@ -229,9 +229,6 @@ export default class GDScriptLanguageClient extends LanguageClient { } private on_connected() { - if (this._initialize_request) { - this.io.writer.write(this._initialize_request); - } this.status = ClientStatus.CONNECTED; const host = get_configuration("lsp.serverHost"); @@ -249,7 +246,7 @@ export default class GDScriptLanguageClient extends LanguageClient { log.info(`attempting to connect to LSP at ${host}:${port}`); this.lastPortTried = port; - this.io.connect_to_language_server(host, port); + this.io.connect(host, port); return; } } @@ -257,39 +254,3 @@ export default class GDScriptLanguageClient extends LanguageClient { this.status = ClientStatus.DISCONNECTED; } } - -class MessageHandler extends EventEmitter { - private io: MessageIO = null; - - constructor(io: MessageIO) { - super(); - this.io = io; - } - - // changeWorkspace(params: { path: string }) { - // vscode.window.showErrorMessage("The GDScript language server can't work properly!\nThe open workspace is different from the editor's.", 'Reload', 'Ignore').then(item => { - // if (item == "Reload") { - // let folderUrl = vscode.Uri.file(params.path); - // vscode.commands.executeCommand('vscode.openFolder', folderUrl, false); - // } - // }); - // } - - on_message(message: any) { - // FIXME: Hot fix VSCode 1.42 hover position - if (message?.result?.range && message.result.contents) { - message.result.range = undefined; - } - - // What does this do? - if (message?.method && (message.method as string).startsWith(CUSTOM_MESSAGE)) { - const method = (message.method as string).substring(CUSTOM_MESSAGE.length, message.method.length); - if (this[method]) { - const ret = this[method](message.params); - if (ret) { - this.io.writer.write(ret); - } - } - } - } -} diff --git a/src/lsp/MessageBuffer.ts b/src/lsp/MessageBuffer.ts index 824150d..3fa3d67 100644 --- a/src/lsp/MessageBuffer.ts +++ b/src/lsp/MessageBuffer.ts @@ -3,21 +3,28 @@ * Licensed under the MIT License. See License.txt in the project root for license information. * ------------------------------------------------------------------------------------------ */ +import { createLogger } from "../utils"; + +const log = createLogger("lsp.buf"); + const DefaultSize: number = 8192; const CR: number = Buffer.from("\r", "ascii")[0]; const LF: number = Buffer.from("\n", "ascii")[0]; const CRLF: string = "\r\n"; -export default class MessageBuffer { - private encoding: BufferEncoding; - private index: number; - private buffer: Buffer; +type Headers = { [key: string]: string }; - constructor(encoding = "utf8") { - this.encoding = encoding as BufferEncoding; - this.index = 0; - this.buffer = Buffer.allocUnsafe(DefaultSize); - } +export default class MessageBuffer { + private encoding: BufferEncoding = "utf8"; + private index = 0; + private buffer: Buffer = Buffer.allocUnsafe(DefaultSize); + + private nextMessageLength: number; + private messageToken: number; + private partialMessageTimer: NodeJS.Timeout | undefined; + private _partialMessageTimeout = 10000; + + constructor(private reader) {} public append(chunk: Buffer | string): void { let toAppend: Buffer = chunk; @@ -41,8 +48,7 @@ export default class MessageBuffer { this.index += toAppend.length; } - public tryReadHeaders(): { [key: string]: string } | undefined { - let result: { [key: string]: string } | undefined = undefined; + public tryReadHeaders(): Headers | undefined { let current = 0; while ( current + 3 < this.index && @@ -55,19 +61,19 @@ export default class MessageBuffer { } // No header / body separator found (e.g CRLFCRLF) if (current + 3 >= this.index) { - return result; + return undefined; } - result = Object.create(null); + const result = Object.create(null); const headers = this.buffer.toString("ascii", 0, current).split(CRLF); - for (const header of headers) { + for (const header of headers) { const index: number = header.indexOf(":"); if (index === -1) { throw new Error("Message header must separate key and value using :"); } const key = header.substr(0, index); const value = header.substr(index + 1).trim(); - result![key] = value; - } + result[key] = value; + } const nextStart = current + 4; this.buffer = this.buffer.slice(nextStart); @@ -86,7 +92,66 @@ export default class MessageBuffer { return result; } - public get numberOfBytes(): number { - return this.index; + public ready() { + if (this.nextMessageLength === -1) { + const headers = this.tryReadHeaders(); + if (!headers) { + return; + } + const contentLength = headers["Content-Length"]; + if (!contentLength) { + log.warn("Header must provide a Content-Length property."); + return; + } + const length = Number.parseInt(contentLength); + if (Number.isNaN(length)) { + log.warn("Content-Length value must be a number."); + return; + } + this.nextMessageLength = length; + } + const msg = this.tryReadContent(this.nextMessageLength); + if (!msg) { + log.warn("haven't recieved full message"); + this.setPartialMessageTimer(); + return; + } + this.clearPartialMessageTimer(); + this.nextMessageLength = -1; + this.messageToken++; + + return msg; + } + + public reset() { + this.nextMessageLength = -1; + this.messageToken = 0; + this.partialMessageTimer = undefined; + } + + private clearPartialMessageTimer(): void { + if (this.partialMessageTimer) { + clearTimeout(this.partialMessageTimer); + this.partialMessageTimer = undefined; + } + } + + private setPartialMessageTimer(): void { + this.clearPartialMessageTimer(); + if (this._partialMessageTimeout <= 0) { + return; + } + this.partialMessageTimer = setTimeout( + (token, timeout) => { + this.partialMessageTimer = undefined; + if (token === this.messageToken) { + this.reader.firePartialMessage({ messageToken: token, waitingTime: timeout }); + this.setPartialMessageTimer(); + } + }, + this._partialMessageTimeout, + this.messageToken, + this._partialMessageTimeout, + ); } } diff --git a/src/lsp/MessageIO.ts b/src/lsp/MessageIO.ts index b031fd2..bd04914 100644 --- a/src/lsp/MessageIO.ts +++ b/src/lsp/MessageIO.ts @@ -10,7 +10,6 @@ import { type MessageWriter, } from "vscode-jsonrpc"; import { EventEmitter } from "node:events"; -import { WebSocket, type Data } from "ws"; import { Socket } from "net"; import MessageBuffer from "./MessageBuffer"; import { createLogger } from "../utils"; @@ -20,252 +19,132 @@ const log = createLogger("lsp.io", { output: "Godot LSP" }); export type Message = RequestMessage | ResponseMessage | NotificationMessage; export class MessageIO extends EventEmitter { - reader: MessageIOReader = null; - writer: MessageIOWriter = null; + reader = new MessageIOReader(this); + writer = new MessageIOWriter(this); - public send_message(message: string) { - // virtual - } + requestFilter: (msg: RequestMessage) => RequestMessage = (msg) => msg; + responseFilter: (msg: ResponseMessage) => ResponseMessage = (msg) => msg; + notificationFilter: (msg: NotificationMessage) => NotificationMessage = (msg) => msg; - protected on_message(chunk: Data) { - const message = chunk.toString(); - this.emit("data", message); - } + socket: Socket = null; + messageCache: string[] = []; - on_send_message(message: any) { - this.emit("send_message", message); - } - - on_message_callback(message: any) { - this.emit("message", message); - } - - async connect_to_language_server(host: string, port: number): Promise { - // virtual - } -} - -export class WebSocketMessageIO extends MessageIO { - private socket: WebSocket = null; - - public send_message(message: string) { - if (this.socket) { - this.socket.send(message); - } - } - - async connect_to_language_server(host: string, port: number): Promise { + async connect(host: string, port: number): Promise { + log.debug(`connecting to ${host}:${port}`); return new Promise((resolve, reject) => { this.socket = null; - const ws = new WebSocket(`ws://${host}:${port}`); - ws.on("open", () => { - this.on_connected(ws); - resolve(); - }); - ws.on("message", this.on_message.bind(this)); - ws.on("error", this.on_disconnected.bind(this)); - ws.on("close", this.on_disconnected.bind(this)); - }); - } - protected on_connected(socket: WebSocket) { - this.socket = socket; - this.emit("connected"); - } - - protected on_disconnected() { - this.socket = null; - this.emit("disconnected"); - } -} - -export class TCPMessageIO extends MessageIO { - private socket: Socket = null; - - public send_message(message: string) { - if (this.socket) { - this.socket.write(message); - } - } - - async connect_to_language_server(host: string, port: number): Promise { - return new Promise((resolve, reject) => { - this.socket = null; const socket = new Socket(); socket.connect(port, host); + socket.on("connect", () => { - this.on_connected(socket); + this.socket = socket; + + while (this.messageCache.length > 0) { + const msg = this.messageCache.shift(); + this.socket.write(msg); + } + + this.emit("connected"); resolve(); }); - socket.on("data", this.on_message.bind(this)); - socket.on("end", this.on_disconnected.bind(this)); - socket.on("close", this.on_disconnected.bind(this)); - socket.on("error", this.on_error.bind(this)); + socket.on("data", (chunk: Buffer) => { + this.emit("data", chunk.toString()); + }); + // socket.on("end", this.on_disconnected.bind(this)); + socket.on("error", () => { + this.socket = null; + this.emit("disconnected"); + }); + socket.on("close", () => { + this.socket = null; + this.emit("disconnected"); + }); }); } - protected on_connected(socket: Socket) { - this.socket = socket; - this.emit("connected"); - } - - protected on_disconnected() { - this.socket = null; - this.emit("disconnected"); - } - - protected on_error(error) { - // TODO: handle errors? + write(message: string) { + if (this.socket) { + this.socket.write(message); + } else { + this.messageCache.push(message); + } } } export class MessageIOReader extends AbstractMessageReader implements MessageReader { - private io: MessageIO; - private callback: DataCallback; - private buffer: MessageBuffer; - private nextMessageLength: number; - private messageToken: number; - private partialMessageTimer: NodeJS.Timeout | undefined; - private _partialMessageTimeout: number; + callback: DataCallback; + private buffer = new MessageBuffer(this); - public constructor(io: MessageIO, encoding: BufferEncoding = "utf8") { + constructor(public io: MessageIO) { super(); - this.io = io; - this.io.reader = this; - this.buffer = new MessageBuffer(encoding); - this._partialMessageTimeout = 10000; } - public set partialMessageTimeout(timeout: number) { - this._partialMessageTimeout = timeout; - } + listen(callback: DataCallback): Disposable { + this.buffer.reset(); - public get partialMessageTimeout(): number { - return this._partialMessageTimeout; - } - - public listen(callback: DataCallback): Disposable { - this.nextMessageLength = -1; - this.messageToken = 0; - this.partialMessageTimer = undefined; this.callback = callback; - this.io.on("data", this.onData.bind(this)); + + this.io.on("data", this.on_data.bind(this)); this.io.on("error", this.fireError.bind(this)); this.io.on("close", this.fireClose.bind(this)); return; } - private onData(data: Buffer | string): void { + private on_data(data: Buffer | string): void { this.buffer.append(data); while (true) { - if (this.nextMessageLength === -1) { - const headers = this.buffer.tryReadHeaders(); - if (!headers) { - return; - } - const contentLength = headers["Content-Length"]; - if (!contentLength) { - throw new Error("Header must provide a Content-Length property."); - } - const length = Number.parseInt(contentLength); - if (Number.isNaN(length)) { - throw new Error("Content-Length value must be a number."); - } - this.nextMessageLength = length; - // Take the encoding form the header. For compatibility - // treat both utf-8 and utf8 as node utf8 - } - const msg = this.buffer.tryReadContent(this.nextMessageLength); - if (msg === null) { - /** We haven't received the full message yet. */ - this.setPartialMessageTimer(); + const msg = this.buffer.ready(); + if (!msg) { return; } - this.clearPartialMessageTimer(); - this.nextMessageLength = -1; - this.messageToken++; const json = JSON.parse(msg); + // allow message to be modified + let modified: ResponseMessage | NotificationMessage; + if ("id" in json) { + modified = this.io.responseFilter(json); + } else if ("method" in json) { + modified = this.io.notificationFilter(json); + } else { + log.warn("rx [unhandled]:", json); + } - log.debug("rx:", json); + if (!modified) { + log.debug("rx [discarded]:", json); + return; + } + log.debug("rx:", modified); this.callback(json); - // callback - this.io.on_message_callback(json); } } - - private clearPartialMessageTimer(): void { - if (this.partialMessageTimer) { - clearTimeout(this.partialMessageTimer); - this.partialMessageTimer = undefined; - } - } - - private setPartialMessageTimer(): void { - this.clearPartialMessageTimer(); - if (this._partialMessageTimeout <= 0) { - return; - } - this.partialMessageTimer = setTimeout( - (token, timeout) => { - this.partialMessageTimer = undefined; - if (token === this.messageToken) { - this.firePartialMessage({ messageToken: token, waitingTime: timeout }); - this.setPartialMessageTimer(); - } - }, - this._partialMessageTimeout, - this.messageToken, - this._partialMessageTimeout, - ); - } } -const ContentLength: string = "Content-Length: "; -const CRLF = "\r\n"; export class MessageIOWriter extends AbstractMessageWriter implements MessageWriter { - private io: MessageIO; - private encoding: BufferEncoding; private errorCount: number; - public constructor(io: MessageIO, encoding: BufferEncoding = "utf8") { + constructor(public io: MessageIO) { super(); - this.io = io; - this.io.writer = this; - this.encoding = encoding as BufferEncoding; - this.errorCount = 0; - this.io.on("error", (error: any) => this.fireError(error)); - this.io.on("close", () => this.fireClose()); } - public end(): void {} - - public write(msg: Message): Promise { - // discard outgoing messages that we know aren't supported - if ((msg as RequestMessage).method === "didChangeWatchedFiles") { + async write(msg: RequestMessage) { + const modified = this.io.requestFilter(msg); + if (!modified) { + log.debug("tx [discarded]:", msg); return; } - if ((msg as RequestMessage).method === "workspace/symbol") { - return; - } - const json = JSON.stringify(msg); - const contentLength = Buffer.byteLength(json, this.encoding); + log.debug("tx:", modified); + const json = JSON.stringify(modified); - const headers: string[] = [ContentLength, contentLength.toString(), CRLF, CRLF]; + const contentLength = Buffer.byteLength(json, "utf-8").toString(); + const message = `Content-Length: ${contentLength}\r\n\r\n${json}`; try { - // callback - this.io.on_send_message(msg); - // Header must be written in ASCII encoding - this.io.send_message(headers.join("")); - // Now write the content. This can be written in any encoding - - log.debug("tx:", msg); - this.io.send_message(json); + this.io.write(message); this.errorCount = 0; } catch (error) { this.errorCount++; - this.fireError(error, msg, this.errorCount); + this.fireError(error, modified, this.errorCount); } - - return; } + + end(): void {} } diff --git a/src/providers/documentation.ts b/src/providers/documentation.ts index 8254a3a..1883327 100644 --- a/src/providers/documentation.ts +++ b/src/providers/documentation.ts @@ -14,7 +14,7 @@ import type { GodotNativeSymbol, GodotNativeClassInfo, GodotCapabilities, -} from "../lsp/gdscript.capabilities"; +} from "./documentation_types"; import { make_html_content } from "./documentation_builder"; import { createLogger, get_configuration, get_extension_uri, make_docs_uri } from "../utils"; import { globals } from "../extension"; diff --git a/src/providers/documentation_builder.ts b/src/providers/documentation_builder.ts index 9d7d3f2..2a5a5b6 100644 --- a/src/providers/documentation_builder.ts +++ b/src/providers/documentation_builder.ts @@ -3,7 +3,7 @@ import { SymbolKind } from "vscode-languageclient"; import * as Prism from "prismjs"; import * as csharp from "prismjs/components/prism-csharp"; import { marked } from "marked"; -import type { GodotNativeSymbol } from "../lsp/gdscript.capabilities"; +import type { GodotNativeSymbol } from "./documentation_types"; import { get_extension_uri } from "../utils"; import yabbcode = require("ya-bbcode"); diff --git a/src/lsp/gdscript.capabilities.ts b/src/providers/documentation_types.ts similarity index 100% rename from src/lsp/gdscript.capabilities.ts rename to src/providers/documentation_types.ts