diff --git a/vscode/src/ruby/chruby.ts b/vscode/src/ruby/chruby.ts index 81a8d9915..b170dd7a8 100644 --- a/vscode/src/ruby/chruby.ts +++ b/vscode/src/ruby/chruby.ts @@ -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 { @@ -45,8 +47,26 @@ export class Chruby extends VersionManager { } async activate(): Promise { - 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}`, ); @@ -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 { + private async discoverRubyVersion(): Promise { let uri = this.bundleUri; const root = path.parse(uri.fsPath).root; let version: string; @@ -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((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 = + /((?[A-Za-z]+)-)?(?\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 diff --git a/vscode/src/test/suite/ruby/chruby.test.ts b/vscode/src/test/suite/ruby/chruby.test.ts index b997caba2..6ee5a78e8 100644 --- a/vscode/src/test/suite/ruby/chruby.test.ts +++ b/vscode/src/test/suite/ruby/chruby.test.ts @@ -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"; @@ -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"), { @@ -67,7 +67,7 @@ suite("Chruby", () => { outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL); }); - after(() => { + afterEach(() => { fs.rmSync(rootPath, { recursive: true, force: true }); }); @@ -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(); + }); + }); });