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 latest known Ruby if no .ruby-version is found #2836

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
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);
vinistock marked this conversation as resolved.
Show resolved Hide resolved

// 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();
}
vinistock marked this conversation as resolved.
Show resolved Hide resolved

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