Browse Source

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 <[email protected]>
Ryan Brue 1 year ago
parent
commit
f4e4b9c422

File diff suppressed because it is too large
+ 274 - 120
package-lock.json


+ 27 - 12
package.json

@@ -15,7 +15,7 @@
 	"author": "The Godot Engine community",
 	"author": "The Godot Engine community",
 	"publisher": "geequlim",
 	"publisher": "geequlim",
 	"engines": {
 	"engines": {
-		"vscode": "^1.68.0"
+		"vscode": "^1.80.0"
 	},
 	},
 	"categories": [
 	"categories": [
 		"Programming Languages",
 		"Programming Languages",
@@ -26,9 +26,6 @@
 	],
 	],
 	"activationEvents": [
 	"activationEvents": [
 		"workspaceContains:project.godot",
 		"workspaceContains:project.godot",
-		"onLanguage:gdscript",
-		"onLanguage:gdshader",
-		"onLanguage:gdresource",
 		"onDebugResolve:godot"
 		"onDebugResolve:godot"
 	],
 	],
 	"main": "./out/extension.js",
 	"main": "./out/extension.js",
@@ -49,6 +46,14 @@
 				"command": "godotTools.openEditor",
 				"command": "godotTools.openEditor",
 				"title": "Godot Tools: Open workspace with Godot editor"
 				"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",
 				"command": "godotTools.runProject",
 				"title": "Godot Tools: Run workspace as Godot project"
 				"title": "Godot Tools: Run workspace as Godot project"
