Skip to content

Commit

Permalink
Fallback to latest known Ruby if no .ruby-version is found
Browse files Browse the repository at this point in the history
  • Loading branch information
vinistock committed Nov 19, 2024
1 parent 04bc155 commit 9a4bd56
Show file tree
Hide file tree
Showing 2 changed files with 229 additions and 7 deletions.
203 changes: 199 additions & 4 deletions vscode/src/ruby/chruby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ interface RubyVersion {
version: string;
}

class RubyVersionCancellationError extends Error {}

// A tool to change the current Ruby version
// Learn more: https://github.com/postmodern/chruby
export class Chruby extends VersionManager {
Expand Down Expand Up @@ -45,8 +47,26 @@ export class Chruby extends VersionManager {
}

async activate(): Promise<ActivationResult> {
const versionInfo = await this.discoverRubyVersion();
const rubyUri = await this.findRubyUri(versionInfo);
let versionInfo = await this.discoverRubyVersion();
let rubyUri: vscode.Uri;

if (versionInfo) {
rubyUri = await this.findRubyUri(versionInfo);
} else {
try {
const fallback = await this.fallbackToLatestRuby();
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();
}

throw error;
}
}

this.outputChannel.info(
`Discovered Ruby installation at ${rubyUri.fsPath}`,
);
Expand Down Expand Up @@ -118,7 +138,7 @@ export class Chruby extends VersionManager {
}

// Returns the Ruby version information including version and engine. E.g.: ruby-3.3.0, truffleruby-21.3.0
private async discoverRubyVersion(): Promise<RubyVersion> {
private async discoverRubyVersion(): Promise<RubyVersion | undefined> {
let uri = this.bundleUri;
const root = path.parse(uri.fsPath).root;
let version: string;
Expand Down Expand Up @@ -156,7 +176,182 @@ export class Chruby extends VersionManager {
return { engine: match.groups.engine, version: match.groups.version };
}

throw new Error("No .ruby-version file was found");
return undefined;
}

private async fallbackToLatestRuby() {
let gemfileContents;

try {
gemfileContents = await vscode.workspace.fs.readFile(
vscode.Uri.joinPath(this.workspaceFolder.uri, "Gemfile"),
);
} catch (error: any) {
// The Gemfile doesn't exist
}

// If the Gemfile includes ruby version restrictions, then trying to fall back to latest Ruby may lead to errors
if (
gemfileContents &&
/^ruby(\s|\()("|')[\d.]+/.test(gemfileContents.toString())
) {
throw new Error(
`Cannot find .ruby-version file. Please specify the Ruby version in a
.ruby-version either in ${this.bundleUri.fsPath} or in a parent directory`,
);
}

const fallback = await vscode.window.withProgress(
{
title:
"No .ruby-version found. Trying to fall back to latest Ruby in 5 seconds",
location: vscode.ProgressLocation.Notification,
cancellable: true,
},
async (progress, token) => {
progress.report({
message:
"You can create a .ruby-version file in a parent directory to configure a fallback",
});

// If they don't cancel, we wait 5 seconds before falling back so that they are aware of what's happening
await new Promise<void>((resolve) => {
setTimeout(resolve, 5000);

// If the user cancels the fallback, resolve immediately so that they don't have to wait 5 seconds
token.onCancellationRequested(() => {
resolve();
});
});

if (token.isCancellationRequested) {
await this.handleCancelledFallback();

// We throw this error to be able to catch and re-run activation after the user has configured a fallback
throw new RubyVersionCancellationError();
}

const fallback = await this.findFallbackRuby();

if (!fallback) {
throw new Error("Cannot find any Ruby installations");
}

return fallback;
},
);

return fallback;
}

private async handleCancelledFallback() {
const answer = await vscode.window.showInformationMessage(
`The Ruby LSP requires a Ruby version to launch.
You can define a fallback by adding a .ruby-version to a parent directory or by configuring a fallback`,
"Add .ruby-version to parent directory",
"Configure global fallback",
"Shutdown",
);

if (answer === "Shutdown") {
throw new Error(
`Cannot find .ruby-version file. Please specify the Ruby version in a
.ruby-version either in ${this.bundleUri.fsPath} or in a parent directory`,
);
}

if (answer === "Add .ruby-version to parent directory") {
await this.createParentRubyVersionFile();
} else if (answer === "Configure global fallback") {
await this.manuallySelectRuby();
}
}

private async createParentRubyVersionFile() {
const items: vscode.QuickPickItem[] = [];

for (const uri of this.rubyInstallationUris) {
let directories;

try {
directories = (await vscode.workspace.fs.readDirectory(uri)).sort(
(left, right) => right[0].localeCompare(left[0]),
);

directories.forEach((directory) => {
items.push({
label: directory[0],
});
});
} catch (error: any) {
continue;
}
}

const answer = await vscode.window.showQuickPick(items, {
title: "Select a Ruby version to use as fallback",
ignoreFocusOut: true,
});

if (!answer) {
throw new Error(
`Cannot find .ruby-version file. Please specify the Ruby version in a
.ruby-version either in ${this.bundleUri.fsPath} or in a parent directory`,
);
}

await vscode.workspace.fs.writeFile(
vscode.Uri.joinPath(this.bundleUri, "..", ".ruby-version"),
Buffer.from(answer.label),
);
}

private async findFallbackRuby(): Promise<
{ uri: vscode.Uri; rubyVersion: RubyVersion } | undefined
> {
for (const uri of this.rubyInstallationUris) {
let directories;

try {
directories = (await vscode.workspace.fs.readDirectory(uri)).sort(
(left, right) => right[0].localeCompare(left[0]),
);

let groups;
let targetDirectory;

for (const directory of directories) {
const match =
/((?<engine>[A-Za-z]+)-)?(?<version>\d+\.\d+(\.\d+)?(-[A-Za-z0-9]+)?)/.exec(
directory[0],
);

if (match?.groups) {
groups = match.groups;
targetDirectory = directory;
break;
}
}

if (targetDirectory) {
return {
uri: vscode.Uri.joinPath(uri, targetDirectory[0], "bin", "ruby"),
rubyVersion: {
engine: groups!.engine,
version: 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;
}
}

return undefined;
}

// Run the activation script using the Ruby installation we found so that we can discover gem paths
Expand Down
33 changes: 30 additions & 3 deletions vscode/src/test/suite/ruby/chruby.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import assert from "assert";
import path from "path";
import os from "os";

import { before, after } from "mocha";
import { beforeEach, afterEach } from "mocha";
import * as vscode from "vscode";
import sinon from "sinon";

Expand Down Expand Up @@ -45,7 +45,7 @@ suite("Chruby", () => {
let workspaceFolder: vscode.WorkspaceFolder;
let outputChannel: WorkspaceChannel;

before(() => {
beforeEach(() => {
rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-chruby-"));

fs.mkdirSync(path.join(rootPath, "opt", "rubies", RUBY_VERSION, "bin"), {
Expand All @@ -67,7 +67,7 @@ suite("Chruby", () => {
outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL);
});

after(() => {
afterEach(() => {
fs.rmSync(rootPath, { recursive: true, force: true });
});

Expand Down Expand Up @@ -291,4 +291,31 @@ suite("Chruby", () => {
assert.strictEqual(version, RUBY_VERSION);
assert.notStrictEqual(yjit, undefined);
});

test("Uses latest Ruby as a fallback if no .ruby-version is found", async () => {
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(10000);

test("Doesn't try to fallback to latest version if there's a Gemfile with ruby constraints", async () => {
fs.writeFileSync(path.join(workspacePath, "Gemfile"), "ruby '3.3.0'");

const chruby = new Chruby(workspaceFolder, outputChannel, async () => {});
chruby.rubyInstallationUris = [
vscode.Uri.file(path.join(rootPath, "opt", "rubies")),
];

await assert.rejects(() => {
return chruby.activate();
});
});
});

0 comments on commit 9a4bd56

Please sign in to comment.