From 425c7e617000e349a4a65b8c9dea6eb140c668c5 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Wed, 24 Apr 2024 14:42:18 -0400 Subject: [PATCH] Add manual Ruby configuration --- vscode/package.json | 4 + vscode/src/ruby.ts | 216 ++++++++++++++++++------- vscode/src/ruby/none.ts | 23 ++- vscode/src/rubyLsp.ts | 51 ++++-- vscode/src/status.ts | 2 +- vscode/src/test/suite/debugger.test.ts | 8 +- vscode/src/test/suite/ruby.test.ts | 12 +- vscode/src/test/suite/status.test.ts | 2 +- 8 files changed, 234 insertions(+), 84 deletions(-) diff --git a/vscode/package.json b/vscode/package.json index ed2583ce3e..5b840477d5 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -318,6 +318,10 @@ "description": "Ignores if the project uses a typechecker. Only intended to be used while working on the Ruby LSP itself", "type": "boolean", "default": false + }, + "rubyLsp.rubyExecutablePath": { + "description": "Path to the Ruby installation. This is used as a fallback if version manager activation fails", + "type": "string" } } }, diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index aaed867bf4..8ba6d502fe 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -101,80 +101,103 @@ export class Ruby implements RubyInterface { .get("rubyVersionManager")!, ) { this.versionManager = versionManager; + this._error = false; - // If the version manager is auto, discover the actual manager before trying to activate anything - if (this.versionManager.identifier === ManagerIdentifier.Auto) { - await this.discoverVersionManager(); - this.outputChannel.info( - `Discovered version manager ${this.versionManager.identifier}`, + const workspaceRubyPath = this.context.workspaceState.get< + string | undefined + >(`rubyLsp.workspaceRubyPath.${this.workspaceFolder.name}`); + + if (workspaceRubyPath) { + // If a workspace specific Ruby path is configured, then we use that to activate the environment + await this.runActivation( + new None(this.workspaceFolder, this.outputChannel, workspaceRubyPath), ); - } + } else { + // If the version manager is auto, discover the actual manager before trying to activate anything + if (this.versionManager.identifier === ManagerIdentifier.Auto) { + await this.discoverVersionManager(); + this.outputChannel.info( + `Discovered version manager ${this.versionManager.identifier}`, + ); + } - try { - switch (this.versionManager.identifier) { - case ManagerIdentifier.Asdf: - await this.runActivation( - new Asdf(this.workspaceFolder, this.outputChannel), - ); - break; - case ManagerIdentifier.Chruby: - await this.runActivation( - new Chruby(this.workspaceFolder, this.outputChannel), - ); - break; - case ManagerIdentifier.Rbenv: - await this.runActivation( - new Rbenv(this.workspaceFolder, this.outputChannel), - ); - break; - case ManagerIdentifier.Rvm: - await this.runActivation( - new Rvm(this.workspaceFolder, this.outputChannel), - ); - break; - case ManagerIdentifier.Mise: - await this.runActivation( - new Mise(this.workspaceFolder, this.outputChannel), - ); - break; - case ManagerIdentifier.RubyInstaller: - await this.runActivation( - new RubyInstaller(this.workspaceFolder, this.outputChannel), - ); - break; - case ManagerIdentifier.Custom: - await this.runActivation( - new Custom(this.workspaceFolder, this.outputChannel), - ); - break; - case ManagerIdentifier.None: - await this.runActivation( - new None(this.workspaceFolder, this.outputChannel), - ); - break; - default: + try { + await this.runManagerActivation(); + } catch (error: any) { + // If an error occurred and a global Ruby path is configured, then we can try to fallback to that + const globalRubyPath = vscode.workspace + .getConfiguration("rubyLsp") + .get("rubyExecutablePath"); + + if (globalRubyPath) { await this.runActivation( - new Shadowenv(this.workspaceFolder, this.outputChannel), + new None(this.workspaceFolder, this.outputChannel, globalRubyPath), ); - break; + } else { + this._error = true; + + // When running tests, we need to throw the error or else activation may silently fail and it's very difficult + // to debug + if (this.context.extensionMode === vscode.ExtensionMode.Test) { + throw error; + } + + await this.handleRubyError(error.message); + } } + } + if (!this.error) { this.fetchRubyVersionInfo(); await this.setupBundlePath(); - this._error = false; - } catch (error: any) { - this._error = true; + } + } - // When running tests, we need to throw the error or else activation may silently fail and it's very difficult to - // debug - if (this.context.extensionMode === vscode.ExtensionMode.Test) { - throw error; - } + async manuallySelectRuby() { + const manualSelection = await vscode.window.showInformationMessage( + "Configure global fallback or workspace specific Ruby?", + "global", + "workspace", + "clear previous workspace selection", + ); - await vscode.window.showErrorMessage( - `Failed to activate ${this.versionManager.identifier} environment: ${error.message}`, + if (!manualSelection) { + return; + } + + if (manualSelection === "clear previous workspace selection") { + await this.context.workspaceState.update( + `rubyLsp.workspaceRubyPath.${this.workspaceFolder.name}`, + undefined, ); + return this.activateRuby(); + } + + const selection = await vscode.window.showOpenDialog({ + title: `Select Ruby binary path for ${manualSelection} configuration`, + openLabel: "Select Ruby binary", + canSelectMany: false, + }); + + if (!selection) { + return; } + + const selectedPath = selection[0].fsPath; + + if (manualSelection === "global") { + await vscode.workspace + .getConfiguration("rubyLsp") + .update("rubyExecutablePath", selectedPath, true); + } else { + // We must update the cached Ruby path for this workspace if the user decided to change it + await this.context.workspaceState.update( + `rubyLsp.workspaceRubyPath.${this.workspaceFolder.name}`, + selectedPath, + ); + } + + return this.activateRuby(); } private async runActivation(manager: VersionManager) { @@ -230,6 +253,56 @@ export class Ruby implements RubyInterface { delete env.DEBUG; } + private async runManagerActivation() { + switch (this.versionManager.identifier) { + case ManagerIdentifier.Asdf: + await this.runActivation( + new Asdf(this.workspaceFolder, this.outputChannel), + ); + break; + case ManagerIdentifier.Chruby: + await this.runActivation( + new Chruby(this.workspaceFolder, this.outputChannel), + ); + break; + case ManagerIdentifier.Rbenv: + await this.runActivation( + new Rbenv(this.workspaceFolder, this.outputChannel), + ); + break; + case ManagerIdentifier.Rvm: + await this.runActivation( + new Rvm(this.workspaceFolder, this.outputChannel), + ); + break; + case ManagerIdentifier.Mise: + await this.runActivation( + new Mise(this.workspaceFolder, this.outputChannel), + ); + break; + case ManagerIdentifier.RubyInstaller: + await this.runActivation( + new RubyInstaller(this.workspaceFolder, this.outputChannel), + ); + break; + case ManagerIdentifier.Custom: + await this.runActivation( + new Custom(this.workspaceFolder, this.outputChannel), + ); + break; + case ManagerIdentifier.None: + await this.runActivation( + new None(this.workspaceFolder, this.outputChannel), + ); + break; + default: + await this.runActivation( + new Shadowenv(this.workspaceFolder, this.outputChannel), + ); + break; + } + } + private async setupBundlePath() { // Some users like to define a completely separate Gemfile for development tools. We allow them to use // `rubyLsp.bundleGemfile` to configure that and need to inject it into the environment @@ -322,4 +395,25 @@ export class Ruby implements RubyInterface { return false; } } + + private async handleRubyError(message: string) { + const answer = await vscode.window.showErrorMessage( + `Automatic Ruby environment activation with ${this.versionManager.identifier} failed: ${message}`, + "Retry", + "Select Ruby manually", + ); + + // If the user doesn't answer anything, we can just return. The error property was already set to true and we won't + // try to launch the LSP + if (!answer) { + return; + } + + // For retrying, reload the entire window to get rid of any state + if (answer === "Retry") { + await vscode.commands.executeCommand("workbench.action.reloadWindow"); + } + + return this.manuallySelectRuby(); + } } diff --git a/vscode/src/ruby/none.ts b/vscode/src/ruby/none.ts index c356d8d5a9..4e3f111699 100644 --- a/vscode/src/ruby/none.ts +++ b/vscode/src/ruby/none.ts @@ -1,5 +1,8 @@ /* eslint-disable no-process-env */ +import * as vscode from "vscode"; + import { asyncExec } from "../common"; +import { WorkspaceChannel } from "../workspaceChannel"; import { VersionManager, ActivationResult } from "./versionManager"; @@ -12,13 +15,27 @@ import { VersionManager, ActivationResult } from "./versionManager"; // If you don't have Ruby automatically available in your PATH and are not using a version manager, look into // configuring custom Ruby activation export class None extends VersionManager { + private readonly rubyPath: string; + + constructor( + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + rubyPath?: string, + ) { + super(workspaceFolder, outputChannel); + this.rubyPath = rubyPath ?? "ruby"; + } + async activate(): Promise { const activationScript = "STDERR.print({ env: ENV.to_h, yjit: !!defined?(RubyVM::YJIT), version: RUBY_VERSION }.to_json)"; - const result = await asyncExec(`ruby -W0 -rjson -e '${activationScript}'`, { - cwd: this.bundleUri.fsPath, - }); + const result = await asyncExec( + `${this.rubyPath} -W0 -rjson -e '${activationScript}'`, + { + cwd: this.bundleUri.fsPath, + }, + ); const parsedResult = JSON.parse(result.stderr); return { diff --git a/vscode/src/rubyLsp.ts b/vscode/src/rubyLsp.ts index cb458b8074..d64fa5f5e8 100644 --- a/vscode/src/rubyLsp.ts +++ b/vscode/src/rubyLsp.ts @@ -285,22 +285,43 @@ export class RubyLsp { vscode.commands.registerCommand( Command.SelectVersionManager, async () => { - const configuration = vscode.workspace.getConfiguration("rubyLsp"); - const managerConfig = - configuration.get("rubyVersionManager")!; - const options = Object.values(ManagerIdentifier); - const manager = (await vscode.window.showQuickPick(options, { - placeHolder: `Current: ${managerConfig.identifier}`, - })) as ManagerIdentifier | undefined; - - if (manager !== undefined) { - managerConfig.identifier = manager; - await configuration.update( - "rubyVersionManager", - managerConfig, - true, - ); + const answer = await vscode.window.showQuickPick( + ["Change version manager", "Change manual Ruby configuration"], + { placeHolder: "What would you like to do?" }, + ); + + if (!answer) { + return; + } + + if (answer === "Change version manager") { + const configuration = vscode.workspace.getConfiguration("rubyLsp"); + const managerConfig = + configuration.get("rubyVersionManager")!; + const options = Object.values(ManagerIdentifier); + const manager = (await vscode.window.showQuickPick(options, { + placeHolder: `Current: ${managerConfig.identifier}`, + })) as ManagerIdentifier | undefined; + + if (manager !== undefined) { + managerConfig.identifier = manager; + await configuration.update( + "rubyVersionManager", + managerConfig, + true, + ); + } + + return; } + + const workspace = await this.showWorkspacePick(); + + if (!workspace) { + return; + } + + await workspace.ruby.manuallySelectRuby(); }, ), vscode.commands.registerCommand( diff --git a/vscode/src/status.ts b/vscode/src/status.ts index 701c883336..773399a3bd 100644 --- a/vscode/src/status.ts +++ b/vscode/src/status.ts @@ -36,7 +36,7 @@ export class RubyVersionStatus extends StatusItem { this.item.name = "Ruby LSP Status"; this.item.command = { - title: "Change version manager", + title: "Configure", command: Command.SelectVersionManager, }; diff --git a/vscode/src/test/suite/debugger.test.ts b/vscode/src/test/suite/debugger.test.ts index 3fbd4ea51b..efa1338e59 100644 --- a/vscode/src/test/suite/debugger.test.ts +++ b/vscode/src/test/suite/debugger.test.ts @@ -190,7 +190,13 @@ suite("Debugger", () => { 'source "https://rubygems.org"\ngem "debug"', ); - const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + const context = { + subscriptions: [], + workspaceState: { + get: () => undefined, + update: () => undefined, + }, + } as unknown as vscode.ExtensionContext; const outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL); const workspaceFolder: vscode.WorkspaceFolder = { uri: vscode.Uri.file(tmpPath), diff --git a/vscode/src/test/suite/ruby.test.ts b/vscode/src/test/suite/ruby.test.ts index 8c9ab62d2c..7da73e64cd 100644 --- a/vscode/src/test/suite/ruby.test.ts +++ b/vscode/src/test/suite/ruby.test.ts @@ -40,7 +40,11 @@ suite("Ruby environment activation", () => { const context = { extensionMode: vscode.ExtensionMode.Test, - } as vscode.ExtensionContext; + workspaceState: { + get: () => undefined, + update: () => undefined, + }, + } as unknown as vscode.ExtensionContext; const outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL); const ruby = new Ruby(context, workspaceFolder, outputChannel); @@ -77,7 +81,11 @@ suite("Ruby environment activation", () => { const context = { extensionMode: vscode.ExtensionMode.Test, - } as vscode.ExtensionContext; + workspaceState: { + get: () => undefined, + update: () => undefined, + }, + } as unknown as vscode.ExtensionContext; const outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL); const ruby = new Ruby(context, workspaceFolder, outputChannel); diff --git a/vscode/src/test/suite/status.test.ts b/vscode/src/test/suite/status.test.ts index 1ba43bda86..a15270a391 100644 --- a/vscode/src/test/suite/status.test.ts +++ b/vscode/src/test/suite/status.test.ts @@ -49,7 +49,7 @@ suite("StatusItems", () => { test("Status is initialized with the right values", () => { assert.strictEqual(status.item.text, "Using Ruby 3.2.0 with shadowenv"); assert.strictEqual(status.item.name, "Ruby LSP Status"); - assert.strictEqual(status.item.command?.title, "Change version manager"); + assert.strictEqual(status.item.command?.title, "Configure"); assert.strictEqual( status.item.command.command, Command.SelectVersionManager,