Skip to content

Commit

Permalink
Fallback to closest Ruby if we can't find an installation for the req…
Browse files Browse the repository at this point in the history
…uested 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.
  • Loading branch information
vinistock authored Nov 29, 2024
1 parent bfae37d commit ff73dc9
Show file tree
Hide file tree
Showing 2 changed files with 169 additions and 54 deletions.
126 changes: 108 additions & 18 deletions vscode/src/ruby/chruby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -48,12 +48,14 @@ export class Chruby extends VersionManager {

async activate(): Promise<ActivationResult> {
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",
Expand All @@ -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(
Expand Down Expand Up @@ -106,7 +133,9 @@ export class Chruby extends VersionManager {
}

// Returns the full URI to the Ruby executable
protected async findRubyUri(rubyVersion: RubyVersion): Promise<vscode.Uri> {
protected async findRubyUri(
rubyVersion: RubyVersion,
): Promise<vscode.Uri | undefined> {
const possibleVersionNames = rubyVersion.engine
? [`${rubyVersion.engine}-${rubyVersion.version}`, rubyVersion.version]
: [rubyVersion.version, `ruby-${rubyVersion.version}`];
Expand Down Expand Up @@ -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 =
/((?<engine>[A-Za-z]+)-)?(?<version>\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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down
97 changes: 61 additions & 36 deletions vscode/src/test/suite/ruby/chruby.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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+`;
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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,
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand All @@ -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);
}
});

0 comments on commit ff73dc9

Please sign in to comment.