mirror of
https://github.com/godotengine/godot-vscode-plugin.git
synced 2025-12-31 13:48:24 +03:00
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
This commit is contained in:
15
package.json
15
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",
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<object>).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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = <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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void> {
|
||||
async connect(host: string, port: number): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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 {}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user