Implement headless LSP mode (#488)

* adds new Headless LSP mode
* refactor and simplify LSP client control flow into new `ClientConnectionManager` class
* adds new setting: `godotTools.lsp.headless`, disabled by default
* split `godotTools.editorPath` into `godotTools.editorPath.godot3` and `.godot4`
* fix #373, broken formatting in hovers
* improve right click -> open docs to work on type-annotated variables

---------

Co-authored-by: David Kincaid <daelonsuzuka@gmail.com>
This commit is contained in:
Ryan Brue
2023-10-10 22:05:22 -05:00
committed by GitHub
parent 6a9f408d4e
commit f4e4b9c422
14 changed files with 1075 additions and 461 deletions

538
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@
"author": "The Godot Engine community",
"publisher": "geequlim",
"engines": {
"vscode": "^1.68.0"
"vscode": "^1.80.0"
},
"categories": [
"Programming Languages",
@@ -26,9 +26,6 @@
],
"activationEvents": [
"workspaceContains:project.godot",
"onLanguage:gdscript",
"onLanguage:gdshader",
"onLanguage:gdresource",
"onDebugResolve:godot"
],
"main": "./out/extension.js",
@@ -49,6 +46,14 @@
"command": "godotTools.openEditor",
"title": "Godot Tools: Open workspace with Godot editor"
},
{
"command": "godotTools.startLanguageServer",
"title": "Godot Tools: Start the GDScript Language Server for this workspace"
},
{
"command": "godotTools.stopLanguageServer",
"title": "Godot Tools: Stop the GDScript Language Server for this workspace"
},
{
"command": "godotTools.runProject",
"title": "Godot Tools: Run workspace as Godot project"
@@ -184,10 +189,20 @@
"default": 6008,
"description": "The server port of the GDScript language server"
},
"godotTools.editorPath": {
"godotTools.lsp.headless": {
"type": "boolean",
"default": false,
"description": "Whether to launch the LSP as a headless child process"
},
"godotTools.editorPath.godot3": {
"type": "string",
"default": "",
"description": "The absolute path to the Godot editor executable"
"default": "godot3",
"description": "The absolute path to the Godot 3 editor executable"
},
"godotTools.editorPath.godot4": {
"type": "string",
"default": "godot4",
"description": "The absolute path to the Godot 4 editor executable"
},
"godotTools.sceneFileConfig": {
"type": "string",
@@ -546,7 +561,7 @@
"editor/context": [
{
"command": "godotTools.openTypeDocumentation",
"when": "godotTools.context.connectedToEditor",
"when": "godotTools.context.connectedToLSP && godotTools.context.typeFound",
"group": "navigation@9"
},
{
@@ -560,15 +575,15 @@
"devDependencies": {
"@types/marked": "^0.6.5",
"@types/mocha": "^9.1.0",
"@types/node": "^10.12.21",
"@types/node": "^18.15.0",
"@types/prismjs": "^1.16.8",
"@types/vscode": "^1.68.0",
"@types/vscode": "^1.80.0",
"@types/ws": "^8.2.2",
"@vscode/vsce": "^2.21.0",
"esbuild": "^0.15.2",
"ts-node": "^10.9.1",
"tslint": "^5.20.1",
"typescript": "^3.5.1",
"vsce": "^2.10.0"
"typescript": "^5.2.2"
},
"dependencies": {
"await-notify": "^1.0.1",

View File

@@ -1,5 +1,5 @@
import * as vscode from "vscode";
import { Uri, Position, Range, TextDocument } from "vscode";
import { Uri, Position, Range } from "vscode";
import { convert_resource_path_to_uri } from "./utils";
export class GDDocumentLinkProvider implements vscode.DocumentLinkProvider {

View File

@@ -2,82 +2,70 @@ import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import { GDDocumentLinkProvider } from "./document_link_provider";
import GDScriptLanguageClient, { ClientStatus } from "./lsp/GDScriptLanguageClient";
import { ClientConnectionManager } from "./lsp/ClientConnectionManager";
import { ScenePreviewProvider } from "./scene_preview_provider";
import { get_configuration, set_configuration, find_file, set_context, find_project_file } from "./utils";
import {
get_configuration,
set_configuration,
find_file,
find_project_file,
register_command
} from "./utils";
const TOOL_NAME = "GodotTools";
export class GodotTools {
private reconnection_attempts = 0;
private context: vscode.ExtensionContext;
private client: GDScriptLanguageClient = null;
private lspClientManager: ClientConnectionManager = null;
private linkProvider: GDDocumentLinkProvider = null;
private scenePreviewManager: ScenePreviewProvider = null;
private connection_status: vscode.StatusBarItem = null;
constructor(p_context: vscode.ExtensionContext) {
this.context = p_context;
this.client = new GDScriptLanguageClient(p_context);
this.client.watch_status(this.on_client_status_changed.bind(this));
this.connection_status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right);
this.lspClientManager = new ClientConnectionManager(p_context);
this.linkProvider = new GDDocumentLinkProvider(p_context);
setInterval(() => {
this.retry_callback();
}, get_configuration("lsp.autoReconnect.cooldown", 3000));
}
public activate() {
vscode.commands.registerCommand("godotTools.openEditor", () => {
register_command("openEditor", () => {
this.open_workspace_with_editor("-e").catch(err => vscode.window.showErrorMessage(err));
});
vscode.commands.registerCommand("godotTools.runProject", () => {
register_command("runProject", () => {
this.open_workspace_with_editor().catch(err => vscode.window.showErrorMessage(err));
});
vscode.commands.registerCommand("godotTools.runProjectDebug", () => {
register_command("runProjectDebug", () => {
this.open_workspace_with_editor("--debug-collisions --debug-navigation").catch(err => vscode.window.showErrorMessage(err));
});
vscode.commands.registerCommand("godotTools.checkStatus", this.check_client_status.bind(this));
vscode.commands.registerCommand("godotTools.setSceneFile", this.set_scene_file.bind(this));
vscode.commands.registerCommand("godotTools.copyResourcePathContext", this.copy_resource_path.bind(this));
vscode.commands.registerCommand("godotTools.copyResourcePath", this.copy_resource_path.bind(this));
vscode.commands.registerCommand("godotTools.openTypeDocumentation", this.open_type_documentation.bind(this));
vscode.commands.registerCommand("godotTools.switchSceneScript", this.switch_scene_script.bind(this));
set_context("godotTools.context.connectedToEditor", false);
register_command("setSceneFile", this.set_scene_file.bind(this));
register_command("copyResourcePathContext", this.copy_resource_path.bind(this));
register_command("copyResourcePath", this.copy_resource_path.bind(this));
register_command("openTypeDocumentation", this.open_type_documentation.bind(this));
register_command("switchSceneScript", this.switch_scene_script.bind(this));
this.scenePreviewManager = new ScenePreviewProvider();
this.connection_status.text = "$(sync) Initializing";
this.connection_status.command = "godotTools.checkStatus";
this.connection_status.show();
this.reconnection_attempts = 0;
this.client.connect_to_server();
}
public deactivate() {
this.client.stop();
this.lspClientManager.client.stop();
}
private open_workspace_with_editor(params = "") {
return new Promise<void>(async (resolve, reject) => {
let valid = false;
let project_dir = '';
let project_file = '';
if (vscode.workspace.workspaceFolders != undefined) {
const files = await vscode.workspace.findFiles("**/project.godot");
if (files) {
project_file = files[0].fsPath;
project_dir = path.dirname(project_file);
let cfg = project_file;
valid = (fs.existsSync(cfg) && fs.statSync(cfg).isFile());
}
}
let project_dir = '';
let project_file = '';
if (vscode.workspace.workspaceFolders != undefined) {
const files = await vscode.workspace.findFiles("**/project.godot");
if (files) {
project_file = files[0].fsPath;
project_dir = path.dirname(project_file);
let cfg = project_file;
valid = (fs.existsSync(cfg) && fs.statSync(cfg).isFile());
}
}
if (valid) {
this.run_editor(`--path "${project_dir}" ${params}`).then(() => resolve()).catch(err => {
reject(err);
@@ -93,11 +81,11 @@ export class GodotTools {
uri = vscode.window.activeTextEditor.document.uri;
}
const project_dir = path.dirname(find_project_file(uri.fsPath));
if (project_dir === null) {
return
}
const project_dir = path.dirname(find_project_file(uri.fsPath));
if (project_dir === null) {
return;
}
let relative_path = path.normalize(path.relative(project_dir, uri.fsPath));
relative_path = relative_path.split(path.sep).join(path.posix.sep);
relative_path = "res://" + relative_path;
@@ -105,15 +93,8 @@ export class GodotTools {
vscode.env.clipboard.writeText(relative_path);
}
private open_type_documentation(uri: vscode.Uri) {
// get word under cursor
const activeEditor = vscode.window.activeTextEditor;
const document = activeEditor.document;
const curPos = activeEditor.selection.active;
const wordRange = document.getWordRangeAtPosition(curPos);
const symbolName = document.getText(wordRange);
this.client.open_documentation(symbolName);
private open_type_documentation() {
this.lspClientManager.client.open_documentation();
}
private async switch_scene_script() {
@@ -145,7 +126,7 @@ export class GodotTools {
}
private run_editor(params = "") {
// TODO: rewrite this entire function
return new Promise<void>((resolve, reject) => {
const run_godot = (path: string, params: string) => {
const is_powershell_path = (path?: string) => {
@@ -206,7 +187,8 @@ export class GodotTools {
resolve();
};
let editorPath = get_configuration("editorPath", "");
// TODO: This config doesn't exist anymore
let editorPath = get_configuration("editorPath");
if (!fs.existsSync(editorPath) || !fs.statSync(editorPath).isFile()) {
vscode.window.showOpenDialog({
openLabel: "Run",
@@ -228,95 +210,4 @@ export class GodotTools {
}
});
}
private check_client_status() {
let host = get_configuration("lsp.serverPort", "localhost");
let port = get_configuration("lsp.serverHost", 6008);
switch (this.client.status) {
case ClientStatus.PENDING:
vscode.window.showInformationMessage(`Connecting to the GDScript language server at ${host}:${port}`);
break;
case ClientStatus.CONNECTED:
vscode.window.showInformationMessage("Connected to the GDScript language server.");
break;
case ClientStatus.DISCONNECTED:
this.retry_connect_client();
break;
}
}
private on_client_status_changed(status: ClientStatus) {
let host = get_configuration("lsp.serverHost", "localhost");
let port = get_configuration("lsp.serverPort", 6008);
switch (status) {
case ClientStatus.PENDING:
this.connection_status.text = `$(sync) Connecting`;
this.connection_status.tooltip = `Connecting to the GDScript language server at ${host}:${port}`;
break;
case ClientStatus.CONNECTED:
this.retry = false;
set_context("godotTools.context.connectedToEditor", true);
this.connection_status.text = `$(check) Connected`;
this.connection_status.tooltip = `Connected to the GDScript language server.`;
if (!this.client.started) {
this.context.subscriptions.push(this.client.start());
}
break;
case ClientStatus.DISCONNECTED:
if (this.retry) {
this.connection_status.text = `$(sync) Connecting ` + this.reconnection_attempts;
this.connection_status.tooltip = `Connecting to the GDScript language server...`;
} else {
set_context("godotTools.context.connectedToEditor", false);
this.connection_status.text = `$(x) Disconnected`;
this.connection_status.tooltip = `Disconnected from the GDScript language server.`;
}
this.retry = true;
break;
default:
break;
}
}
private retry = false;
private retry_callback() {
if (this.retry) {
this.retry_connect_client();
}
}
private retry_connect_client() {
const auto_retry = get_configuration("lsp.autoReconnect.enabled", true);
const max_attempts = get_configuration("lsp.autoReconnect.attempts", 10);
if (auto_retry && this.reconnection_attempts <= max_attempts) {
this.reconnection_attempts++;
this.client.connect_to_server();
this.connection_status.text = `Connecting ` + this.reconnection_attempts;
this.retry = true;
return;
}
this.retry = false;
this.connection_status.text = `$(x) Disconnected`;
this.connection_status.tooltip = `Disconnected from the GDScript language server.`;
let host = get_configuration("lsp.serverHost", "localhost");
let port = get_configuration("lsp.serverPort", 6008);
let message = `Couldn't connect to the GDScript language server at ${host}:${port}. Is the Godot editor running?`;
vscode.window.showErrorMessage(message, "Open Godot Editor", "Retry", "Ignore").then(item => {
if (item == "Retry") {
this.reconnection_attempts = 0;
this.client.connect_to_server();
} else if (item == "Open Godot Editor") {
this.client.status = ClientStatus.PENDING;
this.open_workspace_with_editor("-e").then(() => {
setTimeout(() => {
this.reconnection_attempts = 0;
this.client.connect_to_server();
}, 10 * 1000);
});
}
});
}
}

View File

@@ -1,20 +1,21 @@
export class Logger {
protected buffer: string = "";
protected tag: string = '';
protected tag: string = "";
protected time: boolean = false;
constructor(tag: string, time: boolean) {
this.tag = tag;
this.time = time;
}
clear() {
this.buffer = "";
}
log(...messages) {
let line = '';
let line = "";
if (this.tag) {
line += `[${this.tag}]`;
}
@@ -22,9 +23,9 @@ export class Logger {
line += `[${new Date().toISOString()}]`;
}
if (line) {
line += ' ';
line += " ";
}
for (let index = 0; index < messages.length; index++) {
line += messages[index];
if (index < messages.length) {
@@ -33,15 +34,75 @@ export class Logger {
line += "\n";
}
}
this.buffer += line;
console.log(line);
}
get_buffer(): string {
return this.buffer;
}
}
const logger = new Logger('godot-tools', true);
export class Logger2 {
protected tag: string = "";
protected level: string = "";
protected time: boolean = false;
constructor(tag: string) {
this.tag = tag;
}
log(...messages) {
let line = "[godotTools]";
if (this.time) {
line += `[${new Date().toISOString()}]`;
}
if (this.level) {
line += `[${this.level}]`;
this.level = "";
}
if (this.tag) {
line += `[${this.tag}]`;
}
if (line) {
line += " ";
}
for (let index = 0; index < messages.length; index++) {
line += messages[index];
if (index < messages.length) {
line += " ";
} else {
line += "\n";
}
}
console.log(line);
}
info(...messages) {
this.level = "INFO";
this.log(messages);
}
debug(...messages) {
this.level = "DEBUG";
this.log(messages);
}
warn(...messages) {
this.level = "WARNING";
this.log(messages);
}
error(...messages) {
this.level = "ERROR";
this.log(messages);
}
}
export function createLogger(tag) {
return new Logger2(tag);
}
const logger = new Logger("godot-tools", true);
export default logger;

View File

@@ -0,0 +1,331 @@
import * as vscode from "vscode";
import * as fs from "fs";
import GDScriptLanguageClient, { ClientStatus } from "./GDScriptLanguageClient";
import {
get_configuration,
get_free_port,
get_project_version,
get_project_dir,
set_context,
register_command,
set_configuration,
} from "../utils";
import { createLogger } from "../logger";
import { execSync } from "child_process";
import { subProcess, killSubProcesses } from '../utils/subspawn';
const log = createLogger("lsp.manager");
enum ManagerStatus {
INITIALIZING,
INITIALIZING_LSP,
PENDING,
PENDING_LSP,
DISCONNECTED,
CONNECTED,
RETRYING,
}
export class ClientConnectionManager {
private context: vscode.ExtensionContext;
public client: GDScriptLanguageClient = null;
private reconnection_attempts = 0;
private status: ManagerStatus = ManagerStatus.INITIALIZING;
private statusWidget: vscode.StatusBarItem = null;
constructor(p_context: vscode.ExtensionContext) {
this.context = p_context;
this.client = new GDScriptLanguageClient(p_context);
this.client.watch_status(this.on_client_status_changed.bind(this));
setInterval(() => {
this.retry_callback();
}, get_configuration("lsp.autoReconnect.cooldown"));
register_command("startLanguageServer", () => {
this.start_language_server();
this.reconnection_attempts = 0;
this.client.connect_to_server();
});
register_command("stopLanguageServer", this.stop_language_server.bind(this));
register_command("checkStatus", this.on_status_item_click.bind(this));
set_context("connectedToLSP", false);
this.statusWidget = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right);
this.statusWidget.command = "godotTools.checkStatus";
this.statusWidget.show();
this.update_status_widget();
this.connect_to_language_server();
}
private async connect_to_language_server() {
this.client.port = -1;
if (get_configuration("lsp.headless")) {
await this.start_language_server();
}
this.reconnection_attempts = 0;
this.client.connect_to_server();
}
private stop_language_server() {
killSubProcesses('LSP');
}
private async start_language_server() {
this.stop_language_server();
const projectDir = await get_project_dir();
if (!projectDir) {
vscode.window.showErrorMessage("Current workspace is not a Godot project");
return;
}
const projectVersion = await get_project_version();
let minimumVersion = '6';
let targetVersion = '3.6';
if (projectVersion.startsWith('4')) {
minimumVersion = '2';
targetVersion = '4.2';
}
const settingName = `editorPath.godot${projectVersion[0]}`;
const godotPath = get_configuration(settingName);
try {
const output = execSync(`${godotPath} --version`).toString().trim();
const pattern = /([34])\.([0-9]+)\.(?:[0-9]+\.)?\w+.\w+.[0-9a-f]{9}/;
const match = output.match(pattern);
if (!match) {
const message = `Cannot launch headless LSP: '${settingName}' of '${godotPath}' is not a valid Godot executable`;
vscode.window.showErrorMessage(message, "Select Godot executable", "Ignore").then(item => {
if (item == "Select Godot executable") {
this.select_godot_executable(settingName);
}
});
return;
}
if (match[1] !== projectVersion[0]) {
const message = `Cannot launch headless LSP: The current project uses Godot v${projectVersion}, but the specified Godot executable is version ${match[0]}`;
vscode.window.showErrorMessage(message, "Select Godot executable", "Ignore").then(item => {
if (item == "Select Godot executable") {
this.select_godot_executable(settingName);
}
});
return;
}
if (match[2] < minimumVersion) {
const message = `Cannot launch headless LSP: Headless LSP mode is only available on version ${targetVersion} or newer, but the specified Godot executable is version ${match[0]}.`;
vscode.window.showErrorMessage(message, "Select Godot executable", "Disable Headless LSP", "Ignore").then(item => {
if (item == "Select Godot executable") {
this.select_godot_executable(settingName);
} else if (item == "Disable Headless LSP") {
set_configuration("lsp.headless", false);
this.prompt_for_reload();
}
});
return;
}
} catch (e) {
const message = `Cannot launch headless LSP: ${settingName} of ${godotPath} is not a valid Godot executable`;
vscode.window.showErrorMessage(message, "Select Godot executable", "Ignore").then(item => {
if (item == "Select Godot executable") {
this.select_godot_executable(settingName);
}
});
return;
}
this.client.port = await get_free_port();
log.info(`starting headless LSP on port ${this.client.port}`);
const headlessFlags = "--headless --no-window";
const command = `${godotPath} --path "${projectDir}" --editor ${headlessFlags} --lsp-port ${this.client.port}`;
const lspProcess = subProcess("LSP", command, { shell: true });
const lspStdout = createLogger("lsp.stdout");
lspProcess.stdout.on('data', (data) => {
const out = data.toString().trim();
if (out) {
lspStdout.debug(out);
}
});
// const lspStderr = createLogger("lsp.stderr");
// lspProcess.stderr.on('data', (data) => {
// const out = data.toString().trim();
// if (out) {
// lspStderr.debug(out);
// }
// });
lspProcess.on('close', (code) => {
log.info(`LSP process exited with code ${code}`);
});
}
private async select_godot_executable(settingName: string) {
vscode.window.showOpenDialog({
openLabel: "Select Godot executable",
filters: process.platform === "win32" ? { "Godot Editor Binary": ["exe", "EXE"] } : undefined
}).then(async (uris: vscode.Uri[]) => {
if (!uris) {
return;
}
const path = uris[0].fsPath;
set_configuration(settingName, path);
this.prompt_for_reload();
});
}
private async prompt_for_reload() {
const message = `Reload VSCode to apply settings`;
vscode.window.showErrorMessage(message, "Reload").then(item => {
if (item == "Reload") {
vscode.commands.executeCommand("workbench.action.reloadWindow");
}
});
}
private get_lsp_connection_string() {
let host = get_configuration("lsp.serverHost");
let port = get_configuration("lsp.serverPort");
if (this.client.port !== -1) {
port = this.client.port;
}
return `${host}:${port}`;
}
private on_status_item_click() {
const lsp_target = this.get_lsp_connection_string();
// TODO: fill these out with the ACTIONS a user could perform in each state
switch (this.status) {
case ManagerStatus.INITIALIZING:
// vscode.window.showInformationMessage("Initializing extension");
break;
case ManagerStatus.INITIALIZING_LSP:
// vscode.window.showInformationMessage("Initializing LSP");
break;
case ManagerStatus.PENDING:
// vscode.window.showInformationMessage(`Connecting to the GDScript language server at ${lsp_target}`);
break;
case ManagerStatus.CONNECTED:
// vscode.window.showInformationMessage("Connected to the GDScript language server.");
break;
case ManagerStatus.DISCONNECTED:
this.retry_connect_client();
break;
case ManagerStatus.RETRYING:
break;
}
}
private update_status_widget() {
const lsp_target = this.get_lsp_connection_string();
switch (this.status) {
case ManagerStatus.INITIALIZING:
// this.statusWidget.text = `INITIALIZING`;
this.statusWidget.text = `$(sync~spin) Initializing`;
this.statusWidget.tooltip = `Initializing extension...`;
break;
case ManagerStatus.INITIALIZING_LSP:
// this.statusWidget.text = `INITIALIZING_LSP ` + this.reconnection_attempts;
this.statusWidget.text = `$(sync~spin) Initializing LSP`;
this.statusWidget.tooltip = `Connecting to headless GDScript language server at ${lsp_target}`;
break;
case ManagerStatus.PENDING:
// this.statusWidget.text = `PENDING`;
this.statusWidget.text = `$(sync~spin) Connecting`;
this.statusWidget.tooltip = `Connecting to the GDScript language server at ${lsp_target}`;
break;
case ManagerStatus.CONNECTED:
// this.statusWidget.text = `CONNECTED`;
this.statusWidget.text = `$(check) Connected`;
this.statusWidget.tooltip = `Connected to the GDScript language server.`;
break;
case ManagerStatus.DISCONNECTED:
// this.statusWidget.text = `DISCONNECTED`;
this.statusWidget.text = `$(x) Disconnected`;
this.statusWidget.tooltip = `Disconnected from the GDScript language server.`;
break;
case ManagerStatus.RETRYING:
// this.statusWidget.text = `RETRYING ` + this.reconnection_attempts;
this.statusWidget.text = `$(sync~spin) Connecting ` + this.reconnection_attempts;
this.statusWidget.tooltip = `Connecting to the GDScript language server at ${lsp_target}`;
break;
}
}
private on_client_status_changed(status: ClientStatus) {
switch (status) {
case ClientStatus.PENDING:
this.status = ManagerStatus.PENDING;
break;
case ClientStatus.CONNECTED:
this.retry = false;
set_context("connectedToLSP", true);
this.status = ManagerStatus.CONNECTED;
if (!this.client.started) {
this.context.subscriptions.push(this.client.start());
}
break;
case ClientStatus.DISCONNECTED:
set_context("connectedToLSP", false);
if (this.retry) {
if (this.client.port != -1) {
this.status = ManagerStatus.INITIALIZING_LSP;
} else {
this.status = ManagerStatus.RETRYING;
}
} else {
this.status = ManagerStatus.DISCONNECTED;
}
this.retry = true;
break;
default:
break;
}
this.update_status_widget();
}
private retry = false;
private retry_callback() {
if (this.retry) {
this.retry_connect_client();
}
}
private retry_connect_client() {
const auto_retry = get_configuration("lsp.autoReconnect.enabled");
const max_attempts = get_configuration("lsp.autoReconnect.attempts");
if (auto_retry && this.reconnection_attempts <= max_attempts - 1) {
this.reconnection_attempts++;
this.client.connect_to_server();
this.retry = true;
return;
}
this.retry = false;
this.status = ManagerStatus.DISCONNECTED;
this.update_status_widget();
const lsp_target = this.get_lsp_connection_string();
let message = `Couldn't connect to the GDScript language server at ${lsp_target}. Is the Godot editor or language server running?`;
vscode.window.showErrorMessage(message, "Retry", "Ignore").then(item => {
if (item == "Retry") {
this.connect_to_language_server();
}
});
}
}

View File

@@ -1,11 +1,13 @@
import { EventEmitter } from "events";
import * as vscode from 'vscode';
import { LanguageClient, RequestMessage } from "vscode-languageclient/node";
import logger from "../logger";
import { get_configuration, is_debug_mode } from "../utils";
import { LanguageClient, RequestMessage, ResponseMessage } from "vscode-languageclient/node";
import { createLogger } from "../logger";
import { get_configuration, set_context } from "../utils";
import { Message, MessageIO, MessageIOReader, MessageIOWriter, TCPMessageIO, WebSocketMessageIO } from "./MessageIO";
import NativeDocumentManager from './NativeDocumentManager';
const log = createLogger("lsp.client");
export enum ClientStatus {
PENDING,
DISCONNECTED,
@@ -15,19 +17,23 @@ const CUSTOM_MESSAGE = "gdscrip_client/";
export default class GDScriptLanguageClient extends LanguageClient {
public readonly io: MessageIO = (get_configuration("lsp.serverProtocol", "tcp") == "ws") ? new WebSocketMessageIO() : new TCPMessageIO();
public readonly io: MessageIO = (get_configuration("lsp.serverProtocol") == "ws") ? new WebSocketMessageIO() : new TCPMessageIO();
private context: vscode.ExtensionContext;
private _started : boolean = false;
private _status : ClientStatus;
private _status_changed_callbacks: ((v : ClientStatus)=>void)[] = [];
private _started: boolean = false;
private _status: ClientStatus;
private _status_changed_callbacks: ((v: ClientStatus) => void)[] = [];
private _initialize_request: Message = null;
private message_handler: MessageHandler = null;
private native_doc_manager: NativeDocumentManager = null;
public get started() : boolean { return this._started; }
public get status() : ClientStatus { return this._status; }
public set status(v : ClientStatus) {
public port: number = -1;
public sentMessages = new Map();
public lastSymbolHovered: string = "";
public get started(): boolean { return this._started; }
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) {
@@ -36,14 +42,15 @@ export default class GDScriptLanguageClient extends LanguageClient {
}
}
public watch_status(callback: (v : ClientStatus)=>void) {
public watch_status(callback: (v: ClientStatus) => void) {
if (this._status_changed_callbacks.indexOf(callback) == -1) {
this._status_changed_callbacks.push(callback);
}
}
public open_documentation(symbolName: string) {
this.native_doc_manager.request_documentation(symbolName);
public open_documentation() {
const symbol = this.lastSymbolHovered;
this.native_doc_manager.request_documentation(symbol);
}
constructor(context: vscode.ExtensionContext) {
@@ -51,7 +58,7 @@ export default class GDScriptLanguageClient extends LanguageClient {
`GDScriptLanguageClient`,
() => {
return new Promise((resolve, reject) => {
resolve({reader: new MessageIOReader(this.io), writer: new MessageIOWriter(this.io)});
resolve({ reader: new MessageIOReader(this.io), writer: new MessageIOWriter(this.io) });
});
},
{
@@ -78,45 +85,91 @@ export default class GDScriptLanguageClient extends LanguageClient {
connect_to_server() {
this.status = ClientStatus.PENDING;
let host = get_configuration("lsp.serverHost", "127.0.0.1");
let port = get_configuration("lsp.serverPort", 6008);
const host = get_configuration("lsp.serverHost");
let port = get_configuration("lsp.serverPort");
if (this.port !== -1) {
port = this.port;
}
log.info(`attempting to connect to LSP at port ${port}`);
this.io.connect_to_language_server(host, port);
}
start(): vscode.Disposable {
start() {
this._started = true;
return super.start();
}
private on_send_message(message: Message) {
if (is_debug_mode()) {
logger.log("[client]", JSON.stringify(message));
}
if ((message as RequestMessage).method == "initialize") {
private on_send_message(message: RequestMessage) {
log.debug("tx: " + JSON.stringify(message));
this.sentMessages.set(message.id, message.method);
if (message.method == "initialize") {
this._initialize_request = message;
}
}
private on_message(message: Message) {
if (is_debug_mode()) {
logger.log("[server]", JSON.stringify(message));
}
private on_message(message: ResponseMessage) {
const msgString = JSON.stringify(message);
log.debug("rx: " + msgString);
// 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 = JSON.stringify(message).match(/"target":"file:\/\/[^\/][^"]*"/);
const match = msgString.match(/"target":"file:\/\/[^\/][^"]*"/);
if (match) {
for (let i = 0; i < message["result"].length; i++) {
const x = message["result"][i]["target"];
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:///');
}
}
const method = this.sentMessages.get(message.id);
if (method === "textDocument/hover") {
this.handle_hover_response(message);
// 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
const value: string = message.result["contents"].value;
message.result["contents"].value = value.replace(/\n[#]+/g, '\n');
}
this.message_handler.on_message(message);
}
private handle_hover_response(message: ResponseMessage) {
this.lastSymbolHovered = "";
set_context("typeFound", false);
let decl: string = message.result["contents"].value;
decl = decl.split('\n')[0].trim();
// strip off the value
if (decl.includes("=")) {
decl = decl.split("=")[0];
}
if (decl.includes(":")) {
const parts = decl.split(":");
if (parts.length === 2) {
decl = parts[1].trim();
}
}
if (decl.includes("<Native>")) {
decl = decl.split(" ")[2];
}
if (decl.includes(" ")) {
return;
}
this.lastSymbolHovered = decl;
set_context("typeFound", true);
}
private on_connected() {
if (this._initialize_request) {
this.io.writer.write(this._initialize_request);
@@ -129,10 +182,7 @@ export default class GDScriptLanguageClient extends LanguageClient {
}
}
class MessageHandler extends EventEmitter {
private io: MessageIO = null;
constructor(io: MessageIO) {
@@ -140,8 +190,8 @@ class MessageHandler extends EventEmitter {
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=>{
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);
@@ -150,7 +200,6 @@ class MessageHandler extends EventEmitter {
}
on_message(message: any) {
// FIXME: Hot fix VSCode 1.42 hover position
if (message && message.result && message.result.range && message.result.contents) {
message.result.range = undefined;

View File

@@ -10,12 +10,12 @@ const CRLF: string = '\r\n';
export default class MessageBuffer {
private encoding: string;
private encoding: BufferEncoding;
private index: number;
private buffer: Buffer;
constructor(encoding: string = 'utf8') {
this.encoding = encoding;
this.encoding = encoding as BufferEncoding;
this.index = 0;
this.buffer = Buffer.allocUnsafe(DefaultSize);
}

View File

@@ -108,7 +108,7 @@ export class MessageIOReader extends AbstractMessageReader implements MessageRea
private buffer: MessageBuffer;
private nextMessageLength: number;
private messageToken: number;
private partialMessageTimer: NodeJS.Timer | undefined;
private partialMessageTimer: NodeJS.Timeout | undefined;
private _partialMessageTimeout: number;
public constructor(io: MessageIO, encoding: string = 'utf8') {
@@ -204,14 +204,14 @@ const CRLF = '\r\n';
export class MessageIOWriter extends AbstractMessageWriter implements MessageWriter {
private io: MessageIO;
private encoding: string;
private encoding: BufferEncoding;
private errorCount: number;
public constructor(io: MessageIO, encoding: string = 'utf8') {
super();
this.io = io;
this.io.writer = this;
this.encoding = encoding;
this.encoding = encoding as BufferEncoding;
this.errorCount = 0;
this.io.on('error', (error: any) => this.fireError(error));
this.io.on('close', () => this.fireClose());

View File

@@ -5,7 +5,7 @@ import { MessageIO } from "./MessageIO";
import { NotificationMessage } from "vscode-jsonrpc";
import * as Prism from "../deps/prism/prism";
import * as marked from "marked";
import { get_configuration } from "../utils";
import { get_configuration, register_command } from "../utils";
import {
Methods,
NativeSymbolInspectParams,
@@ -13,6 +13,7 @@ import {
GodotNativeClassInfo,
GodotCapabilities,
} from "./gdscript.capabilities";
marked.setOptions({
highlight: function (code, lang) {
return Prism.highlight(code, GDScriptGrammar, lang);
@@ -52,10 +53,7 @@ export default class NativeDocumentManager extends EventEmitter {
}
});
vscode.commands.registerCommand(
"godotTools.listNativeClasses",
this.list_native_classes.bind(this)
);
register_command("listNativeClasses", this.list_native_classes.bind(this));
}
public request_documentation(symbolName: string) {
@@ -85,7 +83,7 @@ export default class NativeDocumentManager extends EventEmitter {
private inspect_native_symbol(params: NativeSymbolInspectParams) {
let json_data = "";
if (get_configuration("lsp.serverProtocol", "tcp") == "ws") {
if (get_configuration("lsp.serverProtocol") == "ws") {
json_data = JSON.stringify({
id: -1,
jsonrpc: "2.0",
@@ -129,7 +127,7 @@ export default class NativeDocumentManager extends EventEmitter {
* configuration and previously opened native symbols.
*/
private get_new_native_symbol_column(): vscode.ViewColumn {
const config_placement = get_configuration("nativeSymbolPlacement", "beside");
const config_placement = get_configuration("nativeSymbolPlacement");
if (config_placement == "active") {
return vscode.ViewColumn.Active;
@@ -297,8 +295,7 @@ export default class NativeDocumentManager extends EventEmitter {
);
const title = element(
"p",
`${
with_class ? `signal ${with_class ? `${classlink}.` : ""}` : ""
`${with_class ? `signal ${with_class ? `${classlink}.` : ""}` : ""
}${s.name}( ${args} )`
);
const doc = element(
@@ -439,9 +436,8 @@ function element<K extends keyof HTMLElementTagNameMap>(
props_str += ` ${key}="${props[key]}"`;
}
}
return `${indent || ""}<${tag} ${props_str}>${content}</${tag}>${
new_line ? "\n" : ""
}`;
return `${indent || ""}<${tag} ${props_str}>${content}</${tag}>${new_line ? "\n" : ""
}`;
}
function make_link(classname: string, symbol: string) {
if (!symbol || symbol == classname) {

View File

@@ -10,12 +10,16 @@ import {
import path = require("path");
import fs = require("fs");
import * as vscode from "vscode";
import { get_configuration, set_configuration, find_file, set_context, convert_resource_path_to_uri } from "./utils";
import logger from "./logger";
import {
get_configuration,
find_file,
set_context,
convert_resource_path_to_uri,
register_command,
} from "./utils";
import { createLogger } from "./logger";
function log(...messages) {
logger.log("[scene preview]", messages);
}
const log = createLogger("scene preview");
export class ScenePreviewProvider implements TreeDataProvider<SceneNode> {
private root: SceneNode | undefined;
@@ -84,15 +88,15 @@ export class ScenePreviewProvider implements TreeDataProvider<SceneNode> {
this.tree.onDidChangeSelection(this.tree_selection_changed);
vscode.commands.registerCommand("godotTools.scenePreview.pin", this.pin_preview.bind(this));
vscode.commands.registerCommand("godotTools.scenePreview.unpin", this.unpin_preview.bind(this));
vscode.commands.registerCommand("godotTools.scenePreview.copyNodePath", this.copy_node_path.bind(this));
vscode.commands.registerCommand("godotTools.scenePreview.copyResourcePath", this.copy_resource_path.bind(this));
vscode.commands.registerCommand("godotTools.scenePreview.openScene", this.open_scene.bind(this));
vscode.commands.registerCommand("godotTools.scenePreview.openScript", this.open_script.bind(this));
vscode.commands.registerCommand("godotTools.scenePreview.goToDefinition", this.go_to_definition.bind(this));
register_command("scenePreview.pin", this.pin_preview.bind(this));
register_command("scenePreview.unpin", this.unpin_preview.bind(this));
register_command("scenePreview.copyNodePath", this.copy_node_path.bind(this));
register_command("scenePreview.copyResourcePath", this.copy_resource_path.bind(this));
register_command("scenePreview.openScene", this.open_scene.bind(this));
register_command("scenePreview.openScript", this.open_script.bind(this));
register_command("scenePreview.goToDefinition", this.go_to_definition.bind(this));
vscode.commands.registerCommand("godotTools.scenePreview.refresh", () =>
register_command("scenePreview.refresh", () =>
this.refresh()
);

View File

@@ -1,49 +1,106 @@
import * as vscode from "vscode";
import * as path from "path";
import * as fs from "fs";
import { AddressInfo, createServer } from "net";
const CONFIG_CONTAINER = "godotTools";
const EXTENSION_PREFIX = "godotTools";
export function get_configuration(name: string, default_value: any = null) {
let config_value = vscode.workspace.getConfiguration(CONFIG_CONTAINER).get(name, null);
if (config_value === null) {
const config = vscode.workspace.getConfiguration(EXTENSION_PREFIX);
export function get_configuration(name: string, default_value?: any) {
let config_value = config.get(name, null);
if (default_value && config_value === null) {
return default_value;
}
return config_value;
}
export function set_configuration(name: string, value: any) {
return vscode.workspace.getConfiguration(CONFIG_CONTAINER).update(name, value);
return config.update(name, value);
}
export function is_debug_mode(): boolean {
return process.env.VSCODE_DEBUG_MODE === "true";
}
const CONTEXT_PREFIX = `${EXTENSION_PREFIX}.context.`;
export function set_context(name: string, value: any) {
vscode.commands.executeCommand("setContext", name, value);
return vscode.commands.executeCommand("setContext", CONTEXT_PREFIX + name, value);
}
export function find_project_file(start: string, depth:number=20) {
// This function appears to be fast enough, but if speed is ever an issue,
// memoizing the result should be straightforward
const folder = path.dirname(start);
if (start == folder) {
return null;
}
const project_file = path.join(folder, "project.godot");
if (fs.existsSync(project_file)) {
return project_file;
} else {
if (depth === 0) {
return null;
}
return find_project_file(folder, depth - 1);
}
export function register_command(command: string, callback: (...args: any[]) => any, thisArg?: any): vscode.Disposable {
return vscode.commands.registerCommand(`${EXTENSION_PREFIX}.${command}`, callback);
}
export async function find_file(file: string): Promise<vscode.Uri|null> {
export function get_word_under_cursor(): string {
const activeEditor = vscode.window.activeTextEditor;
const document = activeEditor.document;
const curPos = activeEditor.selection.active;
const wordRange = document.getWordRangeAtPosition(curPos);
const symbolName = document.getText(wordRange);
return symbolName;
}
export async function get_project_version(): Promise<string | undefined> {
const project_dir = await get_project_dir();
if (!project_dir) {
return undefined;
}
let godot_version = '3.x';
const project_file = vscode.Uri.file(path.join(project_dir, 'project.godot'));
const document = await vscode.workspace.openTextDocument(project_file);
const text = document.getText();
const match = text.match(/config\/features=PackedStringArray\((.*)\)/);
if (match) {
const line = match[0];
const version = line.match(/\"(4.[0-9]+)\"/);
if (version) {
godot_version = version[1];
}
}
return godot_version;
}
export async function get_project_dir() {
let project_dir = undefined;
let project_file = '';
if (vscode.workspace.workspaceFolders != undefined) {
const files = await vscode.workspace.findFiles("**/project.godot");
if (files) {
project_file = files[0].fsPath;
if (fs.existsSync(project_file) && fs.statSync(project_file).isFile()) {
project_dir = path.dirname(project_file);
}
}
}
return project_dir;
}
export function find_project_file(start: string, depth: number = 20) {
// TODO: rename this, it's actually more like "find_parent_project_file"
// This function appears to be fast enough, but if speed is ever an issue,
// memoizing the result should be straightforward
const folder = path.dirname(start);
if (start == folder) {
return null;
}
const project_file = path.join(folder, "project.godot");
if (fs.existsSync(project_file) && fs.statSync(project_file).isFile()) {
return project_file;
} else {
if (depth === 0) {
return null;
}
return find_project_file(folder, depth - 1);
}
}
export async function find_file(file: string): Promise<vscode.Uri | null> {
if (fs.existsSync(file)) {
return vscode.Uri.file(file);
} else {
@@ -56,7 +113,7 @@ export async function find_file(file: string): Promise<vscode.Uri|null> {
return null;
}
export async function convert_resource_path_to_uri(resPath: string): Promise<vscode.Uri|null> {
export async function convert_resource_path_to_uri(resPath: string): Promise<vscode.Uri | null> {
const files = await vscode.workspace.findFiles("**/project.godot");
if (!files) {
return null;
@@ -64,3 +121,13 @@ export async function convert_resource_path_to_uri(resPath: string): Promise<vsc
const project_dir = files[0].fsPath.replace("project.godot", "");
return vscode.Uri.joinPath(vscode.Uri.file(project_dir), resPath.substring(6));
}
export async function get_free_port(): Promise<number> {
return new Promise(res => {
const srv = createServer();
srv.listen(0, () => {
const port = (srv.address() as AddressInfo).port;
srv.close((err) => res(port));
});
});
}

52
src/utils/subspawn.ts Normal file
View File

@@ -0,0 +1,52 @@
/*
Copied from https://github.com/craigwardman/subspawn
Original library copyright (c) 2022 Craig Wardman
I had to vendor this library to fix the API in a couple places.
*/
import { ChildProcess, execSync, spawn, SpawnOptions } from 'child_process';
interface DictionaryOfStringChildProcessArray {
[key: string]: ChildProcess[];
}
const children: DictionaryOfStringChildProcessArray = {};
export function killSubProcesses(owner: string) {
if (!(owner in children)) {
return;
}
children[owner].forEach((c) => {
try {
if (c.pid) {
if (process.platform === 'win32') {
execSync(`taskkill /pid ${c.pid} /T /F`);
} else {
process.kill(-c.pid);
}
}
} catch { }
});
}
process.on('exit', () => {
Object.keys(children).forEach((owner) => killSubProcesses(owner));
});
function gracefulExitHandler() {
process.exit();
}
process.on('SIGINT', gracefulExitHandler);
process.on('SIGTERM', gracefulExitHandler);
process.on('SIGQUIT', gracefulExitHandler);
export function subProcess(owner: string, command: string, options?: SpawnOptions) {
const childProcess = spawn(command, options);
children[owner] = children[owner] || [];
children[owner].push(childProcess);
return childProcess;
}

View File

@@ -1,7 +1,7 @@
{
"compilerOptions": {
"module": "commonjs",
"target": "es6",
"target": "es2020",
"outDir": "out",
"lib": [
"es2020",