@@ -184,10 +189,20 @@
 					"default": 6008,
 					"default": 6008,
 					"description": "The server port of the GDScript language server"
 					"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",
 					"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": {
 				"godotTools.sceneFileConfig": {
 					"type": "string",
 					"type": "string",
@@ -546,7 +561,7 @@
 			"editor/context": [
 			"editor/context": [
 				{
 				{
 					"command": "godotTools.openTypeDocumentation",
 					"command": "godotTools.openTypeDocumentation",
-					"when": "godotTools.context.connectedToEditor",
+					"when": "godotTools.context.connectedToLSP && godotTools.context.typeFound",
 					"group": "navigation@9"
 					"group": "navigation@9"
 				},
 				},
 				{
 				{
@@ -560,15 +575,15 @@
 	"devDependencies": {
 	"devDependencies": {
 		"@types/marked": "^0.6.5",
 		"@types/marked": "^0.6.5",
 		"@types/mocha": "^9.1.0",
 		"@types/mocha": "^9.1.0",
-		"@types/node": "^10.12.21",
+		"@types/node": "^18.15.0",
 		"@types/prismjs": "^1.16.8",
 		"@types/prismjs": "^1.16.8",
-		"@types/vscode": "^1.68.0",
+		"@types/vscode": "^1.80.0",
 		"@types/ws": "^8.2.2",
 		"@types/ws": "^8.2.2",
+		"@vscode/vsce": "^2.21.0",
 		"esbuild": "^0.15.2",
 		"esbuild": "^0.15.2",
 		"ts-node": "^10.9.1",
 		"ts-node": "^10.9.1",
 		"tslint": "^5.20.1",
 		"tslint": "^5.20.1",
-		"typescript": "^3.5.1",
-		"vsce": "^2.10.0"
+		"typescript": "^5.2.2"
 	},
 	},
 	"dependencies": {
 	"dependencies": {
 		"await-notify": "^1.0.1",
 		"await-notify": "^1.0.1",

+ 1 - 1
src/document_link_provider.ts

@@ -1,5 +1,5 @@
 import * as vscode from "vscode";
 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";
 import { convert_resource_path_to_uri } from "./utils";
 
 
 export class GDDocumentLinkProvider implements vscode.DocumentLinkProvider {
 export class GDDocumentLinkProvider implements vscode.DocumentLinkProvider {

+ 42 - 151
src/godot-tools.ts

@@ -2,82 +2,70 @@ import * as fs from "fs";
 import * as path from "path";
 import * as path from "path";
 import * as vscode from "vscode";
 import * as vscode from "vscode";
 import { GDDocumentLinkProvider } from "./document_link_provider";
 import { GDDocumentLinkProvider } from "./document_link_provider";
-import GDScriptLanguageClient, { ClientStatus } from "./lsp/GDScriptLanguageClient";
+import { ClientConnectionManager } from "./lsp/ClientConnectionManager";
 import { ScenePreviewProvider } from "./scene_preview_provider";
 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";
 const TOOL_NAME = "GodotTools";
 
 
 export class GodotTools {
 export class GodotTools {
-	private reconnection_attempts = 0;
 	private context: vscode.ExtensionContext;
 	private context: vscode.ExtensionContext;
-	private client: GDScriptLanguageClient = null;
+
+	private lspClientManager: ClientConnectionManager = null;
 	private linkProvider: GDDocumentLinkProvider = null;
 	private linkProvider: GDDocumentLinkProvider = null;
 	private scenePreviewManager: ScenePreviewProvider = null;
 	private scenePreviewManager: ScenePreviewProvider = null;
 
 
-	private connection_status: vscode.StatusBarItem = null;
-
 	constructor(p_context: vscode.ExtensionContext) {
 	constructor(p_context: vscode.ExtensionContext) {
 		this.context = p_context;
 		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);
 		this.linkProvider = new GDDocumentLinkProvider(p_context);
-
-		setInterval(() => {
-			this.retry_callback();
-		}, get_configuration("lsp.autoReconnect.cooldown", 3000));
 	}
 	}
 
 
 	public activate() {
 	public activate() {
-		vscode.commands.registerCommand("godotTools.openEditor", () => {
+		register_command("openEditor", () => {
 			this.open_workspace_with_editor("-e").catch(err => vscode.window.showErrorMessage(err));
 			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));
 			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));
 			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.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() {
 	public deactivate() {
-		this.client.stop();
+		this.lspClientManager.client.stop();
 	}
 	}
 
 
 	private open_workspace_with_editor(params = "") {
 	private open_workspace_with_editor(params = "") {
 		return new Promise<void>(async (resolve, reject) => {
 		return new Promise<void>(async (resolve, reject) => {
 			let valid = false;
 			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) {
 			if (valid) {
 				this.run_editor(`--path "${project_dir}" ${params}`).then(() => resolve()).catch(err => {
 				this.run_editor(`--path "${project_dir}" ${params}`).then(() => resolve()).catch(err => {
 					reject(err);
 					reject(err);
@@ -93,11 +81,11 @@ export class GodotTools {
 			uri = vscode.window.activeTextEditor.document.uri;
 			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));
 		let relative_path = path.normalize(path.relative(project_dir, uri.fsPath));
 		relative_path = relative_path.split(path.sep).join(path.posix.sep);
 		relative_path = relative_path.split(path.sep).join(path.posix.sep);
 		relative_path = "res://" + relative_path;
 		relative_path = "res://" + relative_path;
@@ -105,15 +93,8 @@ export class GodotTools {
 		vscode.env.clipboard.writeText(relative_path);
 		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() {
 	private async switch_scene_script() {
@@ -145,7 +126,7 @@ export class GodotTools {
 	}
 	}
 
 
 	private run_editor(params = "") {
 	private run_editor(params = "") {
-
+		// TODO: rewrite this entire function
 		return new Promise<void>((resolve, reject) => {
 		return new Promise<void>((resolve, reject) => {
 			const run_godot = (path: string, params: string) => {
 			const run_godot = (path: string, params: string) => {
 				const is_powershell_path = (path?: string) => {
 				const is_powershell_path = (path?: string) => {
@@ -206,7 +187,8 @@ export class GodotTools {
 				resolve();
 				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()) {
 			if (!fs.existsSync(editorPath) || !fs.statSync(editorPath).isFile()) {
 				vscode.window.showOpenDialog({
 				vscode.window.showOpenDialog({
 					openLabel: "Run",
 					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);
-				});
-			}
-		});
-	}
 }
 }

+ 72 - 11
src/logger.ts

@@ -1,20 +1,21 @@
+
 export class Logger {
 export class Logger {
 	protected buffer: string = "";
 	protected buffer: string = "";
-	protected tag: string = '';
+	protected tag: string = "";
 	protected time: boolean = false;
 	protected time: boolean = false;
-	
+
 	constructor(tag: string, time: boolean) {
 	constructor(tag: string, time: boolean) {
 		this.tag = tag;
 		this.tag = tag;
 		this.time = time;
 		this.time = time;
 	}
 	}
-	
+
 	clear() {
 	clear() {
 		this.buffer = "";
 		this.buffer = "";
 	}
 	}
-	
+
 	log(...messages) {
 	log(...messages) {
-		
-		let line = '';
+
+		let line = "";
 		if (this.tag) {
 		if (this.tag) {
 			line += `[${this.tag}]`;
 			line += `[${this.tag}]`;
 		}
 		}
@@ -22,9 +23,9 @@ export class Logger {
 			line += `[${new Date().toISOString()}]`;
 			line += `[${new Date().toISOString()}]`;
 		}
 		}
 		if (line) {
 		if (line) {
-			line += ' ';
+			line += " ";
 		}
 		}
-		
+
 		for (let index = 0; index < messages.length; index++) {
 		for (let index = 0; index < messages.length; index++) {
 			line += messages[index];
 			line += messages[index];
 			if (index < messages.length) {
 			if (index < messages.length) {
@@ -33,15 +34,75 @@ export class Logger {
 				line += "\n";
 				line += "\n";
 			}
 			}
 		}
 		}
-		
+
 		this.buffer += line;
 		this.buffer += line;
 		console.log(line);
 		console.log(line);
 	}
 	}
-	
+
 	get_buffer(): string {
 	get_buffer(): string {
 		return this.buffer;
 		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;
 export default logger;

+ 331 - 0
src/lsp/ClientConnectionManager.ts

@@ -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();
+			}
+		});
+	}
+}

+ 84 - 35
src/lsp/GDScriptLanguageClient.ts

@@ -1,11 +1,13 @@
 import { EventEmitter } from "events";
 import { EventEmitter } from "events";
 import * as vscode from 'vscode';
 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 { Message, MessageIO, MessageIOReader, MessageIOWriter, TCPMessageIO, WebSocketMessageIO } from "./MessageIO";
 import NativeDocumentManager from './NativeDocumentManager';
 import NativeDocumentManager from './NativeDocumentManager';
 
 
+const log = createLogger("lsp.client");
+
 export enum ClientStatus {
 export enum ClientStatus {
 	PENDING,
 	PENDING,
 	DISCONNECTED,
 	DISCONNECTED,
@@ -15,19 +17,23 @@ const CUSTOM_MESSAGE = "gdscrip_client/";
 
 
 export default class GDScriptLanguageClient extends LanguageClient {
 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 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 _initialize_request: Message = null;
 	private message_handler: MessageHandler = null;
 	private message_handler: MessageHandler = null;
 	private native_doc_manager: NativeDocumentManager = 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) {
 		if (this._status != v) {
 			this._status = v;
 			this._status = v;
 			for (const callback of this._status_changed_callbacks) {
 			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) {
 		if (this._status_changed_callbacks.indexOf(callback) == -1) {
 			this._status_changed_callbacks.push(callback);
 			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) {
 	constructor(context: vscode.ExtensionContext) {
@@ -51,7 +58,7 @@ export default class GDScriptLanguageClient extends LanguageClient {
 			`GDScriptLanguageClient`,
 			`GDScriptLanguageClient`,
 			() => {
 			() => {
 				return new Promise((resolve, reject) => {
 				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() {
 	connect_to_server() {
 		this.status = ClientStatus.PENDING;
 		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);
 		this.io.connect_to_language_server(host, port);
 	}
 	}
 
 
-	start(): vscode.Disposable {
+	start() {
 		this._started = true;
 		this._started = true;
 		return super.start();
 		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;
 			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
 		// This is a dirty hack to fix the language server sending us
 		// invalid file URIs
 		// invalid file URIs
 		// This should be forward-compatible, meaning that it will work
 		// This should be forward-compatible, meaning that it will work
 		// with the current broken version, AND the fixed future version.
 		// 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) {
 		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:///');
 				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);
 		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() {
 	private on_connected() {
 		if (this._initialize_request) {
 		if (this._initialize_request) {
 			this.io.writer.write(this._initialize_request);
 			this.io.writer.write(this._initialize_request);
@@ -129,10 +182,7 @@ export default class GDScriptLanguageClient extends LanguageClient {
 	}
 	}
 }
 }
 
 
-
-
 class MessageHandler extends EventEmitter {
 class MessageHandler extends EventEmitter {
-
 	private io: MessageIO = null;
 	private io: MessageIO = null;
 
 
 	constructor(io: MessageIO) {
 	constructor(io: MessageIO) {
@@ -140,8 +190,8 @@ class MessageHandler extends EventEmitter {
 		this.io = io;
 		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") {
 			if (item == "Reload") {
 				let folderUrl = vscode.Uri.file(params.path);
 				let folderUrl = vscode.Uri.file(params.path);
 				vscode.commands.executeCommand('vscode.openFolder', folderUrl, false);
 				vscode.commands.executeCommand('vscode.openFolder', folderUrl, false);
@@ -150,7 +200,6 @@ class MessageHandler extends EventEmitter {
 	}
 	}
 
 
 	on_message(message: any) {
 	on_message(message: any) {
-
 		// FIXME: Hot fix VSCode 1.42 hover position
 		// FIXME: Hot fix VSCode 1.42 hover position
 		if (message && message.result && message.result.range && message.result.contents) {
 		if (message && message.result && message.result.range && message.result.contents) {
 			message.result.range = undefined;
 			message.result.range = undefined;

+ 2 - 2
src/lsp/MessageBuffer.ts

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

+ 3 - 3
src/lsp/MessageIO.ts

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

+ 8 - 12
src/lsp/NativeDocumentManager.ts

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

+ 17 - 13
src/scene_preview_provider.ts

@@ -10,12 +10,16 @@ import {
 import path = require("path");
 import path = require("path");
 import fs = require("fs");
 import fs = require("fs");
 import * as vscode from "vscode";
 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> {
 export class ScenePreviewProvider implements TreeDataProvider<SceneNode> {
 	private root: SceneNode | undefined;
 	private root: SceneNode | undefined;
@@ -84,15 +88,15 @@ export class ScenePreviewProvider implements TreeDataProvider<SceneNode> {
 
 
 		this.tree.onDidChangeSelection(this.tree_selection_changed);
 		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()
 			this.refresh()
 		);
 		);
 
 

+ 92 - 25
src/utils.ts

@@ -1,49 +1,106 @@
 import * as vscode from "vscode";
 import * as vscode from "vscode";
 import * as path from "path";
 import * as path from "path";
 import * as fs from "fs";
 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 default_value;
 	}
 	}
 	return config_value;
 	return config_value;
 }
 }
 
 
 export function set_configuration(name: string, value: any) {
 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 {
 export function is_debug_mode(): boolean {
 	return process.env.VSCODE_DEBUG_MODE === "true";
 	return process.env.VSCODE_DEBUG_MODE === "true";
 }
 }
 
 
+const CONTEXT_PREFIX = `${EXTENSION_PREFIX}.context.`;
+
 export function set_context(name: string, value: any) {
 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 register_command(command: string, callback: (...args: any[]) => any, thisArg?: any): vscode.Disposable {
+	return vscode.commands.registerCommand(`${EXTENSION_PREFIX}.${command}`, callback);
+}
+
+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 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 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 async function find_file(file: string): Promise<vscode.Uri|null> {
+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)) {
 	if (fs.existsSync(file)) {
 		return vscode.Uri.file(file);
 		return vscode.Uri.file(file);
 	} else {
 	} else {
@@ -56,7 +113,7 @@ export async function find_file(file: string): Promise<vscode.Uri|null> {
 	return 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");
 	const files = await vscode.workspace.findFiles("**/project.godot");
 	if (!files) {
 	if (!files) {
 		return null;
 		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", "");
 	const project_dir = files[0].fsPath.replace("project.godot", "");
 	return vscode.Uri.joinPath(vscode.Uri.file(project_dir), resPath.substring(6));
 	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 - 0
src/utils/subspawn.ts

@@ -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;
+}

+ 1 - 1
tsconfig.json

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

Some files were not shown because too many files changed in this diff