From 456f2870288d025cb7c24b632e674ed9f8e11482 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Fri, 1 Dec 2023 17:07:17 -0500 Subject: [PATCH] Implement internal Ruby activation mechanism --- .github/workflows/ci.yml | 24 +- package.json | 35 +- src/common.ts | 7 +- src/ruby.ts | 662 ++++++++++++++++++++----------- src/rubyLsp.ts | 30 +- src/status.ts | 31 +- src/telemetry.ts | 1 - src/test/suite/client.test.ts | 55 +-- src/test/suite/ruby.test.ts | 182 ++++++++- src/test/suite/status.test.ts | 56 +-- src/test/suite/telemetry.test.ts | 2 +- src/workspace.ts | 22 +- 12 files changed, 674 insertions(+), 433 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d4b5973..733bbfcf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,11 +36,31 @@ jobs: node-version: "18" cache: "yarn" - # We need some Ruby installed for the environment activation tests + # We need some Ruby installed for the environment activation tests. The Ruby version installed here needs to match + # the one we're using the ruby.test.ts file - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: "3.2" + ruby-version: "3.2.2" + + # On GitHub actions, the Ruby binary is installed in a path that's not really standard for version managers. We + # create a symlink using a standard path so that we test the same behaviour as in development machines + - name: Symlink Ruby on Ubuntu + if: matrix.os == 'ubuntu-latest' + run: | + mkdir /opt/rubies + ln -s /opt/hostedtoolcache/Ruby/3.2.2/x64 /opt/rubies/3.2.2 + + - name: Symlink Ruby on MacOS + if: matrix.os == 'macos-latest' + run: | + mkdir /Users/runner/.rubies + ln -s /Users/runner/hostedtoolcache/Ruby/3.2.2/x64 /Users/runner/.rubies/3.2.2 + + - name: Symlink Ruby on Windows + if: matrix.os == 'windows-latest' + run: | + New-Item -Path C:\Ruby32-x64 -ItemType SymbolicLink -Value C:\hostedtoolcache\windows\Ruby\3.2.2\x64 - name: 📦 Install dependencies run: yarn --frozen-lockfile diff --git a/package.json b/package.json index d9be8ab5..1dc219fb 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "ruby-lsp", "displayName": "Ruby LSP", "description": "VS Code plugin for connecting with the Ruby LSP", - "version": "0.5.8", + "version": "0.6.6", "publisher": "Shopify", "repository": { "type": "git", @@ -56,8 +56,8 @@ "category": "Ruby LSP" }, { - "command": "rubyLsp.selectRubyVersionManager", - "title": "Select Ruby version manager", + "command": "rubyLsp.changeRubyVersion", + "title": "Change Ruby version", "category": "Ruby LSP" }, { @@ -243,30 +243,6 @@ } } }, - "rubyLsp.rubyVersionManager": { - "description": "The Ruby version manager to use", - "type": "string", - "enum": [ - "asdf", - "auto", - "chruby", - "none", - "rbenv", - "rvm", - "shadowenv", - "custom" - ], - "default": "auto" - }, - "rubyLsp.customRubyCommand": { - "description": "A shell command to activate the right Ruby version or add a custom Ruby bin folder to the PATH. Only used if rubyVersionManager is set to 'custom'", - "type": "string" - }, - "rubyLsp.yjit": { - "description": "Use YJIT to speed up the Ruby LSP server", - "type": "boolean", - "default": true - }, "rubyLsp.formatter": { "description": "Which tool the Ruby LSP should use for formatting files", "type": "string", @@ -288,6 +264,11 @@ "type": "integer", "default": 30 }, + "rubyLsp.rubyExecutablePath": { + "description": "Specify the path for a Ruby executable to use for the Ruby LSP server on all projects", + "type": "string", + "default": "" + }, "rubyLsp.branch": { "description": "Run the Ruby LSP server from the specified branch rather than using the released gem. Only supported if not using bundleGemfile", "type": "string", diff --git a/src/common.ts b/src/common.ts index eb968cb8..f3657070 100644 --- a/src/common.ts +++ b/src/common.ts @@ -12,8 +12,7 @@ export enum Command { Update = "rubyLsp.update", ToggleExperimentalFeatures = "rubyLsp.toggleExperimentalFeatures", ServerOptions = "rubyLsp.serverOptions", - ToggleYjit = "rubyLsp.toggleYjit", - SelectVersionManager = "rubyLsp.selectRubyVersionManager", + ChangeRubyVersion = "rubyLsp.changeRubyVersion", ToggleFeatures = "rubyLsp.toggleFeatures", FormatterHelp = "rubyLsp.formatterHelp", RunTest = "rubyLsp.runTest", @@ -24,10 +23,8 @@ export enum Command { } export interface RubyInterface { - error: boolean; - versionManager?: string; rubyVersion?: string; - supportsYjit?: boolean; + yjitEnabled?: boolean; } export interface ClientInterface { diff --git a/src/ruby.ts b/src/ruby.ts index 35e3289a..afb9920d 100644 --- a/src/ruby.ts +++ b/src/ruby.ts @@ -1,46 +1,52 @@ +/* eslint-disable no-process-env */ import path from "path"; import fs from "fs/promises"; +import os from "os"; import * as vscode from "vscode"; -import { asyncExec, pathExists, RubyInterface } from "./common"; +import { RubyInterface, asyncExec, pathExists } from "./common"; import { WorkspaceChannel } from "./workspaceChannel"; -export enum VersionManager { - Asdf = "asdf", - Auto = "auto", - Chruby = "chruby", - Rbenv = "rbenv", - Rvm = "rvm", - Shadowenv = "shadowenv", - None = "none", - Custom = "custom", +interface ActivationEnvironment { + defaultGems: string; + gems: string; + version: string; + yjit: string; } +// Where to search for Ruby installations. We need to cover all common cases for Ruby version managers, but we allow +// users to manually point to a Ruby installation if not covered here. +const RUBY_LOOKUP_PATHS = + os.platform() === "win32" + ? ["C:"] + : [ + path.join("/", "opt", "rubies"), + path.join(os.homedir(), ".rubies"), + path.join(os.homedir(), ".rbenv", "versions"), + path.join(os.homedir(), ".local", "share", "rtx", "installs", "ruby"), + path.join(os.homedir(), ".asdf", "installs", "ruby"), + path.join(os.homedir(), ".rvm", "rubies"), + ]; + export class Ruby implements RubyInterface { - public rubyVersion?: string; - public yjitEnabled?: boolean; - public supportsYjit?: boolean; - private readonly workingFolderPath: string; - #versionManager?: VersionManager; - // eslint-disable-next-line no-process-env - private readonly shell = process.env.SHELL?.replace(/(\s+)/g, "\\$1"); - private _env: NodeJS.ProcessEnv = {}; - private _error = false; - private readonly context: vscode.ExtensionContext; private readonly customBundleGemfile?: string; private readonly cwd: string; + private readonly context: vscode.ExtensionContext; + private readonly workspaceName: string; private readonly outputChannel: WorkspaceChannel; + #env: NodeJS.ProcessEnv = process.env; + #rubyVersion?: string; + #yjitEnabled?: boolean; + constructor( - context: vscode.ExtensionContext, workingFolder: vscode.WorkspaceFolder, + context: vscode.ExtensionContext, outputChannel: WorkspaceChannel, ) { - this.context = context; - this.workingFolderPath = workingFolder.uri.fsPath; - this.outputChannel = outputChannel; - + // We allow users to define a custom Gemfile to run the LSP with. This is useful for projects using EOL rubies or + // users that like to share their development dependencies across multiple projects in a separate Gemfile const customBundleGemfile: string = vscode.workspace .getConfiguration("rubyLsp") .get("bundleGemfile")!; @@ -48,200 +54,331 @@ export class Ruby implements RubyInterface { if (customBundleGemfile.length > 0) { this.customBundleGemfile = path.isAbsolute(customBundleGemfile) ? customBundleGemfile - : path.resolve(path.join(this.workingFolderPath, customBundleGemfile)); + : path.resolve( + path.join(workingFolder.uri.fsPath, customBundleGemfile), + ); + + this.cwd = path.dirname(this.customBundleGemfile); + } else { + this.cwd = workingFolder.uri.fsPath; } - this.cwd = this.customBundleGemfile - ? path.dirname(this.customBundleGemfile) - : this.workingFolderPath; + this.outputChannel = outputChannel; + this.context = context; + this.workspaceName = workingFolder.name; } - get versionManager() { - return this.#versionManager; + get env() { + return this.#env; } - private set versionManager(versionManager: VersionManager | undefined) { - this.#versionManager = versionManager; + get rubyVersion() { + return this.#rubyVersion; } - get env() { - return this._env; + get yjitEnabled() { + return this.#yjitEnabled; + } + + async activate(rubyPath?: string) { + let matchedRubyPath = rubyPath; + if (!matchedRubyPath) { + matchedRubyPath = await this.findRubyPath(); + } + + const { defaultGems, gems, version, yjit } = await this.runActivationScript( + matchedRubyPath!, + ); + + let userGemsPath = gems; + const gemsetPath = path.join(this.cwd, ".ruby-gemset"); + + if (await pathExists(gemsetPath)) { + const gemset = (await fs.readFile(gemsetPath, "utf8")).trim(); + + if (gemset) { + userGemsPath = `${gems}@${gemset}`; + } + } + + const [major, minor, _patch] = version.split(".").map(Number); + + if (major < 3) { + throw new Error( + `The Ruby LSP requires Ruby 3.0 or newer to run. This project is using ${version}. \ + [See alternatives](https://github.com/Shopify/vscode-ruby-lsp?tab=readme-ov-file#ruby-version-requirement)`, + ); + } + + this.outputChannel.info( + `Activated Ruby environment: gem_home=${userGemsPath}, version=${version}, yjit=${yjit}, gem_root=${defaultGems}`, + ); + + const pathSeparator = os.platform() === "win32" ? ";" : ":"; + const rubyEnv = { + GEM_HOME: userGemsPath, + GEM_PATH: `${userGemsPath}${pathSeparator}${defaultGems}`, + PATH: `${path.join(userGemsPath, "bin")}${pathSeparator}${path.join( + defaultGems, + "bin", + )}${pathSeparator}${matchedRubyPath}${pathSeparator}${process.env.PATH}`, + }; + + this.#env = { + ...this.#env, + ...rubyEnv, + }; + this.#rubyVersion = version; + + // YJIT is enabled if Ruby was compiled with support for it and the Ruby version is equal or greater to 3.2 + this.#yjitEnabled = yjit === "constant" && major >= 3 && minor >= 2; + + // If the version is exactly 3.2, we enable YJIT through RUBYOPT. Starting with Ruby 3.3 the server enables YJIT + if (this.yjitEnabled && major === 3 && minor === 2) { + // RUBYOPT may be empty or it may contain bundler paths. In the second case, we must concat to avoid accidentally + // removing the paths from the env variable + if (this.#env.RUBYOPT) { + this.#env.RUBYOPT.concat(" --yjit"); + } else { + this.#env.RUBYOPT = "--yjit"; + } + } + + this.deleteGcEnvironmentVariables(); + await this.setupBundlePath(); + + // We need to set the entire NodeJS environment to match what we activated. This is only necessary to make the + // Sorbet extension work + process.env = this.#env; + return rubyEnv; } - get error() { - return this._error; + // Manually select a Ruby version. Used for the language status item + async changeVersion() { + const rubyPath = await this.selectRubyInstallation(); + + if (!rubyPath) { + return; + } + + await this.activate(rubyPath); } - async activateRuby( - versionManager: VersionManager = vscode.workspace - .getConfiguration("rubyLsp") - .get("rubyVersionManager")!, + // Searches for a given filename in the current directory and all parent directories until it finds it or hits the + // root + private async searchAndReadFile( + filename: string, + searchParentDirectories: boolean, ) { - this.versionManager = versionManager; + let dir = this.cwd; - // If the version manager is auto, discover the actual manager before trying to activate anything - if (this.versionManager === VersionManager.Auto) { - await this.discoverVersionManager(); - this.outputChannel.info( - `Discovered version manager ${this.versionManager}`, - ); + if (!searchParentDirectories) { + const fullPath = path.join(dir, filename); + + if (await pathExists(fullPath)) { + return fs.readFile(fullPath, "utf8"); + } + + return; } - try { - switch (this.versionManager) { - case VersionManager.Asdf: - await this.activate("asdf exec ruby"); - break; - case VersionManager.Chruby: - await this.activateChruby(); - break; - case VersionManager.Rbenv: - await this.activate("rbenv exec ruby"); - break; - case VersionManager.Rvm: - await this.activate("rvm-auto-ruby"); - break; - case VersionManager.Custom: - await this.activateCustomRuby(); - break; - case VersionManager.None: - await this.activate("ruby"); - break; - default: - await this.activateShadowenv(); - break; + while (await pathExists(dir)) { + const versionFile = path.join(dir, filename); + + if (await pathExists(versionFile)) { + return fs.readFile(versionFile, "utf8"); } - this.fetchRubyVersionInfo(); - this.deleteGcEnvironmentVariables(); - await this.setupBundlePath(); - this._error = false; - } catch (error: any) { - this._error = true; + const parent = path.dirname(dir); - // 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; + // When we hit the root path (e.g. /), parent will be the same as dir. + // We don't want to loop forever in this case, so we break out of the loop. + if (parent === dir) { + break; } - await vscode.window.showErrorMessage( - `Failed to activate ${this.versionManager} environment: ${error.message}`, - ); + dir = parent; } + + return undefined; } - private async activateShadowenv() { - if ( - !(await pathExists(path.join(this.workingFolderPath, ".shadowenv.d"))) - ) { - throw new Error( - "The Ruby LSP version manager is configured to be shadowenv, \ - but no .shadowenv.d directory was found in the workspace", - ); + // Tries to read the configured Ruby version from a variety of different files, such as `.ruby-version`, + // `.tool-versions` or `.rtx.toml` + private async readConfiguredRubyVersion(): Promise<{ + engine?: string; + version: string; + }> { + let contents = await this.searchAndReadFile("dev.yml", false); + if (contents) { + const match = /- ruby: ('|")?(\d\.\d\.\d)/.exec(contents); + const version = match && match[2]; + + if (!version) { + throw new Error( + "Found dev.yml file, but it did not define a Ruby version", + ); + } + + return { version }; } - const result = await asyncExec("shadowenv hook --json 1>&2", { - cwd: this.cwd, - }); + // Try to find a Ruby version in `.ruby-version`. We search parent directories until we find it or hit the root + contents = await this.searchAndReadFile(".ruby-version", true); - if (result.stderr.trim() === "") { - result.stderr = "{ }"; + // rbenv allows setting a global Ruby version in `~/.rbenv/version`. If we couldn't find a project specific + // `.ruby-version` file, then we need to check for the global one + const globalRbenvVersionPath = path.join(os.homedir(), ".rbenv", "version"); + if (!contents && (await pathExists(globalRbenvVersionPath))) { + contents = await fs.readFile(globalRbenvVersionPath, "utf8"); } - // eslint-disable-next-line no-process-env - const env = { ...process.env, ...JSON.parse(result.stderr).exported }; - - // The only reason we set the process environment here is to allow other extensions that don't perform activation - // work properly - // eslint-disable-next-line no-process-env - process.env = env; - this._env = env; - - // Get the Ruby version and YJIT support. Shadowenv is the only manager where this is separate from activation - const rubyInfo = await asyncExec( - "ruby -e 'STDERR.print(\"#{RUBY_VERSION},#{defined?(RubyVM::YJIT)}\")'", - { env: this._env, cwd: this.cwd }, - ); - const [rubyVersion, yjitIsDefined] = rubyInfo.stderr.trim().split(","); - this.rubyVersion = rubyVersion; - this.yjitEnabled = yjitIsDefined === "constant"; - } + if (contents) { + const match = + /((?[A-Za-z]+)-)?(?\d\.\d\.\d(-[A-Za-z0-9]+)?)/.exec( + contents, + ); - private async activateChruby() { - const rubyVersion = await this.readRubyVersion(); - await this.activate(`chruby "${rubyVersion}" && ruby`); - } + if (!match || !match.groups) { + throw new Error( + "Found .ruby-version file, but it did not define a valid Ruby version", + ); + } - private async activate(ruby: string) { - let command = this.shell ? `${this.shell} -i -c '` : ""; + return { engine: match.groups.engine, version: match.groups.version }; + } - // The Ruby activation script is intentionally written as an array that gets joined into a one liner because some - // terminals cannot handle line breaks. Do not switch this to a multiline string or that will break activation for - // those terminals - const script = [ - "STDERR.printf(%{RUBY_ENV_ACTIVATE%sRUBY_ENV_ACTIVATE}, ", - "JSON.dump({ env: ENV.to_h, ruby_version: RUBY_VERSION, yjit: defined?(RubyVM::YJIT) }))", - ].join(""); + // Try to find a Ruby version in `.tool-versions`. We search parent directories until we find it or hit the root + contents = await this.searchAndReadFile(".tool-versions", true); + if (contents) { + const match = /ruby (\d\.\d\.\d(-[A-Za-z0-9]+)?)/.exec(contents); + const version = match && match[1]; - command += `${ruby} -rjson -e "${script}"`; + if (!version) { + throw new Error( + "Found .tool-versions file, but it did not define a Ruby version", + ); + } - if (this.shell) { - command += "'"; + return { version }; } - this.outputChannel.info( - `Trying to activate Ruby environment with command: ${command} inside directory: ${this.cwd}`, - ); + // Try to find a Ruby version in `.rtx.toml`. Note: rtx has been renamed to mise, which is handled below. We will + // support rtx for a while until people finish migrating their configurations + contents = await this.searchAndReadFile(".rtx.toml", false); + if (contents) { + const match = /ruby\s+=\s+("|')(.*)("|')/.exec(contents); + const version = match && match[2]; + + if (!version) { + throw new Error( + "Found .rtx.toml file, but it did not define a Ruby version", + ); + } - const result = await asyncExec(command, { cwd: this.cwd }); - const rubyInfoJson = /RUBY_ENV_ACTIVATE(.*)RUBY_ENV_ACTIVATE/.exec( - result.stderr, - )![1]; + return { version }; + } - const rubyInfo = JSON.parse(rubyInfoJson); + // Try to find a Ruby version in `.mise.toml` + contents = await this.searchAndReadFile(".mise.toml", false); + if (contents) { + const match = /ruby\s+=\s+("|')(.*)("|')/.exec(contents); + const version = match && match[2]; + + if (!version) { + throw new Error( + "Found .mise.toml file, but it did not define a Ruby version", + ); + } - this._env = rubyInfo.env; - this.rubyVersion = rubyInfo.ruby_version; - this.yjitEnabled = rubyInfo.yjit === "constant"; + return { version }; + } + + throw new Error( + "Could not find a configured Ruby version. Searched for .ruby-version and .tools-versions", + ); } - // Fetch information related to the Ruby version. This can only be invoked after activation, so that `rubyVersion` is - // set - private fetchRubyVersionInfo() { - const [major, minor, _patch] = this.rubyVersion!.split(".").map(Number); + // Searches all `rubyLookupPaths` to find an installation that matches `version` + private async findRubyDir(version: string, engine: string | undefined) { + // Fast path: if the version contains major, minor and patch, we can just search for a directory directly using that + // as the name and return it + if (/\d\.\d\.\d/.exec(version)) { + for (const dir of RUBY_LOOKUP_PATHS) { + let fullPath = path.join(dir, version); + + if (await pathExists(fullPath)) { + return fullPath; + } + + // Some version managers will define versions with `engine-version`, e.g.: `ruby-3.1.2`. We need to check if a + // directory exists for that format if the engine is set + if (engine) { + fullPath = path.join(dir, `${engine}-${version}`); + + if (await pathExists(fullPath)) { + return fullPath; + } + } + + // RubyInstaller for Windows places rubies in paths like `C:\Ruby32-x64` + if (os.platform() === "win32") { + const [major, minor, _patch] = version.split(".").map(Number); + fullPath = path.join(dir, `Ruby${major}${minor}-${os.arch()}`); + + if (await pathExists(fullPath)) { + return fullPath; + } + } + } - if (major < 3) { throw new Error( - `The Ruby LSP requires Ruby 3.0 or newer to run. This project is using ${this.rubyVersion}. \ - [See alternatives](https://github.com/Shopify/vscode-ruby-lsp?tab=readme-ov-file#ruby-version-requirement)`, + `Cannot find installation directory for Ruby version ${version}`, ); } - this.supportsYjit = - this.yjitEnabled && (major > 3 || (major === 3 && minor >= 2)); - - // Starting with Ruby 3.3 the server enables YJIT itself - const useYjit = - vscode.workspace.getConfiguration("rubyLsp").get("yjit") && - major === 3 && - minor === 2; + // Slow path: some version managers allow configuring the Ruby version without specifying the patch (e.g.: `ruby + // 3.1`). In these cases, we have to discover all available directories and match whatever the latest patch + // installed is + for (const dir of RUBY_LOOKUP_PATHS) { + // Find all existings directories. This will return an array with directories like: + // - /opt/rubies/3.0.0 + // - /opt/rubies/3.1.2 + // - /opt/rubies/3.2.2 + const existingDirectories = ( + await fs.readdir(dir, { withFileTypes: true }) + ).filter((entry) => entry.isDirectory()); + + // Sort directories by name so that the latest version is the first one + existingDirectories.sort((first, second) => + second.name.localeCompare(first.name), + ); - if (this.supportsYjit && useYjit) { - // RUBYOPT may be empty or it may contain bundler paths. In the second case, we must concat to avoid accidentally - // removing the paths from the env variable - if (this._env.RUBYOPT) { - this._env.RUBYOPT.concat(" --yjit"); - } else { - this._env.RUBYOPT = "--yjit"; + // Find the first directory that starts with the requested version + const match = existingDirectories.find((dir) => { + const name = dir.name; + return ( + name.startsWith(version) || + (engine && name.startsWith(`${engine}-${version}`)) + ); + }); + + if (match) { + return `${dir}/${match.name}`; } } + + throw new Error( + `Cannot find installation directory for Ruby version ${version}`, + ); } + // Remove garbage collection customizations from the environment. Normally, people set these for Rails apps, but those + // customizations can often degrade the LSP performance private deleteGcEnvironmentVariables() { - Object.keys(this._env).forEach((key) => { + Object.keys(this.#env).forEach((key) => { if (key.startsWith("RUBY_GC")) { - delete this._env[key]; + delete this.#env[key]; } }); } @@ -259,98 +396,157 @@ export class Ruby implements RubyInterface { ); } - this._env.BUNDLE_GEMFILE = this.customBundleGemfile; + this.#env.BUNDLE_GEMFILE = this.customBundleGemfile; } - private async readRubyVersion() { - let dir = this.cwd; - - while (await pathExists(dir)) { - const versionFile = path.join(dir, ".ruby-version"); - - if (await pathExists(versionFile)) { - const version = await fs.readFile(versionFile, "utf8"); - const trimmedVersion = version.trim(); - - if (trimmedVersion !== "") { - return trimmedVersion; - } - } - - const parent = path.dirname(dir); + // Show an error message because we couldn't detect Ruby automatically and give the opportunity for users to manually + // select an installation + private async showRubyFallbackDialog( + errorMessage: string, + ): Promise { + const answer = await vscode.window.showErrorMessage( + `Automatic Ruby detection failed: ${errorMessage}. + Please address the issue and reload or manually select your Ruby install`, + "Select Ruby", + "Reload window", + ); - // When we hit the root path (e.g. /), parent will be the same as dir. - // We don't want to loop forever in this case, so we break out of the loop. - if (parent === dir) { - break; - } + if (!answer) { + return; + } - dir = parent; + if (answer === "Select Ruby") { + return this.selectRubyInstallation(); } - throw new Error("No .ruby-version file was found"); + return vscode.commands.executeCommand("workbench.action.reloadWindow"); } - private async discoverVersionManager() { - // For shadowenv, it wouldn't be enough to check for the executable's existence. We need to check if the project has - // created a .shadowenv.d folder - if (await pathExists(path.join(this.workingFolderPath, ".shadowenv.d"))) { - this.versionManager = VersionManager.Shadowenv; + // Show a file selection dialog for picking the Ruby binary + private async selectRubyInstallation(): Promise { + const answer = await vscode.window.showInformationMessage( + "Update global or workspace Ruby path?", + "global", + "workspace", + ); + + if (!answer) { return; } - const managers = [ - VersionManager.Asdf, - VersionManager.Chruby, - VersionManager.Rbenv, - VersionManager.Rvm, - ]; + const selection = await vscode.window.showOpenDialog({ + title: `Select Ruby binary path for ${answer} configuration`, + openLabel: "Select Ruby binary", + }); - for (const tool of managers) { - const exists = await this.toolExists(tool); + if (!selection) { + return; + } - if (exists) { - this.versionManager = tool; - return; - } + const rubyPath = selection[0].fsPath; + + if (answer === "global") { + vscode.workspace + .getConfiguration("rubyLsp") + .update("rubyExecutablePath", rubyPath, true, true); + } else { + // We must update the cached Ruby path for this workspace if the user decided to change it + this.context.workspaceState.update( + `rubyLsp.rubyPath.${this.workspaceName}`, + rubyPath, + ); } - // If we can't find a version manager, just return None - this.versionManager = VersionManager.None; + return rubyPath; } - private async toolExists(tool: string) { + // Returns the bin directory for the Ruby installation + private async findRubyPath(): Promise { + let rubyPath; + + // Try to identify the Ruby version and the Ruby installation path automatically. If we fail to find it, we + // display an error message with the reason and allow the user to manually select a Ruby installation path try { - let command = this.shell ? `${this.shell} -i -c '` : ""; - command += `${tool} --version`; + const { engine, version } = await this.readConfiguredRubyVersion(); + this.outputChannel.info(`Discovered Ruby version ${version}`); - if (this.shell) { - command += "'"; + const cachedPath: string | undefined = this.context.workspaceState.get( + `rubyLsp.rubyPath.${this.workspaceName}`, + ); + + // If we already cached the Ruby installation path for this workspace and the Ruby version hasn't changed, just + // return the cached path. Otherwise, we will re-discover the path and cache it at the end of this method + if (cachedPath && path.basename(path.dirname(cachedPath)) === version) { + this.outputChannel.info(`Using cached Ruby path: ${cachedPath}`); + return cachedPath; } - this.outputChannel.info( - `Checking if ${tool} is available on the path with command: ${command}`, - ); + rubyPath = path.join(await this.findRubyDir(version, engine), "bin"); + this.outputChannel.info(`Found Ruby installation in ${rubyPath}`); + } catch (error: any) { + // If there's a globally configured Ruby path, then use it + const globalRubyPath: string | undefined = vscode.workspace + .getConfiguration("rubyLsp") + .get("rubyExecutablePath"); + + if (globalRubyPath) { + const binDir = path.dirname(globalRubyPath); + this.outputChannel.info(`Using configured global Ruby path: ${binDir}`); + return binDir; + } - await asyncExec(command, { cwd: this.workingFolderPath, timeout: 1000 }); - return true; - } catch { - return false; - } - } + rubyPath = await this.showRubyFallbackDialog(error.message); - private async activateCustomRuby() { - const configuration = vscode.workspace.getConfiguration("rubyLsp"); - const customCommand: string | undefined = - configuration.get("customRubyCommand"); + // If we couldn't discover the Ruby path and the user didn't select one, we have no way to launch the server + if (!rubyPath) { + throw new Error("Ruby LSP requires a Ruby installation to run"); + } - if (customCommand === undefined) { - throw new Error( - "The customRubyCommand configuration must be set when 'custom' is selected as the version manager. \ - See the [README](https://github.com/Shopify/vscode-ruby-lsp#custom-activation) for instructions.", - ); + // We ask users to select the Ruby binary directly, but we actually need the bin directory containing it + rubyPath = path.dirname(rubyPath); + this.outputChannel.info(`Selected Ruby installation path: ${rubyPath}`); } - await this.activate(`${customCommand} && ruby`); + // Cache the discovered Ruby path for this workspace + this.context.workspaceState.update( + `rubyLsp.rubyPath.${this.workspaceName}`, + rubyPath, + ); + return rubyPath; + } + + // Run the activation script using the Ruby installation we found so that we can discover gem paths + private async runActivationScript( + rubyBinPath: string, + ): Promise { + // Typically, GEM_HOME points to $HOME/.gem/ruby/version_without_patch. For example, for Ruby 3.2.2, it would be + // $HOME/.gem/ruby/3.2.0. However, certain version managers override GEM_HOME to use the patch part of the version, + // resulting in $HOME/.gem/ruby/3.2.2. In our activation script, we check if a directory using the patch exists and + // then prefer that over the default one. + // + // Note: this script follows an odd code style to avoid the usage of && or ||, which lead to syntax errors in + // certain shells if not properly escaped (Windows) + const script = [ + "user_dir = Gem.user_dir", + "paths = Gem.path", + "if paths.length > 2", + " paths.delete(Gem.default_dir)", + " paths.delete(Gem.user_dir)", + " if paths[0]", + " user_dir = paths[0] if Dir.exist?(paths[0])", + " end", + "end", + "newer_gem_home = File.join(File.dirname(user_dir), RUBY_VERSION)", + "gems = (Dir.exist?(newer_gem_home) ? newer_gem_home : user_dir)", + "data = { defaultGems: Gem.default_dir, gems: gems, version: RUBY_VERSION, yjit: defined?(RubyVM::YJIT) }", + "STDERR.print(JSON.dump(data))", + ].join(";"); + + const result = await asyncExec( + `${path.join(rubyBinPath, "ruby")} -rjson -e '${script}'`, + { cwd: this.cwd }, + ); + + return JSON.parse(result.stderr); } } diff --git a/src/rubyLsp.ts b/src/rubyLsp.ts index 30b72486..f943e46c 100644 --- a/src/rubyLsp.ts +++ b/src/rubyLsp.ts @@ -7,7 +7,6 @@ import { Telemetry } from "./telemetry"; import DocumentProvider from "./documentProvider"; import { Workspace } from "./workspace"; import { Command, STATUS_EMITTER, pathExists } from "./common"; -import { VersionManager } from "./ruby"; import { StatusItems } from "./status"; import { TestController } from "./testController"; import { Debugger } from "./debugger"; @@ -261,17 +260,6 @@ export class RubyLsp { .update("enabledFeatures", features, true, true); } }), - vscode.commands.registerCommand(Command.ToggleYjit, () => { - const lspConfig = vscode.workspace.getConfiguration("rubyLsp"); - const yjitEnabled = lspConfig.get("yjit"); - lspConfig.update("yjit", !yjitEnabled, true, true); - - const workspace = this.currentActiveWorkspace(); - - if (workspace) { - STATUS_EMITTER.fire(workspace); - } - }), vscode.commands.registerCommand( Command.ToggleExperimentalFeatures, async () => { @@ -300,20 +288,10 @@ export class RubyLsp { await vscode.commands.executeCommand(result.description); }, ), - vscode.commands.registerCommand( - Command.SelectVersionManager, - async () => { - const configuration = vscode.workspace.getConfiguration("rubyLsp"); - const options = Object.values(VersionManager); - const manager = await vscode.window.showQuickPick(options, { - placeHolder: `Current: ${configuration.get("rubyVersionManager")}`, - }); - - if (manager !== undefined) { - configuration.update("rubyVersionManager", manager, true, true); - } - }, - ), + vscode.commands.registerCommand(Command.ChangeRubyVersion, async () => { + const workspace = this.currentActiveWorkspace(); + return workspace?.ruby.changeVersion(); + }), vscode.commands.registerCommand( Command.RunTest, (_path, name, _command) => { diff --git a/src/status.ts b/src/status.ts index c2f37793..f413a76b 100644 --- a/src/status.ts +++ b/src/status.ts @@ -36,21 +36,18 @@ export class RubyVersionStatus extends StatusItem { this.item.name = "Ruby LSP Status"; this.item.command = { - title: "Change version manager", - command: Command.SelectVersionManager, + title: "Change Ruby version", + command: Command.ChangeRubyVersion, }; this.item.text = "Activating Ruby environment"; - this.item.severity = vscode.LanguageStatusSeverity.Information; } refresh(workspace: WorkspaceInterface): void { - if (workspace.ruby.error) { - this.item.text = "Failed to activate Ruby"; - this.item.severity = vscode.LanguageStatusSeverity.Error; + if (workspace.ruby.rubyVersion) { + this.item.text = `Using Ruby ${workspace.ruby.rubyVersion}`; } else { - this.item.text = `Using Ruby ${workspace.ruby.rubyVersion} with ${workspace.ruby.versionManager}`; - this.item.severity = vscode.LanguageStatusSeverity.Information; + this.item.text = "Ruby environment not activated"; } } } @@ -138,26 +135,10 @@ export class YjitStatus extends StatusItem { } refresh(workspace: WorkspaceInterface): void { - const useYjit: boolean | undefined = vscode.workspace - .getConfiguration("rubyLsp") - .get("yjit"); - - if (useYjit && workspace.ruby.supportsYjit) { + if (workspace.ruby.yjitEnabled) { this.item.text = "YJIT enabled"; - - this.item.command = { - title: "Disable", - command: Command.ToggleYjit, - }; } else { this.item.text = "YJIT disabled"; - - if (workspace.ruby.supportsYjit) { - this.item.command = { - title: "Enable", - command: Command.ToggleYjit, - }; - } } } } diff --git a/src/telemetry.ts b/src/telemetry.ts index ffa9f58f..6af49688 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -75,7 +75,6 @@ export class Telemetry { { namespace: "workbench", field: "colorTheme" }, { namespace: "rubyLsp", field: "enableExperimentalFeatures" }, { namespace: "rubyLsp", field: "yjit" }, - { namespace: "rubyLsp", field: "rubyVersionManager" }, { namespace: "rubyLsp", field: "formatter" }, ].map(({ namespace, field }) => { return this.sendEvent({ diff --git a/src/test/suite/client.test.ts b/src/test/suite/client.test.ts index f285a3d4..63238725 100644 --- a/src/test/suite/client.test.ts +++ b/src/test/suite/client.test.ts @@ -3,11 +3,10 @@ import * as fs from "fs"; import * as path from "path"; import * as os from "os"; -import { afterEach } from "mocha"; import * as vscode from "vscode"; import { State } from "vscode-languageclient/node"; -import { Ruby, VersionManager } from "../../ruby"; +import { Ruby } from "../../ruby"; import { Telemetry, TelemetryApi, TelemetryEvent } from "../../telemetry"; import Client from "../../client"; import { LOG_CHANNEL, asyncExec } from "../../common"; @@ -27,31 +26,19 @@ class FakeApi implements TelemetryApi { } suite("Client", () => { - let client: Client | undefined; - const managerConfig = vscode.workspace.getConfiguration("rubyLsp"); - const currentManager = managerConfig.get("rubyVersionManager"); - - afterEach(async () => { - if (client && client.state === State.Running) { - await client.stop(); - await client.dispose(); - } - - managerConfig.update("rubyVersionManager", currentManager, true, true); - }); + const context = { + extensionMode: vscode.ExtensionMode.Test, + subscriptions: [], + workspaceState: { + get: (_name: string) => undefined, + update: (_name: string, _value: any) => Promise.resolve(), + }, + } as unknown as vscode.ExtensionContext; test("Starting up the server succeeds", async () => { - // eslint-disable-next-line no-process-env - if (process.env.CI) { - await managerConfig.update( - "rubyVersionManager", - VersionManager.None, - true, - true, - ); - } - - const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const tmpPath = fs.mkdtempSync( + path.join(os.tmpdir(), "ruby-lsp-test-client"), + ); const workspaceFolder: vscode.WorkspaceFolder = { uri: vscode.Uri.from({ scheme: "file", path: tmpPath }), name: path.basename(tmpPath), @@ -59,18 +46,14 @@ suite("Client", () => { }; fs.writeFileSync(path.join(tmpPath, ".ruby-version"), "3.2.2"); - const context = { - extensionMode: vscode.ExtensionMode.Test, - subscriptions: [], - workspaceState: { - get: (_name: string) => undefined, - update: (_name: string, _value: any) => Promise.resolve(), - }, - } as unknown as vscode.ExtensionContext; const outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL); + const ruby = new Ruby(workspaceFolder, context, outputChannel); - const ruby = new Ruby(context, workspaceFolder, outputChannel); - await ruby.activateRuby(); + try { + await ruby.activate(); + } catch (error: any) { + assert.fail(`Failed to activate Ruby ${error.message}`); + } await asyncExec("gem install ruby-lsp", { cwd: workspaceFolder.uri.fsPath, @@ -98,7 +81,7 @@ suite("Client", () => { await client.stop(); await client.dispose(); } catch (error: any) { - assert.fail(`Failed to stop server: ${error.message}`); + assert.fail(`Failed to stop server ${error.message}`); } try { diff --git a/src/test/suite/ruby.test.ts b/src/test/suite/ruby.test.ts index e231f93d..6fedb7c4 100644 --- a/src/test/suite/ruby.test.ts +++ b/src/test/suite/ruby.test.ts @@ -5,46 +5,186 @@ import * as os from "os"; import * as vscode from "vscode"; -import { Ruby, VersionManager } from "../../ruby"; -import { WorkspaceChannel } from "../../workspaceChannel"; +import { Ruby } from "../../ruby"; import { LOG_CHANNEL } from "../../common"; +import { WorkspaceChannel } from "../../workspaceChannel"; + +const PATH_SEPARATOR = os.platform() === "win32" ? ";" : ":"; suite("Ruby environment activation", () => { - let ruby: Ruby; + const assertRubyEnv = (rubyEnv: { + GEM_HOME: string; + GEM_PATH: string; + PATH: string; + }) => { + const gemPathParts = rubyEnv.GEM_PATH.split(PATH_SEPARATOR); + assert.match(rubyEnv.GEM_HOME, /.gem\/ruby\/3.2.\d/); + assert.strictEqual(gemPathParts[0], rubyEnv.GEM_HOME); + assert.match(gemPathParts[1], /lib\/ruby\/gems\/3.2.0/); + }; - test("Activate fetches Ruby information when outside of Ruby LSP", async () => { - if (os.platform() !== "win32") { - // eslint-disable-next-line no-process-env - process.env.SHELL = "/bin/bash"; - } + const context = { + extensionMode: vscode.ExtensionMode.Test, + subscriptions: [], + workspaceState: { + get: (_name: string) => undefined, + update: (_name: string, _value: any) => Promise.resolve(), + }, + } as unknown as vscode.ExtensionContext; + const outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL); + test("fetches Ruby environment for .ruby-version", async () => { const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const ruby = new Ruby( + { + uri: { fsPath: tmpPath }, + } as vscode.WorkspaceFolder, + context, + outputChannel, + ); fs.writeFileSync(path.join(tmpPath, ".ruby-version"), "3.2.2"); + const rubyEnv = await ruby.activate(); - const context = { - extensionMode: vscode.ExtensionMode.Test, - } as vscode.ExtensionContext; - const outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL); + assertRubyEnv(rubyEnv); + fs.rmSync(tmpPath, { recursive: true, force: true }); + }); - ruby = new Ruby( + test("fetches Ruby environment for global rbenv version", async () => { + const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const ruby = new Ruby( + { + uri: { fsPath: tmpPath }, + } as vscode.WorkspaceFolder, context, + outputChannel, + ); + const dir = path.join(os.homedir(), ".rbenv"); + const shouldRemoveDir = !fs.existsSync(dir); + + if (shouldRemoveDir) { + fs.mkdirSync(dir); + } + + const versionPath = path.join(dir, "version"); + let originalVersion; + if (fs.existsSync(versionPath)) { + originalVersion = fs.readFileSync(versionPath, "utf8"); + } + + fs.writeFileSync(versionPath, "3.2.2"); + const rubyEnv = await ruby.activate(); + + assertRubyEnv(rubyEnv); + + if (shouldRemoveDir) { + fs.rmSync(dir, { recursive: true, force: true }); + } + + if (originalVersion) { + fs.writeFileSync(versionPath, originalVersion); + } + fs.rmSync(tmpPath, { recursive: true, force: true }); + }); + + test("fetches Ruby environment for .ruby-version using engine", async () => { + const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const ruby = new Ruby( { uri: { fsPath: tmpPath }, } as vscode.WorkspaceFolder, + context, outputChannel, ); - await ruby.activateRuby( - // eslint-disable-next-line no-process-env - process.env.CI ? VersionManager.None : VersionManager.Chruby, + fs.writeFileSync(path.join(tmpPath, ".ruby-version"), "ruby-3.2.2"); + const rubyEnv = await ruby.activate(); + + assertRubyEnv(rubyEnv); + fs.rmSync(tmpPath, { recursive: true, force: true }); + }); + + test("fetches Ruby environment for .ruby-version with .ruby-gemset", async () => { + const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const ruby = new Ruby( + { + uri: { fsPath: tmpPath }, + } as vscode.WorkspaceFolder, + context, + outputChannel, ); + fs.writeFileSync(path.join(tmpPath, ".ruby-version"), "3.2.2"); + fs.writeFileSync(path.join(tmpPath, ".ruby-gemset"), "hello"); + const rubyEnv = await ruby.activate(); + + assertRubyEnv(rubyEnv); + fs.rmSync(tmpPath, { recursive: true, force: true }); + }); + + test("fetches Ruby environment for dev.yml", async () => { + const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const ruby = new Ruby( + { + uri: { fsPath: tmpPath }, + } as vscode.WorkspaceFolder, + context, + outputChannel, + ); + fs.writeFileSync(path.join(tmpPath, "dev.yml"), "- ruby: '3.2.2'"); + const rubyEnv = await ruby.activate(); + + assertRubyEnv(rubyEnv); + fs.rmSync(tmpPath, { recursive: true, force: true }); + }); + + test("fetches Ruby environment for .tool-versions", async () => { + const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const ruby = new Ruby( + { + uri: { fsPath: tmpPath }, + } as vscode.WorkspaceFolder, + context, + outputChannel, + ); + fs.writeFileSync(path.join(tmpPath, ".tool-versions"), "ruby 3.2.2"); + const rubyEnv = await ruby.activate(); + assertRubyEnv(rubyEnv); + fs.rmSync(tmpPath, { recursive: true, force: true }); + }); - assert.ok(ruby.rubyVersion, "Expected Ruby version to be set"); - assert.notStrictEqual( - ruby.supportsYjit, - undefined, - "Expected YJIT support to be set to true or false", + test("fetches Ruby environment for .rtx.toml", async () => { + const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const ruby = new Ruby( + { + uri: { fsPath: tmpPath }, + } as vscode.WorkspaceFolder, + context, + outputChannel, + ); + fs.writeFileSync( + path.join(tmpPath, ".rtx.toml"), + `[tools] + ruby = '3.2.2'`, ); + const rubyEnv = await ruby.activate(); + assertRubyEnv(rubyEnv); + fs.rmSync(tmpPath, { recursive: true, force: true }); + }); + test("fetches Ruby environment for .mise.toml", async () => { + const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const ruby = new Ruby( + { + uri: { fsPath: tmpPath }, + } as vscode.WorkspaceFolder, + context, + outputChannel, + ); + fs.writeFileSync( + path.join(tmpPath, ".mise.toml"), + `[tools] + ruby = '3.2.2'`, + ); + const rubyEnv = await ruby.activate(); + assertRubyEnv(rubyEnv); fs.rmSync(tmpPath, { recursive: true, force: true }); }); }); diff --git a/src/test/suite/status.test.ts b/src/test/suite/status.test.ts index 679b29fc..d5989376 100644 --- a/src/test/suite/status.test.ts +++ b/src/test/suite/status.test.ts @@ -28,7 +28,7 @@ suite("StatusItems", () => { suite("RubyVersionStatus", () => { beforeEach(() => { - ruby = { rubyVersion: "3.2.0", versionManager: "shadowenv" } as Ruby; + ruby = { rubyVersion: "3.2.0" } as Ruby; workspace = { ruby, lspClient: { @@ -43,21 +43,21 @@ 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.text, "Using Ruby 3.2.0"); assert.strictEqual(status.item.name, "Ruby LSP Status"); - assert.strictEqual(status.item.command?.title, "Change version manager"); + assert.strictEqual(status.item.command?.title, "Change Ruby version"); assert.strictEqual( status.item.command.command, - Command.SelectVersionManager, + Command.ChangeRubyVersion, ); }); test("Refresh updates version string", () => { - assert.strictEqual(status.item.text, "Using Ruby 3.2.0 with shadowenv"); + assert.strictEqual(status.item.text, "Using Ruby 3.2.0"); workspace.ruby.rubyVersion = "3.2.1"; status.refresh(workspace); - assert.strictEqual(status.item.text, "Using Ruby 3.2.1 with shadowenv"); + assert.strictEqual(status.item.text, "Using Ruby 3.2.1"); }); }); @@ -145,9 +145,9 @@ suite("StatusItems", () => { }); }); - suite("YjitStatus when Ruby supports it", () => { + suite("YjitStatus", () => { beforeEach(() => { - ruby = { supportsYjit: true } as Ruby; + ruby = { yjitEnabled: true } as Ruby; workspace = { ruby, lspClient: { @@ -161,52 +161,18 @@ suite("StatusItems", () => { status.refresh(workspace); }); - test("Status is initialized with the right values", () => { + test("Shows enabled if YJIT is enabled", () => { assert.strictEqual(status.item.text, "YJIT enabled"); assert.strictEqual(status.item.name, "YJIT"); - assert.strictEqual(status.item.command?.title, "Disable"); - assert.strictEqual(status.item.command.command, Command.ToggleYjit); }); - test("Refresh updates whether it's disabled or enabled", () => { - assert.strictEqual(status.item.text, "YJIT enabled"); - - workspace.ruby.supportsYjit = false; + test("Shows disabled if YJIT is disabled", () => { + workspace.ruby.yjitEnabled = false; status.refresh(workspace); assert.strictEqual(status.item.text, "YJIT disabled"); }); }); - suite("YjitStatus when Ruby does not support it", () => { - beforeEach(() => { - ruby = { supportsYjit: false } as Ruby; - workspace = { - ruby, - lspClient: { - state: State.Running, - formatter: "none", - serverVersion: "1.0.0", - }, - error: false, - }; - status = new YjitStatus(); - status.refresh(workspace); - }); - - test("Refresh ignores YJIT configuration if Ruby doesn't support it", () => { - assert.strictEqual(status.item.text, "YJIT disabled"); - assert.strictEqual(status.item.command, undefined); - - const lspConfig = vscode.workspace.getConfiguration("rubyLsp"); - lspConfig.update("yjit", true, true, true); - workspace.ruby.supportsYjit = false; - status.refresh(workspace); - - assert.strictEqual(status.item.text, "YJIT disabled"); - assert.strictEqual(status.item.command, undefined); - }); - }); - suite("FeaturesStatus", () => { const configuration = vscode.workspace.getConfiguration("rubyLsp"); const originalFeatures: Record = diff --git a/src/test/suite/telemetry.test.ts b/src/test/suite/telemetry.test.ts index 658d6b93..bf964c90 100644 --- a/src/test/suite/telemetry.test.ts +++ b/src/test/suite/telemetry.test.ts @@ -99,7 +99,7 @@ suite("Telemetry", () => { .get("enabledFeatures")!; const expectedNumberOfEvents = - 5 + Object.keys(featureConfigurations).length; + 4 + Object.keys(featureConfigurations).length; assert.strictEqual(api.sentEvents.length, expectedNumberOfEvents); diff --git a/src/workspace.ts b/src/workspace.ts index c8a3cd1a..5fdd555a 100644 --- a/src/workspace.ts +++ b/src/workspace.ts @@ -39,7 +39,7 @@ export class Workspace implements WorkspaceInterface { LOG_CHANNEL, ); this.telemetry = telemetry; - this.ruby = new Ruby(context, workspaceFolder, this.outputChannel); + this.ruby = new Ruby(workspaceFolder, context, this.outputChannel); this.createTestItems = createTestItems; this.registerRestarts(context); @@ -47,10 +47,15 @@ export class Workspace implements WorkspaceInterface { } async start() { - await this.ruby.activateRuby(); - - if (this.ruby.error) { + try { + await this.ruby.activate(); + } catch (error: any) { this.error = true; + + vscode.window.showErrorMessage( + `Failed to activate Ruby environment: ${error.message}`, + ); + return; } @@ -235,13 +240,8 @@ export class Workspace implements WorkspaceInterface { // configuration and restart the server vscode.workspace.onDidChangeConfiguration(async (event) => { if (event.affectsConfiguration("rubyLsp")) { - // Re-activate Ruby if the version manager changed - if ( - event.affectsConfiguration("rubyLsp.rubyVersionManager") || - event.affectsConfiguration("rubyLsp.bundleGemfile") || - event.affectsConfiguration("rubyLsp.customRubyCommand") - ) { - await this.ruby.activateRuby(); + if (event.affectsConfiguration("rubyLsp.bundleGemfile")) { + await this.ruby.activate(); } await this.restart();