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
  • Loading branch information
vinistock committed Nov 26, 2024
1 parent 23d8048 commit e7e02be
Show file tree
Hide file tree
Showing 2 changed files with 140 additions and 18 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 RubyCancellationError 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 fallback 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 RubyCancellationError) {
// 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 - preferrably 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 RubyCancellationError) {
// 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 RubyCancellationError();
}

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
32 changes: 32 additions & 0 deletions vscode/src/test/suite/ruby/chruby.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,4 +318,36 @@ 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 { 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);
}).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 { 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);
}).timeout(20000);
});

0 comments on commit e7e02be

Please sign in to comment.