From ed9558f9286304f775214a0e8ae56975b3c1b0a0 Mon Sep 17 00:00:00 2001 From: Matheus Cumpian Date: Wed, 31 Dec 2025 00:00:40 -0300 Subject: [PATCH 1/2] add support for rv version manager --- jekyll/version-managers.markdown | 6 ++ vscode/README.md | 1 + vscode/package.json | 5 + vscode/src/ruby.ts | 15 ++- vscode/src/ruby/rv.ts | 46 +++++++++ vscode/src/test/suite/ruby/rv.test.ts | 129 ++++++++++++++++++++++++++ 6 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 vscode/src/ruby/rv.ts create mode 100644 vscode/src/test/suite/ruby/rv.test.ts diff --git a/jekyll/version-managers.markdown b/jekyll/version-managers.markdown index ecaefc6c2c..ced9e56a53 100644 --- a/jekyll/version-managers.markdown +++ b/jekyll/version-managers.markdown @@ -31,6 +31,12 @@ Ensure Mise is up-to-date: https://mise.jdx.dev/faq.html#mise-is-failing-or-not- Ensure RVM is up-to-date: https://rvm.io/rvm/upgrading +## rv + +Ensure rv 0.3.1 or later is installed. The extension automatically detects the Ruby version from a `.ruby-version` or `.tool-versions` file in the project. + +Learn more about rv: https://github.com/spinel-coop/rv + ## Custom activation If you're using a different version manager that's not supported by this extension or if you're manually inserting the Ruby diff --git a/vscode/README.md b/vscode/README.md index 9ec7528581..62fe5400b6 100644 --- a/vscode/README.md +++ b/vscode/README.md @@ -105,6 +105,7 @@ by clicking `Change version manager` in the language status center or by changin // "asdf" // "chruby" // "rbenv" +// "rv" // "rvm" // "shadowenv" // "mise" diff --git a/vscode/package.json b/vscode/package.json index e444302840..74c17ff5bc 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -388,6 +388,7 @@ "none", "rbenv", "rvm", + "rv", "shadowenv", "mise", "custom" @@ -406,6 +407,10 @@ "description": "The path to the rbenv executable, if not installed on one of the standard locations", "type": "string" }, + "rvExecutablePath": { + "description": "The path to the rv executable, if not installed on one of the standard locations", + "type": "string" + }, "chrubyRubies": { "description": "An array of extra directories to search for Ruby installations when using chruby. Equivalent to the RUBIES environment variable", "type": "array" diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index abf45d180d..940285825c 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -15,6 +15,7 @@ import { Rvm } from "./ruby/rvm"; import { None } from "./ruby/none"; import { Custom } from "./ruby/custom"; import { Asdf } from "./ruby/asdf"; +import { Rv } from "./ruby/rv"; async function detectMise() { const possiblePaths = [ @@ -44,6 +45,7 @@ export enum ManagerIdentifier { Shadowenv = "shadowenv", Mise = "mise", RubyInstaller = "rubyInstaller", + Rv = "rv", None = "none", Custom = "custom", } @@ -313,6 +315,11 @@ export class Ruby implements RubyInterface { new Mise(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), ); break; + case ManagerIdentifier.Rv: + await this.runActivation( + new Rv(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), + ); + break; case ManagerIdentifier.RubyInstaller: await this.runActivation( new RubyInstaller(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), @@ -362,7 +369,13 @@ export class Ruby implements RubyInterface { // If .shadowenv.d doesn't exist, then we check the other version managers } - const managers = [ManagerIdentifier.Chruby, ManagerIdentifier.Rbenv, ManagerIdentifier.Rvm, ManagerIdentifier.Asdf]; + const managers = [ + ManagerIdentifier.Chruby, + ManagerIdentifier.Rbenv, + ManagerIdentifier.Rvm, + ManagerIdentifier.Asdf, + ManagerIdentifier.Rv, + ]; for (const tool of managers) { const exists = await this.toolExists(tool); diff --git a/vscode/src/ruby/rv.ts b/vscode/src/ruby/rv.ts new file mode 100644 index 0000000000..312db15b49 --- /dev/null +++ b/vscode/src/ruby/rv.ts @@ -0,0 +1,46 @@ +import * as vscode from "vscode"; + +import { VersionManager, ActivationResult } from "./versionManager"; + +// Manage your Ruby environment with rv +// +// Learn more: https://github.com/spinel-coop/rv +export class Rv extends VersionManager { + async activate(): Promise { + const rvExec = await this.findRv(); + const parsedResult = await this.runEnvActivationScript(`${rvExec} ruby run --`); + + return { + env: { ...process.env, ...parsedResult.env }, + yjit: parsedResult.yjit, + version: parsedResult.version, + gemPath: parsedResult.gemPath, + }; + } + + private async findRv(): Promise { + const config = vscode.workspace.getConfiguration("rubyLsp"); + const configuredRvPath = config.get("rubyVersionManager.rvExecutablePath"); + + if (configuredRvPath) { + return this.ensureRvExistsAt(configuredRvPath); + } else { + const possiblePaths = [ + vscode.Uri.file("/home/linuxbrew/.linuxbrew/bin"), + vscode.Uri.file("/usr/local/bin"), + vscode.Uri.file("/opt/homebrew/bin"), + vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "bin"), + ]; + return this.findExec(possiblePaths, "rv"); + } + } + + private async ensureRvExistsAt(path: string): Promise { + try { + await vscode.workspace.fs.stat(vscode.Uri.file(path)); + return path; + } catch (_error: any) { + throw new Error(`The Ruby LSP version manager is configured to be rv, but ${path} does not exist`); + } + } +} diff --git a/vscode/src/test/suite/ruby/rv.test.ts b/vscode/src/test/suite/ruby/rv.test.ts new file mode 100644 index 0000000000..9eb661b176 --- /dev/null +++ b/vscode/src/test/suite/ruby/rv.test.ts @@ -0,0 +1,129 @@ +import assert from "assert"; +import path from "path"; +import os from "os"; +import fs from "fs"; + +import * as vscode from "vscode"; +import sinon from "sinon"; +import { afterEach, beforeEach } from "mocha"; + +import { Rv } from "../../../ruby/rv"; +import { WorkspaceChannel } from "../../../workspaceChannel"; +import * as common from "../../../common"; +import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../../../ruby/versionManager"; +import { createContext, FakeContext } from "../helpers"; + +suite("Rv", () => { + if (os.platform() === "win32") { + // eslint-disable-next-line no-console + console.log("Skipping Rv tests on Windows"); + return; + } + + let activationPath: vscode.Uri; + let sandbox: sinon.SinonSandbox; + let context: FakeContext; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + context = createContext(); + activationPath = vscode.Uri.joinPath(context.extensionUri, "activation.rb"); + }); + + afterEach(() => { + sandbox.restore(); + context.dispose(); + }); + + test("Activates with auto-detected version", async () => { + const workspacePath = process.env.PWD!; + const workspaceFolder = { + uri: vscode.Uri.from({ scheme: "file", path: workspacePath }), + name: path.basename(workspacePath), + index: 0, + }; + const outputChannel = new WorkspaceChannel("fake", common.LOG_CHANNEL); + + const rv = new Rv(workspaceFolder, outputChannel, context, async () => {}); + + const envStub = ["3.4.8", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR); + + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + + // Stub findRv to return the executable path + sandbox.stub(rv, "findRv" as any).resolves("rv"); + + const { env, version, yjit } = await rv.activate(); + + assert.ok( + execStub.calledOnceWithExactly(`rv ruby run -- -EUTF-8:UTF-8 '${activationPath.fsPath}'`, { + cwd: workspacePath, + shell: vscode.env.shell, + + env: process.env, + encoding: "utf-8", + }), + ); + + assert.strictEqual(version, "3.4.8"); + assert.strictEqual(yjit, true); + assert.strictEqual(env.ANY, "true"); + }); + + test("Allows configuring where rv is installed", async () => { + const workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const workspaceFolder = { + uri: vscode.Uri.from({ scheme: "file", path: workspacePath }), + name: path.basename(workspacePath), + index: 0, + }; + const outputChannel = new WorkspaceChannel("fake", common.LOG_CHANNEL); + + const rv = new Rv(workspaceFolder, outputChannel, context, async () => {}); + + const envStub = ["3.4.8", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR); + + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + + const rvPath = path.join(workspacePath, "rv"); + fs.writeFileSync(rvPath, "fakeRvBinary"); + + // Stub findRv to return the configured executable path + sandbox.stub(rv, "findRv" as any).resolves(rvPath); + + const configStub = sinon.stub(vscode.workspace, "getConfiguration").returns({ + get: (name: string) => { + if (name === "rubyVersionManager.rvExecutablePath") { + return rvPath; + } + return ""; + }, + } as any); + + const { env, version, yjit } = await rv.activate(); + + assert.ok( + execStub.calledOnceWithExactly(`${rvPath} ruby run -- -EUTF-8:UTF-8 '${activationPath.fsPath}'`, { + cwd: workspacePath, + shell: vscode.env.shell, + + env: process.env, + encoding: "utf-8", + }), + ); + + assert.strictEqual(version, "3.4.8"); + assert.strictEqual(yjit, true); + assert.deepStrictEqual(env.ANY, "true"); + + execStub.restore(); + configStub.restore(); + fs.rmSync(workspacePath, { recursive: true, force: true }); + }); +}); From 4dbca638f692dbc78814875d18f4c63a7b58d500 Mon Sep 17 00:00:00 2001 From: Matheus Cumpian Date: Sat, 10 Jan 2026 19:18:23 -0300 Subject: [PATCH 2/2] fix: Consistent path construction in rv.ts --- vscode/src/ruby/rv.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/vscode/src/ruby/rv.ts b/vscode/src/ruby/rv.ts index 312db15b49..41f5828ab9 100644 --- a/vscode/src/ruby/rv.ts +++ b/vscode/src/ruby/rv.ts @@ -26,9 +26,9 @@ export class Rv extends VersionManager { return this.ensureRvExistsAt(configuredRvPath); } else { const possiblePaths = [ - vscode.Uri.file("/home/linuxbrew/.linuxbrew/bin"), - vscode.Uri.file("/usr/local/bin"), - vscode.Uri.file("/opt/homebrew/bin"), + vscode.Uri.joinPath(vscode.Uri.file("/"), "home", "linuxbrew", ".linuxbrew", "bin"), + vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "local", "bin"), + vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "homebrew", "bin"), vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "bin"), ]; return this.findExec(possiblePaths, "rv");