diff --git a/editors/code/package-lock.json b/editors/code/package-lock.json index 9e8cd07a..af4a835c 100644 --- a/editors/code/package-lock.json +++ b/editors/code/package-lock.json @@ -11,9 +11,10 @@ "dependencies": { "@types/p-queue": "^3.1.0", "adm-zip": "^0.5.16", - "fs-extra": "^11.2.0", + "fs-extra": "^11.1.1", "p-queue": "npm:@esm2cjs/p-queue@^7.3.0", - "vscode-languageclient": "^9.0.1" + "vscode-languageclient": "^9.0.1", + "which": "^4.0.0" }, "devDependencies": { "@types/adm-zip": "^0.5.6", @@ -21,6 +22,7 @@ "@types/mocha": "^10.0.9", "@types/node": "20.x", "@types/vscode": "^1.90.0", + "@types/which": "^3.0.4", "@typescript-eslint/eslint-plugin": "^8.10.0", "@typescript-eslint/parser": "^8.7.0", "@vscode/test-cli": "^0.0.10", @@ -536,6 +538,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/which": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/which/-/which-3.0.4.tgz", + "integrity": "sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.16.0.tgz", @@ -1630,6 +1639,29 @@ "node": ">= 8" } }, + "node_modules/cross-spawn/node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/cross-spawn/node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", @@ -2139,9 +2171,9 @@ } }, "node_modules/fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -2572,11 +2604,13 @@ "license": "MIT" }, "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "license": "ISC", + "engines": { + "node": ">=16" + } }, "node_modules/isobject": { "version": "3.0.1", @@ -4572,19 +4606,18 @@ } }, "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", + "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", "license": "ISC", "dependencies": { - "isexe": "^2.0.0" + "isexe": "^3.1.1" }, "bin": { - "node-which": "bin/node-which" + "node-which": "bin/which.js" }, "engines": { - "node": ">= 8" + "node": "^16.13.0 || >=18.0.0" } }, "node_modules/wildcard": { diff --git a/editors/code/package.json b/editors/code/package.json index 877d15a1..37e0624b 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -41,11 +41,11 @@ "default": null, "markdownDescription": "Controls the log level of the language server.", "enum": [ - "error", - "warning", - "info", - "debug", - "trace" + "error", + "warning", + "info", + "debug", + "trace" ], "scope": "application", "type": "string" @@ -55,6 +55,20 @@ "markdownDescription": "Controls the log level of the Rust crates that the language server depends on.", "scope": "application", "type": "string" + }, + "air.executableLocation": { + "default": "environment", + "markdownDescription": "Location of the `air` executable to start the language server with.", + "enum": [ + "environment", + "bundled" + ], + "enumDescriptions": [ + "Look for an `air` executable on the `PATH`, falling back to the bundled version.", + "Always use the bundled `air` executable." + ], + "scope": "window", + "type": "string" } } }, @@ -109,15 +123,19 @@ }, "dependencies": { "@types/p-queue": "^3.1.0", + "fs-extra": "^11.1.1", "p-queue": "npm:@esm2cjs/p-queue@^7.3.0", "adm-zip": "^0.5.16", - "vscode-languageclient": "^9.0.1" + "vscode-languageclient": "^9.0.1", + "which": "^4.0.0" }, "devDependencies": { "@types/adm-zip": "^0.5.6", + "@types/fs-extra": "^11.0.4", "@types/mocha": "^10.0.9", "@types/node": "20.x", "@types/vscode": "^1.90.0", + "@types/which": "^3.0.4", "@typescript-eslint/eslint-plugin": "^8.10.0", "@typescript-eslint/parser": "^8.7.0", "@vscode/test-cli": "^0.0.10", diff --git a/editors/code/src/binary.ts b/editors/code/src/binary.ts new file mode 100644 index 00000000..53306640 --- /dev/null +++ b/editors/code/src/binary.ts @@ -0,0 +1,36 @@ +import * as vscode from "vscode"; +import which from "which"; +import { AIR_BINARY_NAME, BUNDLED_AIR_EXECUTABLE } from "./constants"; +import { outputLog } from "./logging"; + +export type ExecutableLocation = "environment" | "bundled"; + +export async function resolveAirBinaryPath( + executableLocation: ExecutableLocation +): Promise { + if (!vscode.workspace.isTrusted) { + outputLog( + `Workspace is not trusted, using bundled executable: ${BUNDLED_AIR_EXECUTABLE}` + ); + return BUNDLED_AIR_EXECUTABLE; + } + + // User requested the bundled air binary + if (executableLocation === "bundled") { + outputLog( + `Using bundled executable as requested by \`air.executableLocation\`: ${BUNDLED_AIR_EXECUTABLE}` + ); + return BUNDLED_AIR_EXECUTABLE; + } + + // First choice: the executable in the global environment. + const environmentPath = await which(AIR_BINARY_NAME, { nothrow: true }); + if (environmentPath) { + outputLog(`Using environment executable: ${environmentPath}`); + return environmentPath; + } + + // Second choice: bundled executable. + outputLog(`Using bundled executable: ${BUNDLED_AIR_EXECUTABLE}`); + return BUNDLED_AIR_EXECUTABLE; +} diff --git a/editors/code/src/constants.ts b/editors/code/src/constants.ts new file mode 100644 index 00000000..2e1ffa9e --- /dev/null +++ b/editors/code/src/constants.ts @@ -0,0 +1,27 @@ +import * as path from "path"; + +const folderName = path.basename(__dirname); + +/** + * Path to the root directory of this extension. + */ +export const EXTENSION_ROOT_DIR = + folderName === "common" + ? path.dirname(path.dirname(__dirname)) + : path.dirname(__dirname); + +/** + * Name of the `air` binary based on the current platform. + */ +export const AIR_BINARY_NAME = process.platform === "win32" ? "air.exe" : "air"; + +/** + * Path to the `air` executable that is bundled with the extension. + * The GitHub Action is in charge of placing the executable here. + */ +export const BUNDLED_AIR_EXECUTABLE = path.join( + EXTENSION_ROOT_DIR, + "bundled", + "bin", + AIR_BINARY_NAME +); diff --git a/editors/code/src/logging.ts b/editors/code/src/logging.ts new file mode 100644 index 00000000..ba469dee --- /dev/null +++ b/editors/code/src/logging.ts @@ -0,0 +1,28 @@ +import * as util from "util"; +import { Disposable, OutputChannel } from "vscode"; + +type Arguments = unknown[]; +class OutputChannelLogger { + constructor(private readonly channel: OutputChannel) {} + + public outputLog(...data: Arguments): void { + this.channel.appendLine(util.format(...data)); + } +} + +let channel: OutputChannelLogger | undefined; +export function registerLogger(logChannel: OutputChannel): Disposable { + channel = new OutputChannelLogger(logChannel); + return { + dispose: () => { + channel = undefined; + }, + }; +} + +export function outputLog(...args: Arguments): void { + if (process.env.CI === "true") { + console.log(...args); + } + channel?.outputLog(...args); +} diff --git a/editors/code/src/lsp.ts b/editors/code/src/lsp.ts index de2c79c5..28ec61d8 100644 --- a/editors/code/src/lsp.ts +++ b/editors/code/src/lsp.ts @@ -1,8 +1,11 @@ import * as vscode from "vscode"; import * as lc from "vscode-languageclient/node"; import { default as PQueue } from "p-queue"; -import { getInitializationOptions } from "./settings"; +import { getInitializationOptions, getWorkspaceSettings } from "./settings"; import { Middleware, ResponseError } from "vscode-languageclient/node"; +import { registerLogger } from "./logging"; +import { resolveAirBinaryPath } from "./binary"; +import { getRootWorkspaceFolder } from "./workspace"; // All session management operations are put on a queue. They can't run // concurrently and either result in a started or stopped state. Starting when @@ -25,7 +28,7 @@ export class Lsp { constructor(context: vscode.ExtensionContext) { this.channel = vscode.window.createOutputChannel("Air Language Server"); - context.subscriptions.push(this.channel); + context.subscriptions.push(this.channel, registerLogger(this.channel)); this.stateQueue = new PQueue({ concurrency: 1 }); } @@ -54,10 +57,17 @@ export class Lsp { return; } + const workspaceFolder = await getRootWorkspaceFolder(); + + const workspaceSettings = getWorkspaceSettings("air", workspaceFolder); const initializationOptions = getInitializationOptions("air"); + const command = await resolveAirBinaryPath( + workspaceSettings.executableLocation + ); + let serverOptions: lc.ServerOptions = { - command: "air", + command: command, args: ["language-server"], }; @@ -89,7 +99,7 @@ export class Lsp { const config = vscode.workspace.getConfiguration( undefined, - { uri, languageId }, + { uri, languageId } ); items[i] = config.get(item.section); } @@ -116,7 +126,7 @@ export class Lsp { "airLanguageServer", "Air Language Server", serverOptions, - clientOptions, + clientOptions ); await client.start(); diff --git a/editors/code/src/settings.ts b/editors/code/src/settings.ts index e6c95c04..f34ecae8 100644 --- a/editors/code/src/settings.ts +++ b/editors/code/src/settings.ts @@ -1,4 +1,10 @@ -import { ConfigurationScope, workspace, WorkspaceConfiguration } from "vscode"; +import { + ConfigurationScope, + workspace, + WorkspaceConfiguration, + WorkspaceFolder, +} from "vscode"; +import { ExecutableLocation } from "./binary"; type LogLevel = "error" | "warn" | "info" | "debug" | "trace"; @@ -11,6 +17,10 @@ export type InitializationOptions = { dependencyLogLevels?: string; }; +export type WorkspaceSettings = { + executableLocation: ExecutableLocation; +}; + export function getInitializationOptions( namespace: string ): InitializationOptions { @@ -25,6 +35,19 @@ export function getInitializationOptions( }; } +export function getWorkspaceSettings( + namespace: string, + workspace: WorkspaceFolder +): WorkspaceSettings { + const config = getConfiguration(namespace, workspace); + + return { + executableLocation: + config.get("executableLocation") ?? + "environment", + }; +} + function getOptionalUserValue( config: WorkspaceConfiguration, key: string diff --git a/editors/code/src/workspace.ts b/editors/code/src/workspace.ts new file mode 100644 index 00000000..96b60c44 --- /dev/null +++ b/editors/code/src/workspace.ts @@ -0,0 +1,51 @@ +import path from "path"; +import * as vscode from "vscode"; +import * as fs from "fs-extra"; + +export async function getRootWorkspaceFolder(): Promise { + const workspaces: readonly vscode.WorkspaceFolder[] = getWorkspaceFolders(); + + if (workspaces.length === 0) { + // No workspaces open, use current working directory + return { + uri: vscode.Uri.file(process.cwd()), + name: path.basename(process.cwd()), + index: 0, + }; + } else if (workspaces.length === 1) { + // One workspace open, return it + return workspaces[0]; + } else { + // Multiple workspaces open, use the one with the shortest path, + // i.e. the most "root" one + let rootWorkspace = workspaces[0]; + let root = undefined; + + // Find first existing workspace path + for (const w of workspaces) { + if (await fs.pathExists(w.uri.fsPath)) { + root = w.uri.fsPath; + rootWorkspace = w; + break; + } + } + + // Update root workspace if we find a shorter path + for (const w of workspaces) { + if ( + root && + root.length > w.uri.fsPath.length && + (await fs.pathExists(w.uri.fsPath)) + ) { + root = w.uri.fsPath; + rootWorkspace = w; + } + } + + return rootWorkspace; + } +} + +function getWorkspaceFolders(): readonly vscode.WorkspaceFolder[] { + return vscode.workspace.workspaceFolders ?? []; +}