Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fallback to closest Ruby if we can't find an installation for the requested version #2912

Merged
merged 1 commit into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 () => {});
vinistock marked this conversation as resolved.
Show resolved Hide resolved
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);
vinistock marked this conversation as resolved.
Show resolved Hide resolved

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);
}
});