From ff73dc96b6399fb805443d718feedb55cab80e24 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Fri, 29 Nov 2024 10:39:34 -0500 Subject: [PATCH] Fallback to closest Ruby if we can't find an installation for the requested version (#2912) ### Motivation Closes #2793 This PR starts adds a fallback for when the specified Ruby in `.ruby-version` is not found/not installed. The UX is similar to the fallback for missing `.ruby-version` configurations. We show a temporary progress warning the user we're going to fallback and give the chance of cancelling, which allows them to manually configure the right version. ### Implementation The implementation is very similar to the fallback for missing `.ruby-version`. The main distinction is that, when we know the version specified, we can try to fallback to the closest one available. There are a few decisions I baked into the implementation: 1. We do not try to approximate major versions. For example, if the `.ruby-version` file specifies Ruby 4.0.0, we would not try to fallback to 3.0.0. I think this is a fair limitation and since Ruby is so conservative about major versions, it shouldn't be too painful 2. To approximate, we search for the Ruby installation with the smallest difference in minor version using the highest patch version as the tie breaker (this is done with a sort). For example, if the requested version is `3.3.5` and we have `3.1.0` and `3.2.2` installed, we want to pick `3.2.2` because that's closest to `3.3.5`. And as another example, if both `3.2.0` and `3.2.2` are installed, we would pick `3.2.2` because that's the highest patch ### Automated Tests Added tests. --- vscode/src/ruby/chruby.ts | 126 ++++++++++++++++++---- vscode/src/test/suite/ruby/chruby.test.ts | 97 ++++++++++------- 2 files changed, 169 insertions(+), 54 deletions(-) diff --git a/vscode/src/ruby/chruby.ts b/vscode/src/ruby/chruby.ts index bed60aa25..cfad8ae47 100644 --- a/vscode/src/ruby/chruby.ts +++ b/vscode/src/ruby/chruby.ts @@ -17,7 +17,7 @@ interface RubyVersion { version: string; } -class RubyVersionCancellationError extends Error {} +class RubyActivationCancellationError extends Error {} // A tool to change the current Ruby version // Learn more: https://github.com/postmodern/chruby @@ -48,12 +48,14 @@ export class Chruby extends VersionManager { async activate(): Promise { let versionInfo = await this.discoverRubyVersion(); - let rubyUri: vscode.Uri; + let rubyUri: vscode.Uri | undefined; - if (versionInfo) { - rubyUri = await this.findRubyUri(versionInfo); - } else { - try { + // If the version informed is available, try to find the Ruby installation. Otherwise, try to fall back to an + // existing version + try { + if (versionInfo) { + rubyUri = await this.findRubyUri(versionInfo); + } else { const fallback = await this.fallbackWithCancellation( "No .ruby-version file found. Trying to fall back to latest installed Ruby in 10 seconds", "You can create a .ruby-version file in a parent directory to configure a fallback", @@ -63,14 +65,39 @@ export class Chruby extends VersionManager { versionInfo = fallback.rubyVersion; rubyUri = fallback.uri; - } catch (error: any) { - if (error instanceof RubyVersionCancellationError) { - // Try to re-activate if the user has configured a fallback during cancellation - return this.activate(); - } + } + } catch (error: any) { + if (error instanceof RubyActivationCancellationError) { + // Try to re-activate if the user has configured a fallback during cancellation + return this.activate(); + } + + throw error; + } + + // If we couldn't find a Ruby installation, that means there's a `.ruby-version` file, but that Ruby is not + // installed. In this case, we fallback to a closest installation of Ruby - preferably only varying in patch + try { + if (!rubyUri) { + const currentVersionInfo = { ...versionInfo }; + + const fallback = await this.fallbackWithCancellation( + `Couldn't find installation for ${versionInfo.version}. Trying to fall back to other Ruby in 10 seconds`, + "You can cancel this fallback and install the required Ruby", + async () => this.findClosestRubyInstallation(currentVersionInfo), + () => this.missingRubyError(currentVersionInfo.version), + ); - throw error; + versionInfo = fallback.rubyVersion; + rubyUri = fallback.uri; + } + } catch (error: any) { + if (error instanceof RubyActivationCancellationError) { + // Try to re-activate if the user has configured a fallback during cancellation + return this.activate(); } + + throw error; } this.outputChannel.info( @@ -106,7 +133,9 @@ export class Chruby extends VersionManager { } // Returns the full URI to the Ruby executable - protected async findRubyUri(rubyVersion: RubyVersion): Promise { + protected async findRubyUri( + rubyVersion: RubyVersion, + ): Promise { const possibleVersionNames = rubyVersion.engine ? [`${rubyVersion.engine}-${rubyVersion.version}`, rubyVersion.version] : [rubyVersion.version, `ruby-${rubyVersion.version}`]; @@ -137,10 +166,67 @@ export class Chruby extends VersionManager { } } - throw new Error( - `Cannot find installation directory for Ruby version ${possibleVersionNames.join(" or ")}. - Searched in ${this.rubyInstallationUris.map((uri) => uri.fsPath).join(", ")}`, - ); + return undefined; + } + + private async findClosestRubyInstallation(rubyVersion: RubyVersion): Promise<{ + uri: vscode.Uri; + rubyVersion: RubyVersion; + }> { + const [major, minor, _patch] = rubyVersion.version.split("."); + const directories: { uri: vscode.Uri; rubyVersion: RubyVersion }[] = []; + + for (const uri of this.rubyInstallationUris) { + try { + // Accumulate all directories that match the `engine-version` pattern and that start with the same requested + // major version. We do not try to approximate major versions + (await vscode.workspace.fs.readDirectory(uri)).forEach(([name]) => { + const match = + /((?[A-Za-z]+)-)?(?\d+\.\d+(\.\d+)?(-[A-Za-z0-9]+)?)/.exec( + name, + ); + + if (match?.groups && match.groups.version.startsWith(major)) { + directories.push({ + uri: vscode.Uri.joinPath(uri, name, "bin", "ruby"), + rubyVersion: { + engine: match.groups.engine, + version: match.groups.version, + }, + }); + } + }); + } catch (error: any) { + // If the directory doesn't exist, keep searching + this.outputChannel.debug( + `Tried searching for Ruby installation in ${uri.fsPath} but it doesn't exist`, + ); + continue; + } + } + + // Sort the directories based on the difference between the minor version and the requested minor version. On + // conflicts, we use the patch version to break the tie. If there's no distance, we prefer the higher patch version + const closest = directories.sort((left, right) => { + const leftVersion = left.rubyVersion.version.split("."); + const rightVersion = right.rubyVersion.version.split("."); + + const leftDiff = Math.abs(Number(leftVersion[1]) - Number(minor)); + const rightDiff = Math.abs(Number(rightVersion[1]) - Number(minor)); + + // If the distance to minor version is the same, prefer higher patch number + if (leftDiff === rightDiff) { + return Number(rightVersion[2] || 0) - Number(leftVersion[2] || 0); + } + + return leftDiff - rightDiff; + })[0]; + + if (closest) { + return closest; + } + + throw new Error("Cannot find any Ruby installations"); } // Returns the Ruby version information including version and engine. E.g.: ruby-3.3.0, truffleruby-21.3.0 @@ -232,7 +318,7 @@ export class Chruby extends VersionManager { await this.handleCancelledFallback(errorFn); // We throw this error to be able to catch and re-run activation after the user has configured a fallback - throw new RubyVersionCancellationError(); + throw new RubyActivationCancellationError(); } return fallbackFn(); @@ -396,6 +482,10 @@ export class Chruby extends VersionManager { return { defaultGems, gemHome, yjit: yjit === "true", version }; } + private missingRubyError(version: string) { + return new Error(`Cannot find Ruby installation for version ${version}`); + } + private rubyVersionError() { return new Error( `Cannot find .ruby-version file. Please specify the Ruby version in a diff --git a/vscode/src/test/suite/ruby/chruby.test.ts b/vscode/src/test/suite/ruby/chruby.test.ts index cd728b492..cfff8503f 100644 --- a/vscode/src/test/suite/ruby/chruby.test.ts +++ b/vscode/src/test/suite/ruby/chruby.test.ts @@ -12,6 +12,7 @@ import { Chruby } from "../../../ruby/chruby"; import { WorkspaceChannel } from "../../../workspaceChannel"; import { LOG_CHANNEL } from "../../../common"; import { RUBY_VERSION } from "../../rubyVersion"; +import { ActivationResult } from "../../../ruby/versionManager"; const [major, minor, _patch] = RUBY_VERSION.split("."); const VERSION_REGEX = `${major}\\.${minor}\\.\\d+`; @@ -91,12 +92,8 @@ suite("Chruby", () => { vscode.Uri.file(path.join(rootPath, "opt", "rubies")), ]; - const { env, version, yjit } = await chruby.activate(); - - assert.match(env.GEM_PATH!, new RegExp(`ruby/${VERSION_REGEX}`)); - assert.match(env.GEM_PATH!, new RegExp(`lib/ruby/gems/${VERSION_REGEX}`)); - assert.strictEqual(version, RUBY_VERSION); - assert.notStrictEqual(yjit, undefined); + const result = await chruby.activate(); + assertActivatedRuby(result); }); test("Finds Ruby when .ruby-version is inside on parent directories", async () => { @@ -107,12 +104,8 @@ suite("Chruby", () => { vscode.Uri.file(path.join(rootPath, "opt", "rubies")), ]; - const { env, version, yjit } = await chruby.activate(); - - assert.match(env.GEM_PATH!, new RegExp(`ruby/${VERSION_REGEX}`)); - assert.match(env.GEM_PATH!, new RegExp(`lib/ruby/gems/${VERSION_REGEX}`)); - assert.strictEqual(version, RUBY_VERSION); - assert.notStrictEqual(yjit, undefined); + const result = await chruby.activate(); + assertActivatedRuby(result); }); test("Considers any version with a suffix to be the latest", async () => { @@ -238,12 +231,8 @@ suite("Chruby", () => { const chruby = new Chruby(workspaceFolder, outputChannel, async () => {}); configStub.restore(); - const { env, version, yjit } = await chruby.activate(); - - assert.match(env.GEM_PATH!, new RegExp(`ruby/${VERSION_REGEX}`)); - assert.match(env.GEM_PATH!, new RegExp(`lib/ruby/gems/${VERSION_REGEX}`)); - assert.strictEqual(version, RUBY_VERSION); - assert.notStrictEqual(yjit, undefined); + const result = await chruby.activate(); + assertActivatedRuby(result); }); test("Finds Ruby when .ruby-version omits patch", async () => { @@ -264,12 +253,8 @@ suite("Chruby", () => { vscode.Uri.file(path.join(rootPath, "opt", "rubies")), ]; - const { env, version, yjit } = await chruby.activate(); - - assert.match(env.GEM_PATH!, new RegExp(`ruby/${VERSION_REGEX}`)); - assert.match(env.GEM_PATH!, new RegExp(`lib/ruby/gems/${VERSION_REGEX}`)); - assert.strictEqual(version, RUBY_VERSION); - assert.notStrictEqual(yjit, undefined); + const result = await chruby.activate(); + assertActivatedRuby(result); fs.rmSync(path.join(rootPath, "opt", "rubies", `${major}.${minor}.0`), { recursive: true, @@ -296,12 +281,8 @@ suite("Chruby", () => { vscode.Uri.file(path.join(rootPath, "opt", "rubies")), ]; - const { env, version, yjit } = await chruby.activate(); - - assert.match(env.GEM_PATH!, new RegExp(`ruby/${VERSION_REGEX}`)); - assert.match(env.GEM_PATH!, new RegExp(`lib/ruby/gems/${VERSION_REGEX}`)); - assert.strictEqual(version, RUBY_VERSION); - assert.notStrictEqual(yjit, undefined); + const result = await chruby.activate(); + assertActivatedRuby(result); }); test("Uses latest Ruby as a fallback if no .ruby-version is found", async () => { @@ -310,12 +291,8 @@ suite("Chruby", () => { vscode.Uri.file(path.join(rootPath, "opt", "rubies")), ]; - const { env, version, yjit } = await chruby.activate(); - - assert.match(env.GEM_PATH!, new RegExp(`ruby/${VERSION_REGEX}`)); - assert.match(env.GEM_PATH!, new RegExp(`lib/ruby/gems/${VERSION_REGEX}`)); - assert.strictEqual(version, RUBY_VERSION); - assert.notStrictEqual(yjit, undefined); + const result = await chruby.activate(); + assertActivatedRuby(result); }).timeout(20000); test("Doesn't try to fallback to latest version if there's a Gemfile with ruby constraints", async () => { @@ -330,4 +307,52 @@ suite("Chruby", () => { return chruby.activate(); }); }); + + test("Uses closest Ruby if the version specified in .ruby-version is not installed (patch difference)", async () => { + fs.writeFileSync(path.join(workspacePath, ".ruby-version"), "ruby '3.3.3'"); + + const chruby = new Chruby(workspaceFolder, outputChannel, async () => {}); + chruby.rubyInstallationUris = [ + vscode.Uri.file(path.join(rootPath, "opt", "rubies")), + ]; + + const result = await chruby.activate(); + assertActivatedRuby(result); + }).timeout(20000); + + test("Uses closest Ruby if the version specified in .ruby-version is not installed (minor difference)", async () => { + fs.writeFileSync(path.join(workspacePath, ".ruby-version"), "ruby '3.2.0'"); + + const chruby = new Chruby(workspaceFolder, outputChannel, async () => {}); + chruby.rubyInstallationUris = [ + vscode.Uri.file(path.join(rootPath, "opt", "rubies")), + ]; + + const result = await chruby.activate(); + assertActivatedRuby(result); + }).timeout(20000); + + test("Uses closest Ruby if the version specified in .ruby-version is not installed (previews)", async () => { + fs.writeFileSync( + path.join(workspacePath, ".ruby-version"), + "ruby '3.4.0-preview1'", + ); + + const chruby = new Chruby(workspaceFolder, outputChannel, async () => {}); + chruby.rubyInstallationUris = [ + vscode.Uri.file(path.join(rootPath, "opt", "rubies")), + ]; + + const result = await chruby.activate(); + assertActivatedRuby(result); + }).timeout(20000); + + function assertActivatedRuby(activationResult: ActivationResult) { + const { env, version, yjit } = activationResult; + + assert.match(env.GEM_PATH!, new RegExp(`ruby/${VERSION_REGEX}`)); + assert.match(env.GEM_PATH!, new RegExp(`lib/ruby/gems/${VERSION_REGEX}`)); + assert.strictEqual(version, RUBY_VERSION); + assert.notStrictEqual(yjit, undefined); + } });