Skip to content

Commit 6e5501d

Browse files
authored
Fallback to latest known Ruby if no .ruby-version is found (#2836)
### 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!
1 parent c3ead8a commit 6e5501d

File tree

3 files changed

+238
-8
lines changed

3 files changed

+238
-8
lines changed

vscode/src/ruby.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export class Ruby implements RubyInterface {
173173

174174
async manuallySelectRuby() {
175175
const manualSelection = await vscode.window.showInformationMessage(
176-
"Configure global fallback or workspace specific Ruby?",
176+
"Configure global or workspace specific fallback for the Ruby LSP?",
177177
"global",
178178
"workspace",
179179
"clear previous workspace selection",

vscode/src/ruby/chruby.ts

Lines changed: 207 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ interface RubyVersion {
1717
version: string;
1818
}
1919

20+
class RubyVersionCancellationError extends Error {}
21+
2022
// A tool to change the current Ruby version
2123
// Learn more: https://github.com/postmodern/chruby
2224
export class Chruby extends VersionManager {
@@ -45,8 +47,26 @@ export class Chruby extends VersionManager {
4547
}
4648

4749
async activate(): Promise<ActivationResult> {
48-
const versionInfo = await this.discoverRubyVersion();
49-
const rubyUri = await this.findRubyUri(versionInfo);
50+
let versionInfo = await this.discoverRubyVersion();
51+
let rubyUri: vscode.Uri;
52+
53+
if (versionInfo) {
54+
rubyUri = await this.findRubyUri(versionInfo);
55+
} else {
56+
try {
57+
const fallback = await this.fallbackToLatestRuby();
58+
versionInfo = fallback.rubyVersion;
59+
rubyUri = fallback.uri;
60+
} catch (error: any) {
61+
if (error instanceof RubyVersionCancellationError) {
62+
// Try to re-activate if the user has configured a fallback during cancellation
63+
return this.activate();
64+
}
65+
66+
throw error;
67+
}
68+
}
69+
5070
this.outputChannel.info(
5171
`Discovered Ruby installation at ${rubyUri.fsPath}`,
5272
);
@@ -118,7 +138,7 @@ export class Chruby extends VersionManager {
118138
}
119139

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

159-
throw new Error("No .ruby-version file was found");
179+
return undefined;
180+
}
181+
182+
private async fallbackToLatestRuby() {
183+
let gemfileContents;
184+
185+
try {
186+
gemfileContents = await vscode.workspace.fs.readFile(
187+
vscode.Uri.joinPath(this.workspaceFolder.uri, "Gemfile"),
188+
);
189+
} catch (error: any) {
190+
// The Gemfile doesn't exist
191+
}
192+
193+
// If the Gemfile includes ruby version restrictions, then trying to fall back to latest Ruby may lead to errors
194+
if (
195+
gemfileContents &&
196+
/^ruby(\s|\()("|')[\d.]+/.test(gemfileContents.toString())
197+
) {
198+
throw this.rubyVersionError();
199+
}
200+
201+
const fallback = await vscode.window.withProgress(
202+
{
203+
title:
204+
"No .ruby-version found. Trying to fall back to latest installed Ruby in 10 seconds",
205+
location: vscode.ProgressLocation.Notification,
206+
cancellable: true,
207+
},
208+
async (progress, token) => {
209+
progress.report({
210+
message:
211+
"You can create a .ruby-version file in a parent directory to configure a fallback",
212+
});
213+
214+
// If they don't cancel, we wait 10 seconds before falling back so that they are aware of what's happening
215+
await new Promise<void>((resolve) => {
216+
setTimeout(resolve, 10000);
217+
218+
// If the user cancels the fallback, resolve immediately so that they don't have to wait 10 seconds
219+
token.onCancellationRequested(() => {
220+
resolve();
221+
});
222+
});
223+
224+
if (token.isCancellationRequested) {
225+
await this.handleCancelledFallback();
226+
227+
// We throw this error to be able to catch and re-run activation after the user has configured a fallback
228+
throw new RubyVersionCancellationError();
229+
}
230+
231+
const fallback = await this.findFallbackRuby();
232+
233+
if (!fallback) {
234+
throw new Error("Cannot find any Ruby installations");
235+
}
236+
237+
return fallback;
238+
},
239+
);
240+
241+
return fallback;
242+
}
243+
244+
private async handleCancelledFallback() {
245+
const answer = await vscode.window.showInformationMessage(
246+
`The Ruby LSP requires a Ruby version to launch.
247+
You can define a fallback for the system or for the Ruby LSP only`,
248+
"System",
249+
"Ruby LSP only",
250+
);
251+
252+
if (answer === "System") {
253+
await this.createParentRubyVersionFile();
254+
} else if (answer === "Ruby LSP only") {
255+
await this.manuallySelectRuby();
256+
}
257+
258+
throw this.rubyVersionError();
259+
}
260+
261+
private async createParentRubyVersionFile() {
262+
const items: vscode.QuickPickItem[] = [];
263+
264+
for (const uri of this.rubyInstallationUris) {
265+
let directories;
266+
267+
try {
268+
directories = (await vscode.workspace.fs.readDirectory(uri)).sort(
269+
(left, right) => right[0].localeCompare(left[0]),
270+
);
271+
272+
directories.forEach((directory) => {
273+
items.push({
274+
label: directory[0],
275+
});
276+
});
277+
} catch (error: any) {
278+
continue;
279+
}
280+
}
281+
282+
const answer = await vscode.window.showQuickPick(items, {
283+
title: "Select a Ruby version to use as fallback",
284+
ignoreFocusOut: true,
285+
});
286+
287+
if (!answer) {
288+
throw this.rubyVersionError();
289+
}
290+
291+
const targetDirectory = await vscode.window.showOpenDialog({
292+
defaultUri: vscode.Uri.file(os.homedir()),
293+
openLabel: "Add fallback in this directory",
294+
canSelectFiles: false,
295+
canSelectFolders: true,
296+
canSelectMany: false,
297+
title: "Select the directory to create the .ruby-version fallback in",
298+
});
299+
300+
if (!targetDirectory) {
301+
throw this.rubyVersionError();
302+
}
303+
304+
await vscode.workspace.fs.writeFile(
305+
vscode.Uri.joinPath(targetDirectory[0], ".ruby-version"),
306+
Buffer.from(answer.label),
307+
);
308+
}
309+
310+
private async findFallbackRuby(): Promise<
311+
{ uri: vscode.Uri; rubyVersion: RubyVersion } | undefined
312+
> {
313+
for (const uri of this.rubyInstallationUris) {
314+
let directories;
315+
316+
try {
317+
directories = (await vscode.workspace.fs.readDirectory(uri)).sort(
318+
(left, right) => right[0].localeCompare(left[0]),
319+
);
320+
321+
let groups;
322+
let targetDirectory;
323+
324+
for (const directory of directories) {
325+
const match =
326+
/((?<engine>[A-Za-z]+)-)?(?<version>\d+\.\d+(\.\d+)?(-[A-Za-z0-9]+)?)/.exec(
327+
directory[0],
328+
);
329+
330+
if (match?.groups) {
331+
groups = match.groups;
332+
targetDirectory = directory;
333+
break;
334+
}
335+
}
336+
337+
if (targetDirectory) {
338+
return {
339+
uri: vscode.Uri.joinPath(uri, targetDirectory[0], "bin", "ruby"),
340+
rubyVersion: {
341+
engine: groups!.engine,
342+
version: groups!.version,
343+
},
344+
};
345+
}
346+
} catch (error: any) {
347+
// If the directory doesn't exist, keep searching
348+
this.outputChannel.debug(
349+
`Tried searching for Ruby installation in ${uri.fsPath} but it doesn't exist`,
350+
);
351+
continue;
352+
}
353+
}
354+
355+
return undefined;
160356
}
161357

162358
// Run the activation script using the Ruby installation we found so that we can discover gem paths
@@ -197,4 +393,11 @@ export class Chruby extends VersionManager {
197393

198394
return { defaultGems, gemHome, yjit: yjit === "true", version };
199395
}
396+
397+
private rubyVersionError() {
398+
return new Error(
399+
`Cannot find .ruby-version file. Please specify the Ruby version in a
400+
.ruby-version either in ${this.bundleUri.fsPath} or in a parent directory`,
401+
);
402+
}
200403
}

vscode/src/test/suite/ruby/chruby.test.ts

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import assert from "assert";
44
import path from "path";
55
import os from "os";
66

7-
import { before, after } from "mocha";
7+
import { beforeEach, afterEach } from "mocha";
88
import * as vscode from "vscode";
99
import sinon from "sinon";
1010

@@ -45,7 +45,7 @@ suite("Chruby", () => {
4545
let workspaceFolder: vscode.WorkspaceFolder;
4646
let outputChannel: WorkspaceChannel;
4747

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

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

70-
after(() => {
70+
afterEach(() => {
7171
fs.rmSync(rootPath, { recursive: true, force: true });
7272
});
7373

@@ -291,4 +291,31 @@ suite("Chruby", () => {
291291
assert.strictEqual(version, RUBY_VERSION);
292292
assert.notStrictEqual(yjit, undefined);
293293
});
294+
295+
test("Uses latest Ruby as a fallback if no .ruby-version is found", async () => {
296+
const chruby = new Chruby(workspaceFolder, outputChannel, async () => {});
297+
chruby.rubyInstallationUris = [
298+
vscode.Uri.file(path.join(rootPath, "opt", "rubies")),
299+
];
300+
301+
const { env, version, yjit } = await chruby.activate();
302+
303+
assert.match(env.GEM_PATH!, new RegExp(`ruby/${VERSION_REGEX}`));
304+
assert.match(env.GEM_PATH!, new RegExp(`lib/ruby/gems/${VERSION_REGEX}`));
305+
assert.strictEqual(version, RUBY_VERSION);
306+
assert.notStrictEqual(yjit, undefined);
307+
}).timeout(20000);
308+
309+
test("Doesn't try to fallback to latest version if there's a Gemfile with ruby constraints", async () => {
310+
fs.writeFileSync(path.join(workspacePath, "Gemfile"), "ruby '3.3.0'");
311+
312+
const chruby = new Chruby(workspaceFolder, outputChannel, async () => {});
313+
chruby.rubyInstallationUris = [
314+
vscode.Uri.file(path.join(rootPath, "opt", "rubies")),
315+
];
316+
317+
await assert.rejects(() => {
318+
return chruby.activate();
319+
});
320+
});
294321
});

0 commit comments

Comments
 (0)