Skip to content

Commit

Permalink
Fallback to latest known Ruby if no .ruby-version is found (#2836)
Browse files Browse the repository at this point in the history
### Motivation

Completes a significant part of handling missing `.ruby-version` for #2793

If the user doesn't have a `.ruby-version` in their project or in any parent directories, then we can try to fallback to the latest known Ruby available.

**Note**: this PR doesn't handle the aspect of having a `.ruby-version` configured to a Ruby that's not installed. I'll follow up with that later.

### Implementation

To avoid having this behaviour be surprising, I used a progress notification with a 5 second delay warning the user that we are going to try falling back to the latest known Ruby. If they don't do anything, we search for the Ruby installation and launch.

If the user clicks cancel, then we stop everything and offer them 3 options:

1. Create a `.ruby-version` file in a parent directory. Here we use a quick pick to list all known rubies and create the file for them using what they select
2. Manually configure a global fallback Ruby installation for the LSP
3. Shutdown

### Automated Tests

Added automated tests for two scenarios. I haven't figured out if it's possible to trigger the cancellation in a test even with a stub, so I failed to create tests for those cases.

If you have an idea about how to fake the cancellation of the progress notification, please let me know!
  • Loading branch information
vinistock authored Nov 22, 2024
1 parent c3ead8a commit 6e5501d
Show file tree
Hide file tree
Showing 3 changed files with 238 additions and 8 deletions.
2 changes: 1 addition & 1 deletion vscode/src/ruby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export class Ruby implements RubyInterface {

async manuallySelectRuby() {
const manualSelection = await vscode.window.showInformationMessage(
"Configure global fallback or workspace specific Ruby?",
"Configure global or workspace specific fallback for the Ruby LSP?",
"global",
"workspace",
"clear previous workspace selection",
Expand Down
211 changes: 207 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,183 @@ 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 this.rubyVersionError();
}

const fallback = await vscode.window.withProgress(
{
title:
"No .ruby-version found. Trying to fall back to latest installed Ruby in 10 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 10 seconds before falling back so that they are aware of what's happening
await new Promise<void>((resolve) => {
setTimeout(resolve, 10000);

// If the user cancels the fallback, resolve immediately so that they don't have to wait 10 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 for the system or for the Ruby LSP only`,
"System",
"Ruby LSP only",
);

if (answer === "System") {
await this.createParentRubyVersionFile();
} else if (answer === "Ruby LSP only") {
await this.manuallySelectRuby();
}

throw this.rubyVersionError();
}

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 this.rubyVersionError();
}

const targetDirectory = await vscode.window.showOpenDialog({
defaultUri: vscode.Uri.file(os.homedir()),
openLabel: "Add fallback in this directory",
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
title: "Select the directory to create the .ruby-version fallback in",
});

if (!targetDirectory) {
throw this.rubyVersionError();
}

await vscode.workspace.fs.writeFile(
vscode.Uri.joinPath(targetDirectory[0], ".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 Expand Up @@ -197,4 +393,11 @@ export class Chruby extends VersionManager {

return { defaultGems, gemHome, yjit: yjit === "true", version };
}

private rubyVersionError() {
return 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`,
);
}
}
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(20000);

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 6e5501d

Please sign in to comment.