diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2637e51e..17fa5aad 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.3" + ruby-version: "3.3.0" + + # 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.3.0/x64 /opt/rubies/3.3.0 + + - name: Symlink Ruby on MacOS + if: matrix.os == 'macos-latest' + run: | + mkdir /Users/runner/.rubies + ln -s /Users/runner/hostedtoolcache/Ruby/3.3.0/x64 /Users/runner/.rubies/3.3.0 + + - name: Symlink Ruby on Windows + if: matrix.os == 'windows-latest' + run: | + New-Item -Path C:\Ruby33-x64 -ItemType SymbolicLink -Value C:\hostedtoolcache\windows\Ruby\3.3.0\x64 - name: 📦 Install dependencies run: yarn --frozen-lockfile diff --git a/package.json b/package.json index 69be0f88..e38b1312 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.11", + "version": "0.6.10", "publisher": "Shopify", "repository": { "type": "git", @@ -57,8 +57,8 @@ "category": "Ruby LSP" }, { - "command": "rubyLsp.selectRubyVersionManager", - "title": "Select Ruby version manager", + "command": "rubyLsp.changeRubyVersion", + "title": "Change Ruby version", "category": "Ruby LSP" }, { @@ -244,30 +244,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", @@ -289,6 +265,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", @@ -494,6 +475,7 @@ "@types/glob": "^8.1.0", "@types/mocha": "^10.0.6", "@types/node": "20.x", + "@types/sinon": "^17.0.3", "@types/vscode": "^1.68.0", "@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/parser": "^5.62.0", @@ -507,6 +489,7 @@ "mocha": "^10.3.0", "ovsx": "^0.8.3", "prettier": "^3.2.5", + "sinon": "^17.0.1", "typescript": "^5.3.3", "vscode-oniguruma": "^2.0.1", "vscode-textmate": "^9.0.0" diff --git a/src/common.ts b/src/common.ts index 5d78139f..1cd4fd0b 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..1a448a54 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,318 @@ 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(); + } + + if (!(await pathExists(matchedRubyPath, fs.constants.X_OK))) { + throw new Error( + `Discovered Ruby path ${matchedRubyPath}, but VS Code does not have permissions to execute it`, + ); + } + + 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 rubyEnv = { + GEM_HOME: userGemsPath, + GEM_PATH: `${userGemsPath}${path.delimiter}${defaultGems}`, + PATH: `${path.join(userGemsPath, "bin")}${path.delimiter}${path.join( + defaultGems, + "bin", + )}${path.delimiter}${matchedRubyPath}${path.delimiter}${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; + }> { + // Try to find a Ruby version in `dev.yml`. We search parent directories until we find it or hit the root + 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) { + 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) { + return { engine: match.groups.engine, version: match.groups.version }; + } + } - private async activate(ruby: string) { - let command = this.shell ? `${this.shell} -i -c '` : ""; + // 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]; - // 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(""); + if (version) { + return { version }; + } + } - command += `${ruby} -rjson -e "${script}"`; + // 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 (this.shell) { - command += "'"; + if (version) { + return { version }; + } } - this.outputChannel.info( - `Trying to activate Ruby environment with command: ${command} inside directory: ${this.cwd}`, + // 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) { + return { version }; + } + } + + throw new Error( + "Could not find a valid Ruby version in any of `.ruby-version`, `.tool-versions`, `.rtx.toml` " + + "or `.mise.toml` files", ); + } - const result = await asyncExec(command, { cwd: this.cwd }); - const rubyInfoJson = /RUBY_ENV_ACTIVATE(.*)RUBY_ENV_ACTIVATE/.exec( - result.stderr, - )![1]; + // 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); - const rubyInfo = JSON.parse(rubyInfoJson); + if (await pathExists(fullPath, fs.constants.F_OK)) { + return fullPath; + } - this._env = rubyInfo.env; - this.rubyVersion = rubyInfo.ruby_version; - this.yjitEnabled = rubyInfo.yjit === "constant"; - } + // 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}`); - // 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); + if (await pathExists(fullPath, fs.constants.F_OK)) { + 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, fs.constants.F_OK)) { + 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 +383,178 @@ 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"); + // 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", + ); - if (await pathExists(versionFile)) { - const version = await fs.readFile(versionFile, "utf8"); - const trimmedVersion = version.trim(); + if (!answer) { + throw new Error("Ruby LSP requires a Ruby installation to run"); + } - if (trimmedVersion !== "") { - return trimmedVersion; - } - } + if (answer === "Select Ruby") { + return this.selectRubyInstallation(); + } - const parent = path.dirname(dir); + return vscode.commands.executeCommand("workbench.action.reloadWindow"); + } - // 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; - } + // 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", + "clear previous workspace selection", + ); - dir = parent; + if (!answer) { + throw new Error("Ruby LSP requires a Ruby installation to run"); } - throw new Error("No .ruby-version file was found"); - } + if (answer === "clear previous workspace selection") { + this.context.workspaceState.update( + `rubyLsp.selectedRubyPath.${this.workspaceName}`, + undefined, + ); - 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; - return; + return this.findRubyPath(); } - 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) { + throw new Error("Ruby LSP requires a Ruby installation to run"); + } - 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.selectedRubyPath.${this.workspaceName}`, + path.dirname(rubyPath), + ); } - // If we can't find a version manager, just return None - this.versionManager = VersionManager.None; + return rubyPath; } - private async toolExists(tool: string) { - try { - let command = this.shell ? `${this.shell} -i -c '` : ""; - command += `${tool} --version`; + // Returns the bin directory for the Ruby installation + private async findRubyPath(): Promise { + let rubyPath: string; - if (this.shell) { - command += "'"; + // 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 { + const { engine, version } = await this.readConfiguredRubyVersion(); + this.outputChannel.info(`Discovered Ruby version ${version}`); + + const selectedCachedPath: string | undefined = + this.context.workspaceState.get( + `rubyLsp.selectedRubyPath.${this.workspaceName}`, + ); + + // If the user selected a Ruby path manually, then we need to respect that selection + if (selectedCachedPath) { + this.outputChannel.info( + `Using cached Ruby path: ${selectedCachedPath}`, + ); + return selectedCachedPath; } - this.outputChannel.info( - `Checking if ${tool} is available on the path with command: ${command}`, + const cachedPath: string | undefined = this.context.workspaceState.get( + `rubyLsp.rubyPath.${this.workspaceName}`, ); - await asyncExec(command, { cwd: this.workingFolderPath, timeout: 1000 }); - return true; - } catch { - return false; - } - } + // 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; + } - private async activateCustomRuby() { - const configuration = vscode.workspace.getConfiguration("rubyLsp"); - const customCommand: string | undefined = - configuration.get("customRubyCommand"); + 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; + } - 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.", - ); + rubyPath = await this.showRubyFallbackDialog(error.message); + + // 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"); + } + + // 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 a5a6f083..bef989c3 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"; @@ -263,17 +262,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 () => { @@ -302,20 +290,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 d45d3c99..42be2bef 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.3.0"); - 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/debugger.test.ts b/src/test/suite/debugger.test.ts index 1ca80c70..adcec12e 100644 --- a/src/test/suite/debugger.test.ts +++ b/src/test/suite/debugger.test.ts @@ -6,7 +6,7 @@ import * as os from "os"; import * as vscode from "vscode"; import { Debugger } from "../../debugger"; -import { Ruby, VersionManager } from "../../ruby"; +import { Ruby } from "../../ruby"; import { Workspace } from "../../workspace"; import { WorkspaceChannel } from "../../workspaceChannel"; import { LOG_CHANNEL, asyncExec } from "../../common"; @@ -138,13 +138,6 @@ suite("Debugger", () => { }); test("Launching the debugger", async () => { - // eslint-disable-next-line no-process-env - if (process.env.CI) { - await vscode.workspace - .getConfiguration("rubyLsp") - .update("rubyVersionManager", VersionManager.None, true, true); - } - // By default, VS Code always saves all open files when launching a debugging session. This is a problem for tests // because it attempts to save an untitled test file and then we get stuck in the save file dialog with no way of // closing it. We have to disable that before running this test @@ -165,15 +158,25 @@ suite("Debugger", () => { 'source "https://rubygems.org"\ngem "debug"', ); - const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; + const context = { + subscriptions: [], + workspaceState: { + get: (_name: string) => undefined, + update: (_name: string, _value: any) => Promise.resolve(), + }, + globalState: { + get: (_name: string) => undefined, + update: (_name: string, _value: any) => Promise.resolve(), + }, + } as unknown as vscode.ExtensionContext; const outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL); const workspaceFolder: vscode.WorkspaceFolder = { uri: vscode.Uri.from({ scheme: "file", path: tmpPath }), name: path.basename(tmpPath), index: 0, }; - const ruby = new Ruby(context, workspaceFolder, outputChannel); - await ruby.activateRuby(); + const ruby = new Ruby(workspaceFolder, context, outputChannel); + await ruby.activate(); try { await asyncExec("gem install debug", { env: ruby.env, cwd: tmpPath }); diff --git a/src/test/suite/ruby.test.ts b/src/test/suite/ruby.test.ts index 09c87860..4a31460b 100644 --- a/src/test/suite/ruby.test.ts +++ b/src/test/suite/ruby.test.ts @@ -5,46 +5,228 @@ 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"; 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.delimiter); + assert.match(rubyEnv.GEM_HOME, /.gem\/ruby\/3.3.\d/); + assert.strictEqual(gemPathParts[0], rubyEnv.GEM_HOME); + assert.match(gemPathParts[1], /lib\/ruby\/gems\/3.3.0/); + }; + + const context = { + extensionMode: vscode.ExtensionMode.Test, + subscriptions: [], + workspaceState: { + get: (_name: string) => undefined, + update: (_name: string, _value: any) => Promise.resolve(), + }, + globalState: { + get: (_name: string) => undefined, + update: (_name: string, _value: any) => Promise.resolve(), + }, + } as unknown as vscode.ExtensionContext; + const outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL); + + test("falls through all Ruby environment detection methods", 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"); + fs.writeFileSync(path.join(tmpPath, ".ruby-version"), ""); + fs.writeFileSync(path.join(tmpPath, ".tool-versions"), ""); + fs.writeFileSync(path.join(tmpPath, ".rtx.toml"), ""); + fs.writeFileSync( + path.join(tmpPath, ".mise.toml"), + `[tools] + ruby = '3.3.0'`, + ); + + await ruby.activate(); + fs.rmSync(tmpPath, { recursive: true, force: true }); + }); + + 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.3.0"); + const rubyEnv = await ruby.activate(); + + assertRubyEnv(rubyEnv); + fs.rmSync(tmpPath, { recursive: true, force: true }); + }); - 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"; + 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.3.0"); + 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, + ); + fs.writeFileSync(path.join(tmpPath, ".ruby-version"), "ruby-3.3.0"); + 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.3.0"); + fs.writeFileSync(path.join(tmpPath, ".ruby-gemset"), "hello"); + 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 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.3.0'"); + const rubyEnv = await ruby.activate(); + + assertRubyEnv(rubyEnv); + fs.rmSync(tmpPath, { recursive: true, force: true }); + }); + + test("falls back to parsing Ruby environment from .ruby-version if dev.yml doesn't contain 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, ); - await ruby.activateRuby( - // eslint-disable-next-line no-process-env - process.env.CI ? VersionManager.None : VersionManager.Chruby, + fs.writeFileSync(path.join(tmpPath, "dev.yml"), "- ruby"); + fs.writeFileSync(path.join(tmpPath, ".ruby-version"), "3.3.0"); + 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.3.0"); + 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.3.0'`, + ); + 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.3.0'`, + ); + 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 382f83b6..e19b7ebf 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: { @@ -44,21 +44,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"); }); }); @@ -148,9 +148,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: { @@ -165,50 +165,15 @@ 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; - 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", - sendRequest: () => Promise.resolve([] as T), - }, - 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; + test("Shows disabled if YJIT is disabled", () => { + workspace.ruby.yjitEnabled = false; status.refresh(workspace); - assert.strictEqual(status.item.text, "YJIT disabled"); - assert.strictEqual(status.item.command, undefined); }); }); 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(); diff --git a/yarn.lock b/yarn.lock index 7ae25901..c7d80513 100644 --- a/yarn.lock +++ b/yarn.lock @@ -506,6 +506,41 @@ resolved "https://registry.yarnpkg.com/@shopify/prettier-config/-/prettier-config-1.1.2.tgz#612f87c0cd1196e8b43c85425e587d0fa7f1172d" integrity sha512-5ugCL9sPGzmOaZjeRGaWUWhHgAbemrS6z+R7v6gwiD+BiqSeoFhIY+imLpfdFCVpuOGalpHeCv6o3gv++EHs0A== +"@sinonjs/commons@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-2.0.0.tgz#fd4ca5b063554307e8327b4564bd56d3b73924a3" + integrity sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg== + dependencies: + type-detect "4.0.8" + +"@sinonjs/commons@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^11.2.2": + version "11.2.2" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz#50063cc3574f4a27bd8453180a04171c85cc9699" + integrity sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw== + dependencies: + "@sinonjs/commons" "^3.0.0" + +"@sinonjs/samsam@^8.0.0": + version "8.0.0" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-8.0.0.tgz#0d488c91efb3fa1442e26abea81759dfc8b5ac60" + integrity sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew== + dependencies: + "@sinonjs/commons" "^2.0.0" + lodash.get "^4.4.2" + type-detect "^4.0.8" + +"@sinonjs/text-encoding@^0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@sinonjs/text-encoding/-/text-encoding-0.7.2.tgz#5981a8db18b56ba38ef0efb7d995b12aa7b51918" + integrity sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ== + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -551,6 +586,18 @@ resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.5.6.tgz#c65b2bfce1bec346582c07724e3f8c1017a20339" integrity sha512-dn1l8LaMea/IjDoHNd9J52uBbInB796CDffS6VdIxvqYCPSG0V0DzHp76GpaWnlhg88uYyPbXCDIowa86ybd5A== +"@types/sinon@^17.0.3": + version "17.0.3" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.3.tgz#9aa7e62f0a323b9ead177ed23a36ea757141a5fa" + integrity sha512-j3uovdn8ewky9kRBG19bOwaZbexJu/XjtkHyjvUgt4xfPFz18dcORIMqnYh66Fx3Powhcr85NT5+er3+oViapw== + dependencies: + "@types/sinonjs__fake-timers" "*" + +"@types/sinonjs__fake-timers@*": + version "8.1.5" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2" + integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ== + "@types/vscode@^1.68.0": version "1.85.0" resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.85.0.tgz#46beb07f0f626665b52d1e2294382b2bc63b602e" @@ -1391,6 +1438,11 @@ diff@5.0.0: resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== +diff@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.1.0.tgz#bc52d298c5ea8df9194800224445ed43ffc87e40" + integrity sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw== + dir-glob@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" @@ -2746,6 +2798,11 @@ jszip@^3.10.1: readable-stream "~2.3.6" setimmediate "^1.0.5" +just-extend@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-6.2.0.tgz#b816abfb3d67ee860482e7401564672558163947" + integrity sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw== + keytar@^7.7.0: version "7.9.0" resolved "https://registry.yarnpkg.com/keytar/-/keytar-7.9.0.tgz#4c6225708f51b50cbf77c5aae81721964c2918cb" @@ -2807,6 +2864,11 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" +lodash.get@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.get/-/lodash.get-4.4.2.tgz#2d177f652fa31e939b4438d5341499dfa3825e99" + integrity sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ== + lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" @@ -3006,6 +3068,17 @@ natural-compare@^1.4.0: resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +nise@^5.1.5: + version "5.1.7" + resolved "https://registry.yarnpkg.com/nise/-/nise-5.1.7.tgz#03ca96539efb306612eb60a8c5d6beeb208e27e5" + integrity sha512-wWtNUhkT7k58uvWTB/Gy26eA/EJKtPZFVAhEilN5UYVmmGRYOURbejRUyKm0Uu9XVEW7K5nBOZfR8VMB4QR2RQ== + dependencies: + "@sinonjs/commons" "^3.0.0" + "@sinonjs/fake-timers" "^11.2.2" + "@sinonjs/text-encoding" "^0.7.2" + just-extend "^6.2.0" + path-to-regexp "^6.2.1" + no-case@^3.0.4: version "3.0.4" resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" @@ -3302,6 +3375,11 @@ path-scurry@^1.10.1: lru-cache "^9.1.1 || ^10.0.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" +path-to-regexp@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-6.2.1.tgz#d54934d6798eb9e5ef14e7af7962c945906918e5" + integrity sha512-JLyh7xT1kizaEvcaXOQwOc2/Yhw6KZOvPf1S8401UyLk86CU79LN3vl7ztXGm/pZ+YjoyAJ4rxmHwbkBXJX+yw== + path-type@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" @@ -3692,6 +3770,18 @@ simple-get@^4.0.0: once "^1.3.1" simple-concat "^1.0.0" +sinon@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-17.0.1.tgz#26b8ef719261bf8df43f925924cccc96748e407a" + integrity sha512-wmwE19Lie0MLT+ZYNpDymasPHUKTaZHUH/pKEubRXIzySv9Atnlw+BUMGCzWgV7b7wO+Hw6f1TEOr0IUnmU8/g== + dependencies: + "@sinonjs/commons" "^3.0.0" + "@sinonjs/fake-timers" "^11.2.2" + "@sinonjs/samsam" "^8.0.0" + diff "^5.1.0" + nise "^5.1.5" + supports-color "^7.2.0" + slash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" @@ -3833,7 +3923,7 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^7.1.0: +supports-color@^7.1.0, supports-color@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== @@ -3959,6 +4049,11 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" +type-detect@4.0.8, type-detect@^4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + type-fest@^0.20.2: version "0.20.2" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"