From ba54f3152f789c54394589f022a5d00730ca3370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 7 Jan 2026 19:54:04 -0500 Subject: [PATCH 01/14] Remove duplicated mise detection logic by moving it into the Mise class as a static method --- vscode/src/ruby.ts | 21 +-------- vscode/src/ruby/mise.ts | 49 +++++++++++++-------- vscode/src/test/suite/ruby/mise.test.ts | 57 +++++++++++++++++++++++++ 3 files changed, 90 insertions(+), 37 deletions(-) diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index d0a6d83716..1dab12ec7c 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -17,25 +17,6 @@ import { Custom } from "./ruby/custom"; import { Asdf } from "./ruby/asdf"; import { Rv } from "./ruby/rv"; -async function detectMise() { - const possiblePaths = [ - vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".local", "bin", "mise"), - vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "homebrew", "bin", "mise"), - vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "bin", "mise"), - ]; - - for (const possiblePath of possiblePaths) { - try { - await vscode.workspace.fs.stat(possiblePath); - return true; - } catch (_error: any) { - // Continue looking - } - } - - return false; -} - export enum ManagerIdentifier { Asdf = "asdf", Auto = "auto", @@ -385,7 +366,7 @@ export class Ruby implements RubyInterface { } } - if (await detectMise()) { + if (await Mise.detect()) { this.versionManager = ManagerIdentifier.Mise; return; } diff --git a/vscode/src/ruby/mise.ts b/vscode/src/ruby/mise.ts index 2c647ed4de..91a2a2cb84 100644 --- a/vscode/src/ruby/mise.ts +++ b/vscode/src/ruby/mise.ts @@ -8,6 +8,34 @@ import { VersionManager, ActivationResult } from "./versionManager"; // // Learn more: https://github.com/jdx/mise export class Mise extends VersionManager { + // Possible mise installation paths + // + // 1. Installation from curl | sh (per mise.jdx.dev Getting Started) + // 2. Homebrew M series + // 3. Installation from `apt install mise` + private static getPossiblePaths(): vscode.Uri[] { + return [ + vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".local", "bin", "mise"), + vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "homebrew", "bin", "mise"), + vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "bin", "mise"), + ]; + } + + static async detect(): Promise { + const possiblePaths = Mise.getPossiblePaths(); + + for (const possiblePath of possiblePaths) { + try { + await vscode.workspace.fs.stat(possiblePath); + return possiblePath; + } catch (_error: any) { + // Continue looking + } + } + + return undefined; + } + async activate(): Promise { const miseUri = await this.findMiseUri(); @@ -37,26 +65,13 @@ export class Mise extends VersionManager { } } - // Possible mise installation paths - // - // 1. Installation from curl | sh (per mise.jdx.dev Getting Started) - // 2. Homebrew M series - // 3. Installation from `apt install mise` - const possiblePaths = [ - vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".local", "bin", "mise"), - vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "homebrew", "bin", "mise"), - vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "bin", "mise"), - ]; + const detectedPath = await Mise.detect(); - for (const possiblePath of possiblePaths) { - try { - await vscode.workspace.fs.stat(possiblePath); - return possiblePath; - } catch (_error: any) { - // Continue looking - } + if (detectedPath) { + return detectedPath; } + const possiblePaths = Mise.getPossiblePaths(); throw new Error( `The Ruby LSP version manager is configured to be Mise, but could not find Mise installation. Searched in ${possiblePaths.join(", ")}`, diff --git a/vscode/src/test/suite/ruby/mise.test.ts b/vscode/src/test/suite/ruby/mise.test.ts index a37d5ee0ce..a71a931ff7 100644 --- a/vscode/src/test/suite/ruby/mise.test.ts +++ b/vscode/src/test/suite/ruby/mise.test.ts @@ -127,4 +127,61 @@ suite("Mise", () => { configStub.restore(); fs.rmSync(workspacePath, { recursive: true, force: true }); }); + + test("detect returns the first found mise path", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const misePath = path.join(tempDir, "mise"); + fs.writeFileSync(misePath, "fakeMiseBinary"); + + const getPossiblePathsStub = sandbox + .stub(Mise as any, "getPossiblePaths") + .returns([vscode.Uri.file(misePath), vscode.Uri.file(path.join(tempDir, "other", "mise"))]); + + const result = await Mise.detect(); + + assert.strictEqual(result?.fsPath, vscode.Uri.file(misePath).fsPath); + + getPossiblePathsStub.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("detect returns undefined when mise is not found", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + + const getPossiblePathsStub = sandbox + .stub(Mise as any, "getPossiblePaths") + .returns([ + vscode.Uri.file(path.join(tempDir, "nonexistent1", "mise")), + vscode.Uri.file(path.join(tempDir, "nonexistent2", "mise")), + ]); + + const result = await Mise.detect(); + + assert.strictEqual(result, undefined); + + getPossiblePathsStub.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("detect checks multiple paths in order", async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const secondPath = path.join(tempDir, "second", "mise"); + fs.mkdirSync(path.dirname(secondPath), { recursive: true }); + fs.writeFileSync(secondPath, "fakeMiseBinary"); + + const getPossiblePathsStub = sandbox + .stub(Mise as any, "getPossiblePaths") + .returns([ + vscode.Uri.file(path.join(tempDir, "nonexistent", "mise")), + vscode.Uri.file(secondPath), + vscode.Uri.file(path.join(tempDir, "third", "mise")), + ]); + + const result = await Mise.detect(); + + assert.strictEqual(result?.fsPath, vscode.Uri.file(secondPath).fsPath); + + getPossiblePathsStub.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); }); From e69f12bc1e6f61282a82e97e0ad99f9f19d0b9cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 7 Jan 2026 20:05:46 -0500 Subject: [PATCH 02/14] Extract logic to detect Asdf version manager into the Asdf class --- vscode/src/ruby.ts | 30 ++----- vscode/src/ruby/asdf.ts | 102 ++++++++++++++++-------- vscode/src/ruby/mise.ts | 13 +-- vscode/src/ruby/versionManager.ts | 36 +++++++++ vscode/src/test/suite/ruby/asdf.test.ts | 10 +-- 5 files changed, 119 insertions(+), 72 deletions(-) diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index 1dab12ec7c..9937444515 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -3,7 +3,7 @@ import os from "os"; import * as vscode from "vscode"; -import { asyncExec, RubyInterface } from "./common"; +import { RubyInterface } from "./common"; import { WorkspaceChannel } from "./workspaceChannel"; import { Shadowenv, UntrustedWorkspaceError } from "./ruby/shadowenv"; import { Chruby } from "./ruby/chruby"; @@ -349,13 +349,7 @@ 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, - ManagerIdentifier.Rv, - ]; + const managers = [ManagerIdentifier.Chruby, ManagerIdentifier.Rbenv, ManagerIdentifier.Rvm, ManagerIdentifier.Rv]; for (const tool of managers) { const exists = await this.toolExists(tool); @@ -366,6 +360,11 @@ export class Ruby implements RubyInterface { } } + if (await Asdf.detect(this.workspaceFolder, this.outputChannel)) { + this.versionManager = ManagerIdentifier.Asdf; + return; + } + if (await Mise.detect()) { this.versionManager = ManagerIdentifier.Mise; return; @@ -381,20 +380,7 @@ export class Ruby implements RubyInterface { } private async toolExists(tool: string) { - try { - const shell = vscode.env.shell.replace(/(\s+)/g, "\\$1"); - const command = `${shell} -i -c '${tool} --version'`; - - this.outputChannel.info(`Checking if ${tool} is available on the path with command: ${command}`); - - await asyncExec(command, { - cwd: this.workspaceFolder.uri.fsPath, - timeout: 1000, - }); - return true; - } catch { - return false; - } + return VersionManager.toolExists(tool, this.workspaceFolder, this.outputChannel); } private async handleRubyError(message: string) { diff --git a/vscode/src/ruby/asdf.ts b/vscode/src/ruby/asdf.ts index e06ff5a34b..6ee8793ac9 100644 --- a/vscode/src/ruby/asdf.ts +++ b/vscode/src/ruby/asdf.ts @@ -4,41 +4,21 @@ import path from "path"; import * as vscode from "vscode"; import { VersionManager, ActivationResult } from "./versionManager"; +import { WorkspaceChannel } from "../workspaceChannel"; // A tool to manage multiple runtime versions with a single CLI tool // // Learn more: https://github.com/asdf-vm/asdf export class Asdf extends VersionManager { - async activate(): Promise { + private static getPossibleExecutablePaths(): vscode.Uri[] { // These directories are where we can find the ASDF executable for v0.16 and above - const possibleExecutablePaths = [ + return [ vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "homebrew", "bin"), vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "local", "bin"), ]; - - // Prefer the path configured by the user, then the ASDF scripts for versions below v0.16 and finally the - // executables for v0.16 and above - const asdfPath = - (await this.getConfiguredAsdfPath()) ?? - (await this.findAsdfInstallation()) ?? - (await this.findExec(possibleExecutablePaths, "asdf")); - - // If there's no extension name, then we are using the ASDF executable directly. If there is an extension, then it's - // a shell script and we have to source it first - const baseCommand = path.extname(asdfPath) === "" ? asdfPath : `. ${asdfPath} && asdf`; - - const parsedResult = await this.runEnvActivationScript(`${baseCommand} exec ruby`); - - return { - env: { ...process.env, ...parsedResult.env }, - yjit: parsedResult.yjit, - version: parsedResult.version, - gemPath: parsedResult.gemPath, - }; } - // Only public for testing. Finds the ASDF installation URI based on what's advertised in the ASDF documentation - async findAsdfInstallation(): Promise { + private static getPossibleScriptPaths(): vscode.Uri[] { const scriptName = path.basename(vscode.env.shell) === "fish" ? "asdf.fish" : "asdf.sh"; // Possible ASDF installation paths as described in https://asdf-vm.com/guide/getting-started.html#_3-install-asdf. @@ -47,26 +27,82 @@ export class Asdf extends VersionManager { // 2. Pacman // 3. Homebrew M series // 4. Homebrew Intel series - const possiblePaths = [ + return [ vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".asdf", scriptName), vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "asdf-vm", scriptName), vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "homebrew", "opt", "asdf", "libexec", scriptName), vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "local", "opt", "asdf", "libexec", scriptName), ]; + } - for (const possiblePath of possiblePaths) { - try { - await vscode.workspace.fs.stat(possiblePath); - return possiblePath.fsPath; - } catch (_error: any) { - // Continue looking - } + static async detect( + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + ): Promise { + // Check for v0.16+ executables first + const executablePaths = Asdf.getPossibleExecutablePaths(); + const asdfExecPaths = executablePaths.map((dir) => vscode.Uri.joinPath(dir, "asdf")); + const execResult = await VersionManager.findFirst(asdfExecPaths); + if (execResult) { + return execResult; + } + + // Check for < v0.16 scripts + const scriptResult = await VersionManager.findFirst(Asdf.getPossibleScriptPaths()); + if (scriptResult) { + return scriptResult; + } + + // check on PATH + const toolExists = await VersionManager.toolExists("asdf", workspaceFolder, outputChannel); + if (toolExists) { + return vscode.Uri.file("asdf"); } - this.outputChannel.info(`Could not find installation for ASDF < v0.16. Searched in ${possiblePaths.join(", ")}`); return undefined; } + async activate(): Promise { + // Prefer the path configured by the user, then use detect() to find ASDF + const configuredPath = await this.getConfiguredAsdfPath(); + let asdfUri: vscode.Uri | undefined; + + if (configuredPath) { + asdfUri = vscode.Uri.file(configuredPath); + } else { + asdfUri = await Asdf.detect(this.workspaceFolder, this.outputChannel); + + if (!asdfUri) { + throw new Error( + `Could not find ASDF installation. Searched in ${[ + ...Asdf.getPossibleExecutablePaths(), + ...Asdf.getPossibleScriptPaths(), + ].join(", ")}`, + ); + } + } + + let baseCommand: string; + + if (asdfUri.fsPath !== "/asdf") { + const asdfPath = asdfUri.fsPath; + // If there's no extension name, then we are using the ASDF executable directly. If there is an extension, then it's + // a shell script and we have to source it first + baseCommand = path.extname(asdfPath) === "" ? asdfPath : `. ${asdfPath} && asdf`; + } else { + baseCommand = "asdf"; + } + + const parsedResult = await this.runEnvActivationScript(`${baseCommand} exec ruby`); + + return { + env: { ...process.env, ...parsedResult.env }, + yjit: parsedResult.yjit, + version: parsedResult.version, + gemPath: parsedResult.gemPath, + }; + } + private async getConfiguredAsdfPath(): Promise { const config = vscode.workspace.getConfiguration("rubyLsp"); const asdfPath = config.get("rubyVersionManager.asdfExecutablePath"); diff --git a/vscode/src/ruby/mise.ts b/vscode/src/ruby/mise.ts index 91a2a2cb84..b211162046 100644 --- a/vscode/src/ruby/mise.ts +++ b/vscode/src/ruby/mise.ts @@ -22,18 +22,7 @@ export class Mise extends VersionManager { } static async detect(): Promise { - const possiblePaths = Mise.getPossiblePaths(); - - for (const possiblePath of possiblePaths) { - try { - await vscode.workspace.fs.stat(possiblePath); - return possiblePath; - } catch (_error: any) { - // Continue looking - } - } - - return undefined; + return VersionManager.findFirst(Mise.getPossiblePaths()); } async activate(): Promise { diff --git a/vscode/src/ruby/versionManager.ts b/vscode/src/ruby/versionManager.ts index 66c591ce7f..e37b682f6a 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -53,6 +53,42 @@ export abstract class VersionManager { // language server abstract activate(): Promise; + // Finds the first existing path from a list of possible paths + protected static async findFirst(paths: vscode.Uri[]): Promise { + for (const possiblePath of paths) { + try { + await vscode.workspace.fs.stat(possiblePath); + return possiblePath; + } catch (_error: any) { + // Continue looking + } + } + + return undefined; + } + + // Checks if a tool exists by running `tool --version` + static async toolExists( + tool: string, + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + ): Promise { + try { + const shell = vscode.env.shell.replace(/(\s+)/g, "\\$1"); + const command = `${shell} -i -c '${tool} --version'`; + + outputChannel.info(`Checking if ${tool} is available on the path`); + + await asyncExec(command, { + cwd: workspaceFolder.uri.fsPath, + timeout: 1000, + }); + return true; + } catch { + return false; + } + } + protected async runEnvActivationScript(activatedRuby: string): Promise { const activationUri = vscode.Uri.joinPath(this.context.extensionUri, "activation.rb"); diff --git a/vscode/src/test/suite/ruby/asdf.test.ts b/vscode/src/test/suite/ruby/asdf.test.ts index aba133cbcf..aa79e15773 100644 --- a/vscode/src/test/suite/ruby/asdf.test.ts +++ b/vscode/src/test/suite/ruby/asdf.test.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach } from "mocha"; import { Asdf } from "../../../ruby/asdf"; import { WorkspaceChannel } from "../../../workspaceChannel"; import * as common from "../../../common"; -import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../../../ruby/versionManager"; +import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR, VersionManager } from "../../../ruby/versionManager"; import { createContext, FakeContext } from "../helpers"; suite("Asdf", () => { @@ -50,7 +50,7 @@ suite("Asdf", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); - sandbox.stub(asdf, "findAsdfInstallation").resolves(`${os.homedir()}/.asdf/asdf.sh`); + sandbox.stub(Asdf, "detect").resolves(vscode.Uri.file(`${os.homedir()}/.asdf/asdf.sh`)); sandbox.stub(vscode.env, "shell").get(() => "/bin/bash"); const { env, version, yjit } = await asdf.activate(); @@ -82,7 +82,7 @@ suite("Asdf", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); - sandbox.stub(asdf, "findAsdfInstallation").resolves(`${os.homedir()}/.asdf/asdf.fish`); + sandbox.stub(Asdf, "detect").resolves(vscode.Uri.file(`${os.homedir()}/.asdf/asdf.fish`)); sandbox.stub(vscode.env, "shell").get(() => "/opt/homebrew/bin/fish"); const { env, version, yjit } = await asdf.activate(); @@ -114,7 +114,7 @@ suite("Asdf", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); - sandbox.stub(asdf, "findAsdfInstallation").resolves(undefined); + sandbox.stub(Asdf, "detect").resolves(vscode.Uri.file("/opt/homebrew/bin/asdf")); sandbox.stub(vscode.workspace, "fs").value({ stat: () => Promise.resolve(undefined), @@ -146,7 +146,7 @@ suite("Asdf", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); - sandbox.stub(asdf, "findAsdfInstallation").resolves(undefined); + sandbox.stub(VersionManager, "toolExists").resolves(true); const { env, version, yjit } = await asdf.activate(); From f72ffae2f08193b569bbda356d76744fa688703a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 7 Jan 2026 20:57:41 -0500 Subject: [PATCH 03/14] Use polymorphism to improve version manager detection and activation --- vscode/src/ruby.ts | 111 ++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 62 deletions(-) diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index 9937444515..93b23bf42c 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -35,6 +35,34 @@ export interface ManagerConfiguration { identifier: ManagerIdentifier; } +interface ManagerClass { + new ( + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + context: vscode.ExtensionContext, + manuallySelectRuby: () => Promise, + ...args: any[] + ): VersionManager; + detect?: ( + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + ) => Promise; +} + +const VERSION_MANAGERS: Record = { + [ManagerIdentifier.Asdf]: Asdf, + [ManagerIdentifier.Auto]: None, // Auto is handled specially + [ManagerIdentifier.Chruby]: Chruby, + [ManagerIdentifier.Rbenv]: Rbenv, + [ManagerIdentifier.Rvm]: Rvm, + [ManagerIdentifier.Rv]: Rv, + [ManagerIdentifier.Shadowenv]: Shadowenv, + [ManagerIdentifier.Mise]: Mise, + [ManagerIdentifier.RubyInstaller]: RubyInstaller, + [ManagerIdentifier.None]: None, + [ManagerIdentifier.Custom]: Custom, +}; + export class Ruby implements RubyInterface { public rubyVersion?: string; // This property indicates that Ruby has been compiled with YJIT support and that we're running on a Ruby version @@ -269,58 +297,14 @@ export class Ruby implements RubyInterface { } private async runManagerActivation() { - switch (this.versionManager.identifier) { - case ManagerIdentifier.Asdf: - await this.runActivation( - new Asdf(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - case ManagerIdentifier.Chruby: - await this.runActivation( - new Chruby(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - case ManagerIdentifier.Rbenv: - await this.runActivation( - new Rbenv(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - case ManagerIdentifier.Rvm: - await this.runActivation( - new Rvm(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - case ManagerIdentifier.Mise: - await this.runActivation( - 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)), - ); - break; - case ManagerIdentifier.Custom: - await this.runActivation( - new Custom(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - case ManagerIdentifier.None: - await this.runActivation( - new None(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - default: - await this.runActivation( - new Shadowenv(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - } + const ManagerClass = VERSION_MANAGERS[this.versionManager.identifier]; + const manager = new ManagerClass( + this.workspaceFolder, + this.outputChannel, + this.context, + this.manuallySelectRuby.bind(this), + ); + await this.runActivation(manager); } private async setupBundlePath() { @@ -349,9 +333,14 @@ 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.Rv]; + const managersWithToolExists = [ + ManagerIdentifier.Chruby, + ManagerIdentifier.Rbenv, + ManagerIdentifier.Rvm, + ManagerIdentifier.Rv, + ]; - for (const tool of managers) { + for (const tool of managersWithToolExists) { const exists = await this.toolExists(tool); if (exists) { @@ -360,14 +349,12 @@ export class Ruby implements RubyInterface { } } - if (await Asdf.detect(this.workspaceFolder, this.outputChannel)) { - this.versionManager = ManagerIdentifier.Asdf; - return; - } - - if (await Mise.detect()) { - this.versionManager = ManagerIdentifier.Mise; - return; + // Check managers that have a detect() method + for (const [identifier, ManagerClass] of Object.entries(VERSION_MANAGERS)) { + if (ManagerClass.detect && (await ManagerClass.detect(this.workspaceFolder, this.outputChannel))) { + this.versionManager = identifier as ManagerIdentifier; + return; + } } if (os.platform() === "win32") { From 5769b3a34146a5543f189bfc46239a494484717e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 7 Jan 2026 22:09:42 -0500 Subject: [PATCH 04/14] Move logic to detect Chruby to the VersionManager class --- vscode/src/ruby.ts | 7 +------ vscode/src/ruby/chruby.ts | 8 ++++++++ vscode/src/ruby/mise.ts | 8 ++++++-- vscode/src/test/suite/ruby/mise.test.ts | 27 ++++++++++++++++++++++--- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index 93b23bf42c..6b7cd3318c 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -333,12 +333,7 @@ export class Ruby implements RubyInterface { // If .shadowenv.d doesn't exist, then we check the other version managers } - const managersWithToolExists = [ - ManagerIdentifier.Chruby, - ManagerIdentifier.Rbenv, - ManagerIdentifier.Rvm, - ManagerIdentifier.Rv, - ]; + const managersWithToolExists = [ManagerIdentifier.Rbenv, ManagerIdentifier.Rvm, ManagerIdentifier.Rv]; for (const tool of managersWithToolExists) { const exists = await this.toolExists(tool); diff --git a/vscode/src/ruby/chruby.ts b/vscode/src/ruby/chruby.ts index 0036efb672..98d59c62b4 100644 --- a/vscode/src/ruby/chruby.ts +++ b/vscode/src/ruby/chruby.ts @@ -17,6 +17,14 @@ class RubyActivationCancellationError extends Error {} // A tool to change the current Ruby version // Learn more: https://github.com/postmodern/chruby export class Chruby extends VersionManager { + static async detect( + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + ): Promise { + const exists = await VersionManager.toolExists("chruby", workspaceFolder, outputChannel); + return exists ? vscode.Uri.file("chruby") : undefined; + } + // Only public so that we can point to a different directory in tests public rubyInstallationUris = [ vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".rubies"), diff --git a/vscode/src/ruby/mise.ts b/vscode/src/ruby/mise.ts index b211162046..fc7fa14dcc 100644 --- a/vscode/src/ruby/mise.ts +++ b/vscode/src/ruby/mise.ts @@ -3,6 +3,7 @@ import os from "os"; import * as vscode from "vscode"; import { VersionManager, ActivationResult } from "./versionManager"; +import { WorkspaceChannel } from "../workspaceChannel"; // Mise (mise en place) is a manager for dev tools, environment variables and tasks // @@ -21,7 +22,10 @@ export class Mise extends VersionManager { ]; } - static async detect(): Promise { + static async detect( + _workspaceFolder: vscode.WorkspaceFolder, + _outputChannel: WorkspaceChannel, + ): Promise { return VersionManager.findFirst(Mise.getPossiblePaths()); } @@ -54,7 +58,7 @@ export class Mise extends VersionManager { } } - const detectedPath = await Mise.detect(); + const detectedPath = await Mise.detect(this.workspaceFolder, this.outputChannel); if (detectedPath) { return detectedPath; diff --git a/vscode/src/test/suite/ruby/mise.test.ts b/vscode/src/test/suite/ruby/mise.test.ts index a71a931ff7..b1377a906c 100644 --- a/vscode/src/test/suite/ruby/mise.test.ts +++ b/vscode/src/test/suite/ruby/mise.test.ts @@ -129,6 +129,13 @@ suite("Mise", () => { }); test("detect returns the first found mise path", 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 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); const misePath = path.join(tempDir, "mise"); fs.writeFileSync(misePath, "fakeMiseBinary"); @@ -137,7 +144,7 @@ suite("Mise", () => { .stub(Mise as any, "getPossiblePaths") .returns([vscode.Uri.file(misePath), vscode.Uri.file(path.join(tempDir, "other", "mise"))]); - const result = await Mise.detect(); + const result = await Mise.detect(workspaceFolder, outputChannel); assert.strictEqual(result?.fsPath, vscode.Uri.file(misePath).fsPath); @@ -146,6 +153,13 @@ suite("Mise", () => { }); test("detect returns undefined when mise is not found", 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 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); const getPossiblePathsStub = sandbox @@ -155,7 +169,7 @@ suite("Mise", () => { vscode.Uri.file(path.join(tempDir, "nonexistent2", "mise")), ]); - const result = await Mise.detect(); + const result = await Mise.detect(workspaceFolder, outputChannel); assert.strictEqual(result, undefined); @@ -164,6 +178,13 @@ suite("Mise", () => { }); test("detect checks multiple paths in order", 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 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); const secondPath = path.join(tempDir, "second", "mise"); fs.mkdirSync(path.dirname(secondPath), { recursive: true }); @@ -177,7 +198,7 @@ suite("Mise", () => { vscode.Uri.file(path.join(tempDir, "third", "mise")), ]); - const result = await Mise.detect(); + const result = await Mise.detect(workspaceFolder, outputChannel); assert.strictEqual(result?.fsPath, vscode.Uri.file(secondPath).fsPath); From 8dec75715df6c5f7f1f196d97bdc913f5eb4736e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 7 Jan 2026 22:13:27 -0500 Subject: [PATCH 05/14] Move rbenv detection logic to the Rbenv class --- vscode/src/ruby.ts | 2 +- vscode/src/ruby/rbenv.ts | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index 6b7cd3318c..edf9328123 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -333,7 +333,7 @@ export class Ruby implements RubyInterface { // If .shadowenv.d doesn't exist, then we check the other version managers } - const managersWithToolExists = [ManagerIdentifier.Rbenv, ManagerIdentifier.Rvm, ManagerIdentifier.Rv]; + const managersWithToolExists = [ManagerIdentifier.Rvm, ManagerIdentifier.Rv]; for (const tool of managersWithToolExists) { const exists = await this.toolExists(tool); diff --git a/vscode/src/ruby/rbenv.ts b/vscode/src/ruby/rbenv.ts index 4c7308ca9d..2f18a7f6ba 100644 --- a/vscode/src/ruby/rbenv.ts +++ b/vscode/src/ruby/rbenv.ts @@ -6,6 +6,11 @@ import { VersionManager, ActivationResult } from "./versionManager"; // // Learn more: https://github.com/rbenv/rbenv export class Rbenv extends VersionManager { + static async detect(workspaceFolder: vscode.WorkspaceFolder): Promise { + const exists = await VersionManager.toolExists("rbenv", workspaceFolder); + return exists ? vscode.Uri.file("rbenv") : undefined; + } + async activate(): Promise { const rbenvExec = await this.findRbenv(); From 8b272b4f373b3c2ee115f37bc0919de28d455dcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 7 Jan 2026 22:23:59 -0500 Subject: [PATCH 06/14] Move detect logic to the Rvm class --- vscode/src/ruby.ts | 8 ++------ vscode/src/ruby/rbenv.ts | 8 ++++++-- vscode/src/ruby/rvm.ts | 9 +++++++++ 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index edf9328123..f359d2b1f4 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -333,10 +333,10 @@ export class Ruby implements RubyInterface { // If .shadowenv.d doesn't exist, then we check the other version managers } - const managersWithToolExists = [ManagerIdentifier.Rvm, ManagerIdentifier.Rv]; + const managersWithToolExists = [ManagerIdentifier.Rv]; for (const tool of managersWithToolExists) { - const exists = await this.toolExists(tool); + const exists = await VersionManager.toolExists(tool, this.workspaceFolder, this.outputChannel); if (exists) { this.versionManager = tool; @@ -361,10 +361,6 @@ export class Ruby implements RubyInterface { this.versionManager = ManagerIdentifier.None; } - private async toolExists(tool: string) { - return VersionManager.toolExists(tool, this.workspaceFolder, this.outputChannel); - } - private async handleRubyError(message: string) { const answer = await vscode.window.showErrorMessage( `Automatic Ruby environment activation with ${this.versionManager.identifier} failed: ${message}`, diff --git a/vscode/src/ruby/rbenv.ts b/vscode/src/ruby/rbenv.ts index 2f18a7f6ba..b3db87bd17 100644 --- a/vscode/src/ruby/rbenv.ts +++ b/vscode/src/ruby/rbenv.ts @@ -1,13 +1,17 @@ import * as vscode from "vscode"; import { VersionManager, ActivationResult } from "./versionManager"; +import { WorkspaceChannel } from "../workspaceChannel"; // Seamlessly manage your app’s Ruby environment with rbenv. // // Learn more: https://github.com/rbenv/rbenv export class Rbenv extends VersionManager { - static async detect(workspaceFolder: vscode.WorkspaceFolder): Promise { - const exists = await VersionManager.toolExists("rbenv", workspaceFolder); + static async detect( + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + ): Promise { + const exists = await VersionManager.toolExists("rbenv", workspaceFolder, outputChannel); return exists ? vscode.Uri.file("rbenv") : undefined; } diff --git a/vscode/src/ruby/rvm.ts b/vscode/src/ruby/rvm.ts index 66f508db0c..643ba89f03 100644 --- a/vscode/src/ruby/rvm.ts +++ b/vscode/src/ruby/rvm.ts @@ -3,12 +3,21 @@ import os from "os"; import * as vscode from "vscode"; import { ActivationResult, VersionManager } from "./versionManager"; +import { WorkspaceChannel } from "../workspaceChannel"; // Ruby enVironment Manager. It manages Ruby application environments and enables switching between them. // Learn more: // - https://github.com/rvm/rvm // - https://rvm.io export class Rvm extends VersionManager { + static async detect( + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + ): Promise { + const exists = await VersionManager.toolExists("rvm", workspaceFolder, outputChannel); + return exists ? vscode.Uri.file("rvm") : undefined; + } + async activate(): Promise { const installationPath = await this.findRvmInstallation(); const parsedResult = await this.runEnvActivationScript(installationPath.fsPath); From 38f86d3c49e50ace7a1653360e96a014ff2a6797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 7 Jan 2026 22:29:28 -0500 Subject: [PATCH 07/14] Move detection logic to the Shadowenv class --- vscode/src/ruby.ts | 10 ---------- vscode/src/ruby/shadowenv.ts | 20 ++++++++++++++++++-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index f359d2b1f4..a538823963 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -323,16 +323,6 @@ export class Ruby implements RubyInterface { } private async discoverVersionManager() { - // For shadowenv, it wouldn't be enough to check for the executable's existence. We need to check if the project has - // created a .shadowenv.d folder - try { - await vscode.workspace.fs.stat(vscode.Uri.joinPath(this.workspaceFolder.uri, ".shadowenv.d")); - this.versionManager.identifier = ManagerIdentifier.Shadowenv; - return; - } catch (_error: any) { - // If .shadowenv.d doesn't exist, then we check the other version managers - } - const managersWithToolExists = [ManagerIdentifier.Rv]; for (const tool of managersWithToolExists) { diff --git a/vscode/src/ruby/shadowenv.ts b/vscode/src/ruby/shadowenv.ts index a683ad8e2f..b6b03d6e09 100644 --- a/vscode/src/ruby/shadowenv.ts +++ b/vscode/src/ruby/shadowenv.ts @@ -11,10 +11,26 @@ import { VersionManager, ActivationResult } from "./versionManager"; export class UntrustedWorkspaceError extends Error {} export class Shadowenv extends VersionManager { - async activate(): Promise { + private static async shadowenvDirExists(workspaceUri: vscode.Uri): Promise { try { - await vscode.workspace.fs.stat(vscode.Uri.joinPath(this.bundleUri, ".shadowenv.d")); + await vscode.workspace.fs.stat(vscode.Uri.joinPath(workspaceUri, ".shadowenv.d")); + return true; } catch (_error: any) { + return false; + } + } + + static async detect( + workspaceFolder: vscode.WorkspaceFolder, + _outputChannel: vscode.LogOutputChannel, + ): Promise { + const exists = await Shadowenv.shadowenvDirExists(workspaceFolder.uri); + return exists ? vscode.Uri.joinPath(workspaceFolder.uri, ".shadowenv.d") : undefined; + } + + async activate(): Promise { + const exists = await Shadowenv.shadowenvDirExists(this.bundleUri); + if (!exists) { throw new Error( "The Ruby LSP version manager is configured to be shadowenv, \ but no .shadowenv.d directory was found in the workspace", From 6ff144804a174daa9530c20ce4b2303fdd6b2306 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 7 Jan 2026 22:33:53 -0500 Subject: [PATCH 08/14] Move detection logic to the RubyInstaller class --- vscode/src/ruby.ts | 6 ------ vscode/src/ruby/rubyInstaller.ts | 8 ++++++++ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index a538823963..811583df0b 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -1,5 +1,4 @@ import path from "path"; -import os from "os"; import * as vscode from "vscode"; @@ -342,11 +341,6 @@ export class Ruby implements RubyInterface { } } - if (os.platform() === "win32") { - this.versionManager = ManagerIdentifier.RubyInstaller; - return; - } - // If we can't find a version manager, just return None this.versionManager = ManagerIdentifier.None; } diff --git a/vscode/src/ruby/rubyInstaller.ts b/vscode/src/ruby/rubyInstaller.ts index 3adc0503bb..e4e5573318 100644 --- a/vscode/src/ruby/rubyInstaller.ts +++ b/vscode/src/ruby/rubyInstaller.ts @@ -16,6 +16,14 @@ interface RubyVersion { // // If we can't find it there, then we throw an error and rely on the user to manually select where Ruby is installed. export class RubyInstaller extends Chruby { + // eslint-disable-next-line @typescript-eslint/require-await + static async detect( + _workspaceFolder: vscode.WorkspaceFolder, + _outputChannel: vscode.LogOutputChannel, + ): Promise { + return os.platform() === "win32" ? vscode.Uri.file("RubyInstaller") : undefined; + } + // Environment variables are case sensitive on Windows when we access them through NodeJS. We need to ensure that // we're searching through common variations, so that we don't accidentally miss the path we should inherit protected getProcessPath() { From 10ac66d86ce0de75ef6b3d7fdd832314aed8d797 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 7 Jan 2026 22:45:11 -0500 Subject: [PATCH 09/14] Make sure all version managers implement the detect logic --- vscode/src/ruby.ts | 28 ++++++++++++++-------------- vscode/src/ruby/custom.ts | 8 ++++++++ vscode/src/ruby/none.ts | 9 +++++++++ 3 files changed, 31 insertions(+), 14 deletions(-) diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index 811583df0b..09dd51b702 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -42,24 +42,21 @@ interface ManagerClass { manuallySelectRuby: () => Promise, ...args: any[] ): VersionManager; - detect?: ( - workspaceFolder: vscode.WorkspaceFolder, - outputChannel: WorkspaceChannel, - ) => Promise; + detect: (workspaceFolder: vscode.WorkspaceFolder, outputChannel: WorkspaceChannel) => Promise; } const VERSION_MANAGERS: Record = { + [ManagerIdentifier.Shadowenv]: Shadowenv, [ManagerIdentifier.Asdf]: Asdf, - [ManagerIdentifier.Auto]: None, // Auto is handled specially [ManagerIdentifier.Chruby]: Chruby, [ManagerIdentifier.Rbenv]: Rbenv, [ManagerIdentifier.Rvm]: Rvm, [ManagerIdentifier.Rv]: Rv, - [ManagerIdentifier.Shadowenv]: Shadowenv, [ManagerIdentifier.Mise]: Mise, [ManagerIdentifier.RubyInstaller]: RubyInstaller, - [ManagerIdentifier.None]: None, [ManagerIdentifier.Custom]: Custom, + [ManagerIdentifier.Auto]: None, // Auto is handled specially + [ManagerIdentifier.None]: None, // None is last as the fallback }; export class Ruby implements RubyInterface { @@ -333,16 +330,19 @@ export class Ruby implements RubyInterface { } } - // Check managers that have a detect() method - for (const [identifier, ManagerClass] of Object.entries(VERSION_MANAGERS)) { - if (ManagerClass.detect && (await ManagerClass.detect(this.workspaceFolder, this.outputChannel))) { - this.versionManager = identifier as ManagerIdentifier; + // Check all managers for detection + const entries = Object.entries(VERSION_MANAGERS) as [ManagerIdentifier, ManagerClass][]; + + for (const [identifier, ManagerClass] of entries) { + if (identifier === ManagerIdentifier.Auto) { + continue; + } + + if (await ManagerClass.detect(this.workspaceFolder, this.outputChannel)) { + this.versionManager = identifier; return; } } - - // If we can't find a version manager, just return None - this.versionManager = ManagerIdentifier.None; } private async handleRubyError(message: string) { diff --git a/vscode/src/ruby/custom.ts b/vscode/src/ruby/custom.ts index c4564c7946..f64924ddcb 100644 --- a/vscode/src/ruby/custom.ts +++ b/vscode/src/ruby/custom.ts @@ -8,6 +8,14 @@ import { VersionManager, ActivationResult } from "./versionManager"; // Users are allowed to define a shell script that runs before calling ruby, giving them the chance to modify the PATH, // GEM_HOME and GEM_PATH as needed to find the correct Ruby runtime. export class Custom extends VersionManager { + // eslint-disable-next-line @typescript-eslint/require-await + static async detect( + _workspaceFolder: vscode.WorkspaceFolder, + _outputChannel: vscode.LogOutputChannel, + ): Promise { + return undefined; + } + async activate(): Promise { const parsedResult = await this.runEnvActivationScript(`${this.customCommand()} && ruby`); diff --git a/vscode/src/ruby/none.ts b/vscode/src/ruby/none.ts index 0c6a8c7e54..a6fb89079c 100644 --- a/vscode/src/ruby/none.ts +++ b/vscode/src/ruby/none.ts @@ -13,6 +13,15 @@ import { VersionManager, ActivationResult } from "./versionManager"; // If you don't have Ruby automatically available in your PATH and are not using a version manager, look into // configuring custom Ruby activation export class None extends VersionManager { + // eslint-disable-next-line @typescript-eslint/require-await + static async detect( + _workspaceFolder: vscode.WorkspaceFolder, + _outputChannel: vscode.LogOutputChannel, + ): Promise { + // None always matches as the final fallback + return vscode.Uri.file("none"); + } + private readonly rubyPath: string; constructor( From e3d6b8c500d01e49f2ff896e40bf13c02051140d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 7 Jan 2026 23:42:08 -0500 Subject: [PATCH 10/14] Extract helper function to create URIs from paths and use it across the codebase --- vscode/src/common.ts | 6 ++++++ vscode/src/ruby/asdf.ts | 14 ++++++-------- vscode/src/ruby/chruby.ts | 6 ++---- vscode/src/ruby/mise.ts | 7 ++++--- vscode/src/ruby/rubyInstaller.ts | 5 +++-- vscode/src/ruby/rvm.ts | 7 ++++--- vscode/src/test/suite/debugger.test.ts | 4 ++-- vscode/src/test/suite/ruby/mise.test.ts | 2 +- vscode/src/test/suite/ruby/rvm.test.ts | 2 +- 9 files changed, 29 insertions(+), 24 deletions(-) diff --git a/vscode/src/common.ts b/vscode/src/common.ts index d7f38cbd50..e85f376db1 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -144,3 +144,9 @@ export function featureEnabled(feature: keyof typeof FEATURE_FLAGS): boolean { // If that number is below the percentage, then the feature is enabled for this user return hashNum < percentage; } + +// Helper to create a URI from a file path and optional path segments +// Usage: pathToUri("/", "opt", "bin") instead of vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "bin") +export function pathToUri(basePath: string, ...segments: string[]): vscode.Uri { + return vscode.Uri.joinPath(vscode.Uri.file(basePath), ...segments); +} diff --git a/vscode/src/ruby/asdf.ts b/vscode/src/ruby/asdf.ts index 6ee8793ac9..64e9744590 100644 --- a/vscode/src/ruby/asdf.ts +++ b/vscode/src/ruby/asdf.ts @@ -5,6 +5,7 @@ import * as vscode from "vscode"; import { VersionManager, ActivationResult } from "./versionManager"; import { WorkspaceChannel } from "../workspaceChannel"; +import { pathToUri } from "../common"; // A tool to manage multiple runtime versions with a single CLI tool // @@ -12,10 +13,7 @@ import { WorkspaceChannel } from "../workspaceChannel"; export class Asdf extends VersionManager { private static getPossibleExecutablePaths(): vscode.Uri[] { // These directories are where we can find the ASDF executable for v0.16 and above - return [ - vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "homebrew", "bin"), - vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "local", "bin"), - ]; + return [pathToUri("/", "opt", "homebrew", "bin"), pathToUri("/", "usr", "local", "bin")]; } private static getPossibleScriptPaths(): vscode.Uri[] { @@ -28,10 +26,10 @@ export class Asdf extends VersionManager { // 3. Homebrew M series // 4. Homebrew Intel series return [ - vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".asdf", scriptName), - vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "asdf-vm", scriptName), - vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "homebrew", "opt", "asdf", "libexec", scriptName), - vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "local", "opt", "asdf", "libexec", scriptName), + pathToUri(os.homedir(), ".asdf", scriptName), + pathToUri("/", "opt", "asdf-vm", scriptName), + pathToUri("/", "opt", "homebrew", "opt", "asdf", "libexec", scriptName), + pathToUri("/", "usr", "local", "opt", "asdf", "libexec", scriptName), ]; } diff --git a/vscode/src/ruby/chruby.ts b/vscode/src/ruby/chruby.ts index 98d59c62b4..ccf57c34c2 100644 --- a/vscode/src/ruby/chruby.ts +++ b/vscode/src/ruby/chruby.ts @@ -4,6 +4,7 @@ import path from "path"; import * as vscode from "vscode"; import { WorkspaceChannel } from "../workspaceChannel"; +import { pathToUri } from "../common"; import { ActivationResult, VersionManager, ACTIVATION_SEPARATOR } from "./versionManager"; @@ -26,10 +27,7 @@ export class Chruby extends VersionManager { } // Only public so that we can point to a different directory in tests - public rubyInstallationUris = [ - vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".rubies"), - vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "rubies"), - ]; + public rubyInstallationUris = [pathToUri(os.homedir(), ".rubies"), pathToUri("/", "opt", "rubies")]; constructor( workspaceFolder: vscode.WorkspaceFolder, diff --git a/vscode/src/ruby/mise.ts b/vscode/src/ruby/mise.ts index fc7fa14dcc..43797b4b1e 100644 --- a/vscode/src/ruby/mise.ts +++ b/vscode/src/ruby/mise.ts @@ -4,6 +4,7 @@ import * as vscode from "vscode"; import { VersionManager, ActivationResult } from "./versionManager"; import { WorkspaceChannel } from "../workspaceChannel"; +import { pathToUri } from "../common"; // Mise (mise en place) is a manager for dev tools, environment variables and tasks // @@ -16,9 +17,9 @@ export class Mise extends VersionManager { // 3. Installation from `apt install mise` private static getPossiblePaths(): vscode.Uri[] { return [ - vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".local", "bin", "mise"), - vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "homebrew", "bin", "mise"), - vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "bin", "mise"), + pathToUri(os.homedir(), ".local", "bin", "mise"), + pathToUri("/", "opt", "homebrew", "bin", "mise"), + pathToUri("/", "usr", "bin", "mise"), ]; } diff --git a/vscode/src/ruby/rubyInstaller.ts b/vscode/src/ruby/rubyInstaller.ts index e4e5573318..cf6ecddcf6 100644 --- a/vscode/src/ruby/rubyInstaller.ts +++ b/vscode/src/ruby/rubyInstaller.ts @@ -3,6 +3,7 @@ import os from "os"; import * as vscode from "vscode"; import { Chruby } from "./chruby"; +import { pathToUri } from "../common"; interface RubyVersion { engine?: string; @@ -35,8 +36,8 @@ export class RubyInstaller extends Chruby { const [major, minor, _patch] = rubyVersion.version.split(".").map(Number); const possibleInstallationUris = [ - vscode.Uri.joinPath(vscode.Uri.file("C:"), `Ruby${major}${minor}-${os.arch()}`), - vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), `Ruby${major}${minor}-${os.arch()}`), + pathToUri("C:", `Ruby${major}${minor}-${os.arch()}`), + pathToUri(os.homedir(), `Ruby${major}${minor}-${os.arch()}`), ]; for (const installationUri of possibleInstallationUris) { diff --git a/vscode/src/ruby/rvm.ts b/vscode/src/ruby/rvm.ts index 643ba89f03..47fd7fced4 100644 --- a/vscode/src/ruby/rvm.ts +++ b/vscode/src/ruby/rvm.ts @@ -4,6 +4,7 @@ import * as vscode from "vscode"; import { ActivationResult, VersionManager } from "./versionManager"; import { WorkspaceChannel } from "../workspaceChannel"; +import { pathToUri } from "../common"; // Ruby enVironment Manager. It manages Ruby application environments and enables switching between them. // Learn more: @@ -38,9 +39,9 @@ export class Rvm extends VersionManager { async findRvmInstallation(): Promise { const possiblePaths = [ - vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".rvm", "bin", "rvm-auto-ruby"), - vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "local", "rvm", "bin", "rvm-auto-ruby"), - vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "share", "rvm", "bin", "rvm-auto-ruby"), + pathToUri(os.homedir(), ".rvm", "bin", "rvm-auto-ruby"), + pathToUri("/", "usr", "local", "rvm", "bin", "rvm-auto-ruby"), + pathToUri("/", "usr", "share", "rvm", "bin", "rvm-auto-ruby"), ]; for (const uri of possiblePaths) { diff --git a/vscode/src/test/suite/debugger.test.ts b/vscode/src/test/suite/debugger.test.ts index 6ab3ba49e7..af62d29126 100644 --- a/vscode/src/test/suite/debugger.test.ts +++ b/vscode/src/test/suite/debugger.test.ts @@ -10,7 +10,7 @@ import { Debugger } from "../../debugger"; import { Ruby, ManagerIdentifier } from "../../ruby"; import { Workspace } from "../../workspace"; import { WorkspaceChannel } from "../../workspaceChannel"; -import { LOG_CHANNEL, asyncExec } from "../../common"; +import { LOG_CHANNEL, asyncExec, pathToUri } from "../../common"; import { RUBY_VERSION } from "../rubyVersion"; import { FAKE_TELEMETRY } from "./fakeTelemetry"; @@ -156,7 +156,7 @@ suite("Debugger", () => { { parallel: "1", ...ruby.env, - BUNDLE_GEMFILE: vscode.Uri.joinPath(vscode.Uri.file(tmpPath), ".ruby-lsp", "Gemfile").fsPath, + BUNDLE_GEMFILE: pathToUri(tmpPath, ".ruby-lsp", "Gemfile").fsPath, }, configs.env, ); diff --git a/vscode/src/test/suite/ruby/mise.test.ts b/vscode/src/test/suite/ruby/mise.test.ts index b1377a906c..ed796974ac 100644 --- a/vscode/src/test/suite/ruby/mise.test.ts +++ b/vscode/src/test/suite/ruby/mise.test.ts @@ -53,7 +53,7 @@ suite("Mise", () => { }); const findStub = sandbox .stub(mise, "findMiseUri") - .resolves(vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".local", "bin", "mise")); + .resolves(common.pathToUri(os.homedir(), ".local", "bin", "mise")); const { env, version, yjit } = await mise.activate(); diff --git a/vscode/src/test/suite/ruby/rvm.test.ts b/vscode/src/test/suite/ruby/rvm.test.ts index b1d6c8d120..1d2904f2d2 100644 --- a/vscode/src/test/suite/ruby/rvm.test.ts +++ b/vscode/src/test/suite/ruby/rvm.test.ts @@ -46,7 +46,7 @@ suite("RVM", () => { const installationPathStub = sandbox .stub(rvm, "findRvmInstallation") - .resolves(vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".rvm", "bin", "rvm-auto-ruby")); + .resolves(common.pathToUri(os.homedir(), ".rvm", "bin", "rvm-auto-ruby")); const envStub = ["3.0.0", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR); From 4ddc1e381cf12c2ed5c300b1c8c6d8bc2fd08d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 12 Jan 2026 16:44:02 -0500 Subject: [PATCH 11/14] Make the Rv version manager confirm with the new detection mechanism --- vscode/src/ruby.ts | 12 ------ vscode/src/ruby/mise.ts | 35 +++------------- vscode/src/ruby/rv.ts | 53 ++++++++++++------------- vscode/src/ruby/versionManager.ts | 33 +++++++++++++++ vscode/src/test/suite/ruby/mise.test.ts | 2 +- vscode/src/test/suite/ruby/rv.test.ts | 11 ++--- 6 files changed, 71 insertions(+), 75 deletions(-) diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index 09dd51b702..9cd8f8f9f4 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -319,18 +319,6 @@ export class Ruby implements RubyInterface { } private async discoverVersionManager() { - const managersWithToolExists = [ManagerIdentifier.Rv]; - - for (const tool of managersWithToolExists) { - const exists = await VersionManager.toolExists(tool, this.workspaceFolder, this.outputChannel); - - if (exists) { - this.versionManager = tool; - return; - } - } - - // Check all managers for detection const entries = Object.entries(VERSION_MANAGERS) as [ManagerIdentifier, ManagerClass][]; for (const [identifier, ManagerClass] of entries) { diff --git a/vscode/src/ruby/mise.ts b/vscode/src/ruby/mise.ts index 43797b4b1e..4fcebd3c85 100644 --- a/vscode/src/ruby/mise.ts +++ b/vscode/src/ruby/mise.ts @@ -31,7 +31,12 @@ export class Mise extends VersionManager { } async activate(): Promise { - const miseUri = await this.findMiseUri(); + const miseUri = await this.findVersionManagerUri( + "Mise", + "rubyVersionManager.miseExecutablePath", + Mise.getPossiblePaths(), + () => Mise.detect(this.workspaceFolder, this.outputChannel), + ); // The exec command in Mise is called `x` const parsedResult = await this.runEnvActivationScript(`${miseUri.fsPath} x -- ruby`); @@ -43,32 +48,4 @@ export class Mise extends VersionManager { gemPath: parsedResult.gemPath, }; } - - async findMiseUri(): Promise { - const config = vscode.workspace.getConfiguration("rubyLsp"); - const misePath = config.get("rubyVersionManager.miseExecutablePath"); - - if (misePath) { - const configuredPath = vscode.Uri.file(misePath); - - try { - await vscode.workspace.fs.stat(configuredPath); - return configuredPath; - } catch (_error: any) { - throw new Error(`Mise executable configured as ${configuredPath.fsPath}, but that file doesn't exist`); - } - } - - const detectedPath = await Mise.detect(this.workspaceFolder, this.outputChannel); - - if (detectedPath) { - return detectedPath; - } - - const possiblePaths = Mise.getPossiblePaths(); - throw new Error( - `The Ruby LSP version manager is configured to be Mise, but could not find Mise installation. Searched in - ${possiblePaths.join(", ")}`, - ); - } } diff --git a/vscode/src/ruby/rv.ts b/vscode/src/ruby/rv.ts index 41f5828ab9..4b1212deb7 100644 --- a/vscode/src/ruby/rv.ts +++ b/vscode/src/ruby/rv.ts @@ -1,14 +1,37 @@ import * as vscode from "vscode"; import { VersionManager, ActivationResult } from "./versionManager"; +import { pathToUri } from "../common"; +import { WorkspaceChannel } from "../workspaceChannel"; // Manage your Ruby environment with rv // // Learn more: https://github.com/spinel-coop/rv export class Rv extends VersionManager { + private static getPossiblePaths(): vscode.Uri[] { + return [ + pathToUri("/", "home", "linuxbrew", ".linuxbrew", "bin", "rv"), + pathToUri("/", "usr", "local", "bin", "rv"), + pathToUri("/", "opt", "homebrew", "bin", "rv"), + pathToUri("/", "usr", "bin", "rv"), + ]; + } + + static async detect( + _workspaceFolder: vscode.WorkspaceFolder, + _outputChannel: WorkspaceChannel, + ): Promise { + return VersionManager.findFirst(Rv.getPossiblePaths()); + } + async activate(): Promise { - const rvExec = await this.findRv(); - const parsedResult = await this.runEnvActivationScript(`${rvExec} ruby run --`); + const rvExec = await this.findVersionManagerUri( + "Rv", + "rubyVersionManager.rvExecutablePath", + Rv.getPossiblePaths(), + () => Rv.detect(this.workspaceFolder, this.outputChannel), + ); + const parsedResult = await this.runEnvActivationScript(`${rvExec.fsPath} ruby run --`); return { env: { ...process.env, ...parsedResult.env }, @@ -17,30 +40,4 @@ export class Rv extends VersionManager { 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.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"); - } - } - - 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/ruby/versionManager.ts b/vscode/src/ruby/versionManager.ts index e37b682f6a..2cfdb818f8 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -144,4 +144,37 @@ export abstract class VersionManager { return execName; } + + // Common helper to find a version manager executable with configuration support + protected async findVersionManagerUri( + managerName: string, + configKey: string, + possiblePaths: vscode.Uri[], + detectFn: () => Promise, + ): Promise { + const config = vscode.workspace.getConfiguration("rubyLsp"); + const configuredPath = config.get(configKey); + + if (configuredPath) { + const uri = vscode.Uri.file(configuredPath); + + try { + await vscode.workspace.fs.stat(uri); + return uri; + } catch (_error: any) { + throw new Error(`${managerName} executable configured as ${uri.fsPath}, but that file doesn't exist`); + } + } + + const detectedPath = await detectFn(); + + if (detectedPath) { + return detectedPath; + } + + throw new Error( + `The Ruby LSP version manager is configured to be ${managerName}, but could not find ${managerName} installation. Searched in + ${possiblePaths.map((p) => p.fsPath).join(", ")}`, + ); + } } diff --git a/vscode/src/test/suite/ruby/mise.test.ts b/vscode/src/test/suite/ruby/mise.test.ts index ed796974ac..a7a84d0048 100644 --- a/vscode/src/test/suite/ruby/mise.test.ts +++ b/vscode/src/test/suite/ruby/mise.test.ts @@ -52,7 +52,7 @@ suite("Mise", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); const findStub = sandbox - .stub(mise, "findMiseUri") + .stub(mise, "findVersionManagerUri" as any) .resolves(common.pathToUri(os.homedir(), ".local", "bin", "mise")); const { env, version, yjit } = await mise.activate(); diff --git a/vscode/src/test/suite/ruby/rv.test.ts b/vscode/src/test/suite/ruby/rv.test.ts index 9eb661b176..15469aa1b6 100644 --- a/vscode/src/test/suite/ruby/rv.test.ts +++ b/vscode/src/test/suite/ruby/rv.test.ts @@ -53,13 +53,14 @@ suite("Rv", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); - // Stub findRv to return the executable path - sandbox.stub(rv, "findRv" as any).resolves("rv"); + // Stub findVersionManagerUri to return the executable path as a Uri + const rvPath = vscode.Uri.file("rv"); + sandbox.stub(rv, "findVersionManagerUri" as any).resolves(rvPath); const { env, version, yjit } = await rv.activate(); assert.ok( - execStub.calledOnceWithExactly(`rv ruby run -- -EUTF-8:UTF-8 '${activationPath.fsPath}'`, { + execStub.calledOnceWithExactly(`${rvPath.fsPath} ruby run -- -EUTF-8:UTF-8 '${activationPath.fsPath}'`, { cwd: workspacePath, shell: vscode.env.shell, @@ -94,8 +95,8 @@ suite("Rv", () => { 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); + // Stub findVersionManagerUri to return the configured executable path as a Uri + sandbox.stub(rv, "findVersionManagerUri" as any).resolves(vscode.Uri.file(rvPath)); const configStub = sinon.stub(vscode.workspace, "getConfiguration").returns({ get: (name: string) => { From 7e42cbc1bec42bbad0a7dc1a81b1ae138a031aad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 12 Jan 2026 18:38:46 -0500 Subject: [PATCH 12/14] Implement Rv using Mise as base class --- vscode/src/ruby/mise.ts | 79 +++++++++++++++++++++++-------- vscode/src/ruby/rv.ts | 33 ++++--------- vscode/src/ruby/versionManager.ts | 33 ------------- 3 files changed, 69 insertions(+), 76 deletions(-) diff --git a/vscode/src/ruby/mise.ts b/vscode/src/ruby/mise.ts index 4fcebd3c85..b5ce8cbbf9 100644 --- a/vscode/src/ruby/mise.ts +++ b/vscode/src/ruby/mise.ts @@ -10,12 +10,32 @@ import { pathToUri } from "../common"; // // Learn more: https://github.com/jdx/mise export class Mise extends VersionManager { + static async detect( + _workspaceFolder: vscode.WorkspaceFolder, + _outputChannel: WorkspaceChannel, + ): Promise { + return this.findFirst(this.getPossiblePaths()); + } + + async activate(): Promise { + const execUri = await this.findVersionManagerUri(); + + const parsedResult = await this.runEnvActivationScript(this.getExecutionCommand(execUri.fsPath)); + + return { + env: { ...process.env, ...parsedResult.env }, + yjit: parsedResult.yjit, + version: parsedResult.version, + gemPath: parsedResult.gemPath, + }; + } + // Possible mise installation paths // // 1. Installation from curl | sh (per mise.jdx.dev Getting Started) // 2. Homebrew M series // 3. Installation from `apt install mise` - private static getPossiblePaths(): vscode.Uri[] { + protected static getPossiblePaths(): vscode.Uri[] { return [ pathToUri(os.homedir(), ".local", "bin", "mise"), pathToUri("/", "opt", "homebrew", "bin", "mise"), @@ -23,29 +43,48 @@ export class Mise extends VersionManager { ]; } - static async detect( - _workspaceFolder: vscode.WorkspaceFolder, - _outputChannel: WorkspaceChannel, - ): Promise { - return VersionManager.findFirst(Mise.getPossiblePaths()); + protected getVersionManagerName(): string { + return "Mise"; } - async activate(): Promise { - const miseUri = await this.findVersionManagerUri( - "Mise", - "rubyVersionManager.miseExecutablePath", - Mise.getPossiblePaths(), - () => Mise.detect(this.workspaceFolder, this.outputChannel), - ); + protected getConfigKey(): string { + return "rubyVersionManager.miseExecutablePath"; + } + protected getExecutionCommand(executablePath: string): string { // The exec command in Mise is called `x` - const parsedResult = await this.runEnvActivationScript(`${miseUri.fsPath} x -- ruby`); + return `${executablePath} x -- ruby`; + } - return { - env: { ...process.env, ...parsedResult.env }, - yjit: parsedResult.yjit, - version: parsedResult.version, - gemPath: parsedResult.gemPath, - }; + private async findVersionManagerUri(): Promise { + const constructor = this.constructor as typeof Mise; + const managerName = this.getVersionManagerName(); + const configKey = this.getConfigKey(); + + const config = vscode.workspace.getConfiguration("rubyLsp"); + const configuredPath = config.get(configKey); + + if (configuredPath) { + const uri = vscode.Uri.file(configuredPath); + + try { + await vscode.workspace.fs.stat(uri); + return uri; + } catch (_error: any) { + throw new Error(`${managerName} executable configured as ${uri.fsPath}, but that file doesn't exist`); + } + } + + const detectedPath = await constructor.detect(this.workspaceFolder, this.outputChannel); + + if (detectedPath) { + return detectedPath; + } + + const possiblePaths = constructor.getPossiblePaths(); + throw new Error( + `The Ruby LSP version manager is configured to be ${managerName}, but could not find ${managerName} installation. Searched in + ${possiblePaths.map((p) => p.fsPath).join(", ")}`, + ); } } diff --git a/vscode/src/ruby/rv.ts b/vscode/src/ruby/rv.ts index 4b1212deb7..abdae10cdc 100644 --- a/vscode/src/ruby/rv.ts +++ b/vscode/src/ruby/rv.ts @@ -1,14 +1,13 @@ import * as vscode from "vscode"; -import { VersionManager, ActivationResult } from "./versionManager"; import { pathToUri } from "../common"; -import { WorkspaceChannel } from "../workspaceChannel"; +import { Mise } from "./mise"; // Manage your Ruby environment with rv // // Learn more: https://github.com/spinel-coop/rv -export class Rv extends VersionManager { - private static getPossiblePaths(): vscode.Uri[] { +export class Rv extends Mise { + protected static getPossiblePaths(): vscode.Uri[] { return [ pathToUri("/", "home", "linuxbrew", ".linuxbrew", "bin", "rv"), pathToUri("/", "usr", "local", "bin", "rv"), @@ -17,27 +16,15 @@ export class Rv extends VersionManager { ]; } - static async detect( - _workspaceFolder: vscode.WorkspaceFolder, - _outputChannel: WorkspaceChannel, - ): Promise { - return VersionManager.findFirst(Rv.getPossiblePaths()); + protected getVersionManagerName(): string { + return "Rv"; } - async activate(): Promise { - const rvExec = await this.findVersionManagerUri( - "Rv", - "rubyVersionManager.rvExecutablePath", - Rv.getPossiblePaths(), - () => Rv.detect(this.workspaceFolder, this.outputChannel), - ); - const parsedResult = await this.runEnvActivationScript(`${rvExec.fsPath} ruby run --`); + protected getConfigKey(): string { + return "rubyVersionManager.rvExecutablePath"; + } - return { - env: { ...process.env, ...parsedResult.env }, - yjit: parsedResult.yjit, - version: parsedResult.version, - gemPath: parsedResult.gemPath, - }; + protected getExecutionCommand(executablePath: string): string { + return `${executablePath} ruby run --`; } } diff --git a/vscode/src/ruby/versionManager.ts b/vscode/src/ruby/versionManager.ts index 2cfdb818f8..e37b682f6a 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -144,37 +144,4 @@ export abstract class VersionManager { return execName; } - - // Common helper to find a version manager executable with configuration support - protected async findVersionManagerUri( - managerName: string, - configKey: string, - possiblePaths: vscode.Uri[], - detectFn: () => Promise, - ): Promise { - const config = vscode.workspace.getConfiguration("rubyLsp"); - const configuredPath = config.get(configKey); - - if (configuredPath) { - const uri = vscode.Uri.file(configuredPath); - - try { - await vscode.workspace.fs.stat(uri); - return uri; - } catch (_error: any) { - throw new Error(`${managerName} executable configured as ${uri.fsPath}, but that file doesn't exist`); - } - } - - const detectedPath = await detectFn(); - - if (detectedPath) { - return detectedPath; - } - - throw new Error( - `The Ruby LSP version manager is configured to be ${managerName}, but could not find ${managerName} installation. Searched in - ${possiblePaths.map((p) => p.fsPath).join(", ")}`, - ); - } } From 9f265bd2b07da0871b4ba8b8377388df5900e1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 8 Jan 2026 17:22:40 -0500 Subject: [PATCH 13/14] Change the result of detect methods to return DetectionResult objects --- vscode/src/ruby.ts | 7 ++++--- vscode/src/ruby/asdf.ts | 22 +++++++++++++--------- vscode/src/ruby/chruby.ts | 6 +++--- vscode/src/ruby/custom.ts | 6 +++--- vscode/src/ruby/mise.ts | 13 +++++++------ vscode/src/ruby/none.ts | 6 +++--- vscode/src/ruby/rbenv.ts | 6 +++--- vscode/src/ruby/rubyInstaller.ts | 5 +++-- vscode/src/ruby/rvm.ts | 6 +++--- vscode/src/ruby/shadowenv.ts | 9 ++++++--- vscode/src/ruby/versionManager.ts | 6 ++++++ vscode/src/test/suite/ruby/asdf.test.ts | 6 +++--- vscode/src/test/suite/ruby/mise.test.ts | 6 +++--- 13 files changed, 60 insertions(+), 44 deletions(-) diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index 9cd8f8f9f4..303d8d6050 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -6,7 +6,7 @@ import { RubyInterface } from "./common"; import { WorkspaceChannel } from "./workspaceChannel"; import { Shadowenv, UntrustedWorkspaceError } from "./ruby/shadowenv"; import { Chruby } from "./ruby/chruby"; -import { VersionManager } from "./ruby/versionManager"; +import { VersionManager, DetectionResult } from "./ruby/versionManager"; import { Mise } from "./ruby/mise"; import { RubyInstaller } from "./ruby/rubyInstaller"; import { Rbenv } from "./ruby/rbenv"; @@ -42,7 +42,7 @@ interface ManagerClass { manuallySelectRuby: () => Promise, ...args: any[] ): VersionManager; - detect: (workspaceFolder: vscode.WorkspaceFolder, outputChannel: WorkspaceChannel) => Promise; + detect: (workspaceFolder: vscode.WorkspaceFolder, outputChannel: WorkspaceChannel) => Promise; } const VERSION_MANAGERS: Record = { @@ -326,7 +326,8 @@ export class Ruby implements RubyInterface { continue; } - if (await ManagerClass.detect(this.workspaceFolder, this.outputChannel)) { + const result = await ManagerClass.detect(this.workspaceFolder, this.outputChannel); + if (result.type !== "none") { this.versionManager = identifier; return; } diff --git a/vscode/src/ruby/asdf.ts b/vscode/src/ruby/asdf.ts index 64e9744590..f8b970bb97 100644 --- a/vscode/src/ruby/asdf.ts +++ b/vscode/src/ruby/asdf.ts @@ -3,7 +3,7 @@ import path from "path"; import * as vscode from "vscode"; -import { VersionManager, ActivationResult } from "./versionManager"; +import { VersionManager, ActivationResult, DetectionResult } from "./versionManager"; import { WorkspaceChannel } from "../workspaceChannel"; import { pathToUri } from "../common"; @@ -36,28 +36,28 @@ export class Asdf extends VersionManager { static async detect( workspaceFolder: vscode.WorkspaceFolder, outputChannel: WorkspaceChannel, - ): Promise { + ): Promise { // Check for v0.16+ executables first const executablePaths = Asdf.getPossibleExecutablePaths(); const asdfExecPaths = executablePaths.map((dir) => vscode.Uri.joinPath(dir, "asdf")); const execResult = await VersionManager.findFirst(asdfExecPaths); if (execResult) { - return execResult; + return { type: "path", uri: execResult }; } // Check for < v0.16 scripts const scriptResult = await VersionManager.findFirst(Asdf.getPossibleScriptPaths()); if (scriptResult) { - return scriptResult; + return { type: "path", uri: scriptResult }; } // check on PATH const toolExists = await VersionManager.toolExists("asdf", workspaceFolder, outputChannel); if (toolExists) { - return vscode.Uri.file("asdf"); + return { type: "semantic", marker: "asdf" }; } - return undefined; + return { type: "none" }; } async activate(): Promise { @@ -68,9 +68,13 @@ export class Asdf extends VersionManager { if (configuredPath) { asdfUri = vscode.Uri.file(configuredPath); } else { - asdfUri = await Asdf.detect(this.workspaceFolder, this.outputChannel); + const result = await Asdf.detect(this.workspaceFolder, this.outputChannel); - if (!asdfUri) { + if (result.type === "path") { + asdfUri = result.uri; + } else if (result.type === "semantic") { + // Use ASDF from PATH + } else { throw new Error( `Could not find ASDF installation. Searched in ${[ ...Asdf.getPossibleExecutablePaths(), @@ -82,7 +86,7 @@ export class Asdf extends VersionManager { let baseCommand: string; - if (asdfUri.fsPath !== "/asdf") { + if (asdfUri) { const asdfPath = asdfUri.fsPath; // If there's no extension name, then we are using the ASDF executable directly. If there is an extension, then it's // a shell script and we have to source it first diff --git a/vscode/src/ruby/chruby.ts b/vscode/src/ruby/chruby.ts index ccf57c34c2..c0f2daa21c 100644 --- a/vscode/src/ruby/chruby.ts +++ b/vscode/src/ruby/chruby.ts @@ -6,7 +6,7 @@ import * as vscode from "vscode"; import { WorkspaceChannel } from "../workspaceChannel"; import { pathToUri } from "../common"; -import { ActivationResult, VersionManager, ACTIVATION_SEPARATOR } from "./versionManager"; +import { ActivationResult, VersionManager, ACTIVATION_SEPARATOR, DetectionResult } from "./versionManager"; interface RubyVersion { engine?: string; @@ -21,9 +21,9 @@ export class Chruby extends VersionManager { static async detect( workspaceFolder: vscode.WorkspaceFolder, outputChannel: WorkspaceChannel, - ): Promise { + ): Promise { const exists = await VersionManager.toolExists("chruby", workspaceFolder, outputChannel); - return exists ? vscode.Uri.file("chruby") : undefined; + return exists ? { type: "semantic", marker: "chruby" } : { type: "none" }; } // Only public so that we can point to a different directory in tests diff --git a/vscode/src/ruby/custom.ts b/vscode/src/ruby/custom.ts index f64924ddcb..df86d94260 100644 --- a/vscode/src/ruby/custom.ts +++ b/vscode/src/ruby/custom.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import { VersionManager, ActivationResult } from "./versionManager"; +import { VersionManager, ActivationResult, DetectionResult } from "./versionManager"; // Custom // @@ -12,8 +12,8 @@ export class Custom extends VersionManager { static async detect( _workspaceFolder: vscode.WorkspaceFolder, _outputChannel: vscode.LogOutputChannel, - ): Promise { - return undefined; + ): Promise { + return { type: "none" }; } async activate(): Promise { diff --git a/vscode/src/ruby/mise.ts b/vscode/src/ruby/mise.ts index b5ce8cbbf9..90a7d902fa 100644 --- a/vscode/src/ruby/mise.ts +++ b/vscode/src/ruby/mise.ts @@ -2,7 +2,7 @@ import os from "os"; import * as vscode from "vscode"; -import { VersionManager, ActivationResult } from "./versionManager"; +import { VersionManager, ActivationResult, DetectionResult } from "./versionManager"; import { WorkspaceChannel } from "../workspaceChannel"; import { pathToUri } from "../common"; @@ -13,8 +13,9 @@ export class Mise extends VersionManager { static async detect( _workspaceFolder: vscode.WorkspaceFolder, _outputChannel: WorkspaceChannel, - ): Promise { - return this.findFirst(this.getPossiblePaths()); + ): Promise { + const result = await this.findFirst(this.getPossiblePaths()); + return result ? { type: "path", uri: result } : { type: "none" }; } async activate(): Promise { @@ -75,10 +76,10 @@ export class Mise extends VersionManager { } } - const detectedPath = await constructor.detect(this.workspaceFolder, this.outputChannel); + const result = await constructor.detect(this.workspaceFolder, this.outputChannel); - if (detectedPath) { - return detectedPath; + if (result.type === "path") { + return result.uri; } const possiblePaths = constructor.getPossiblePaths(); diff --git a/vscode/src/ruby/none.ts b/vscode/src/ruby/none.ts index a6fb89079c..44f6821e6e 100644 --- a/vscode/src/ruby/none.ts +++ b/vscode/src/ruby/none.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import { WorkspaceChannel } from "../workspaceChannel"; -import { VersionManager, ActivationResult } from "./versionManager"; +import { VersionManager, ActivationResult, DetectionResult } from "./versionManager"; // None // @@ -17,9 +17,9 @@ export class None extends VersionManager { static async detect( _workspaceFolder: vscode.WorkspaceFolder, _outputChannel: vscode.LogOutputChannel, - ): Promise { + ): Promise { // None always matches as the final fallback - return vscode.Uri.file("none"); + return { type: "semantic", marker: "none" }; } private readonly rubyPath: string; diff --git a/vscode/src/ruby/rbenv.ts b/vscode/src/ruby/rbenv.ts index b3db87bd17..d145d48000 100644 --- a/vscode/src/ruby/rbenv.ts +++ b/vscode/src/ruby/rbenv.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import { VersionManager, ActivationResult } from "./versionManager"; +import { VersionManager, ActivationResult, DetectionResult } from "./versionManager"; import { WorkspaceChannel } from "../workspaceChannel"; // Seamlessly manage your app’s Ruby environment with rbenv. @@ -10,9 +10,9 @@ export class Rbenv extends VersionManager { static async detect( workspaceFolder: vscode.WorkspaceFolder, outputChannel: WorkspaceChannel, - ): Promise { + ): Promise { const exists = await VersionManager.toolExists("rbenv", workspaceFolder, outputChannel); - return exists ? vscode.Uri.file("rbenv") : undefined; + return exists ? { type: "semantic", marker: "rbenv" } : { type: "none" }; } async activate(): Promise { diff --git a/vscode/src/ruby/rubyInstaller.ts b/vscode/src/ruby/rubyInstaller.ts index cf6ecddcf6..ec538f982c 100644 --- a/vscode/src/ruby/rubyInstaller.ts +++ b/vscode/src/ruby/rubyInstaller.ts @@ -4,6 +4,7 @@ import * as vscode from "vscode"; import { Chruby } from "./chruby"; import { pathToUri } from "../common"; +import { DetectionResult } from "./versionManager"; interface RubyVersion { engine?: string; @@ -21,8 +22,8 @@ export class RubyInstaller extends Chruby { static async detect( _workspaceFolder: vscode.WorkspaceFolder, _outputChannel: vscode.LogOutputChannel, - ): Promise { - return os.platform() === "win32" ? vscode.Uri.file("RubyInstaller") : undefined; + ): Promise { + return os.platform() === "win32" ? { type: "semantic", marker: "RubyInstaller" } : { type: "none" }; } // Environment variables are case sensitive on Windows when we access them through NodeJS. We need to ensure that diff --git a/vscode/src/ruby/rvm.ts b/vscode/src/ruby/rvm.ts index 47fd7fced4..b8bd24709d 100644 --- a/vscode/src/ruby/rvm.ts +++ b/vscode/src/ruby/rvm.ts @@ -2,7 +2,7 @@ import os from "os"; import * as vscode from "vscode"; -import { ActivationResult, VersionManager } from "./versionManager"; +import { ActivationResult, VersionManager, DetectionResult } from "./versionManager"; import { WorkspaceChannel } from "../workspaceChannel"; import { pathToUri } from "../common"; @@ -14,9 +14,9 @@ export class Rvm extends VersionManager { static async detect( workspaceFolder: vscode.WorkspaceFolder, outputChannel: WorkspaceChannel, - ): Promise { + ): Promise { const exists = await VersionManager.toolExists("rvm", workspaceFolder, outputChannel); - return exists ? vscode.Uri.file("rvm") : undefined; + return exists ? { type: "semantic", marker: "rvm" } : { type: "none" }; } async activate(): Promise { diff --git a/vscode/src/ruby/shadowenv.ts b/vscode/src/ruby/shadowenv.ts index b6b03d6e09..6a33ac3ec2 100644 --- a/vscode/src/ruby/shadowenv.ts +++ b/vscode/src/ruby/shadowenv.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import { asyncExec } from "../common"; -import { VersionManager, ActivationResult } from "./versionManager"; +import { VersionManager, ActivationResult, DetectionResult } from "./versionManager"; // Shadowenv is a tool that allows managing environment variables upon entering a directory. It allows users to manage // which Ruby version should be used for each project, in addition to other customizations such as GEM_HOME. @@ -23,9 +23,12 @@ export class Shadowenv extends VersionManager { static async detect( workspaceFolder: vscode.WorkspaceFolder, _outputChannel: vscode.LogOutputChannel, - ): Promise { + ): Promise { const exists = await Shadowenv.shadowenvDirExists(workspaceFolder.uri); - return exists ? vscode.Uri.joinPath(workspaceFolder.uri, ".shadowenv.d") : undefined; + if (exists) { + return { type: "path", uri: vscode.Uri.joinPath(workspaceFolder.uri, ".shadowenv.d") }; + } + return { type: "none" }; } async activate(): Promise { diff --git a/vscode/src/ruby/versionManager.ts b/vscode/src/ruby/versionManager.ts index e37b682f6a..353db6dac5 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -13,6 +13,12 @@ export interface ActivationResult { gemPath: string[]; } +// Detection result types for version managers +export type DetectionResult = + | { type: "semantic"; marker: string } // Detected by semantic markers (e.g., "chruby", "rbenv") + | { type: "path"; uri: vscode.Uri } // Detected with actual file/directory path + | { type: "none" }; // No detection (not found or not applicable) + // Changes to either one of these values have to be synchronized with a corresponding update in `activation.rb` export const ACTIVATION_SEPARATOR = "RUBY_LSP_ACTIVATION_SEPARATOR"; export const VALUE_SEPARATOR = "RUBY_LSP_VS"; diff --git a/vscode/src/test/suite/ruby/asdf.test.ts b/vscode/src/test/suite/ruby/asdf.test.ts index aa79e15773..99936bf138 100644 --- a/vscode/src/test/suite/ruby/asdf.test.ts +++ b/vscode/src/test/suite/ruby/asdf.test.ts @@ -50,7 +50,7 @@ suite("Asdf", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); - sandbox.stub(Asdf, "detect").resolves(vscode.Uri.file(`${os.homedir()}/.asdf/asdf.sh`)); + sandbox.stub(Asdf, "detect").resolves({ type: "path", uri: vscode.Uri.file(`${os.homedir()}/.asdf/asdf.sh`) }); sandbox.stub(vscode.env, "shell").get(() => "/bin/bash"); const { env, version, yjit } = await asdf.activate(); @@ -82,7 +82,7 @@ suite("Asdf", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); - sandbox.stub(Asdf, "detect").resolves(vscode.Uri.file(`${os.homedir()}/.asdf/asdf.fish`)); + sandbox.stub(Asdf, "detect").resolves({ type: "path", uri: vscode.Uri.file(`${os.homedir()}/.asdf/asdf.fish`) }); sandbox.stub(vscode.env, "shell").get(() => "/opt/homebrew/bin/fish"); const { env, version, yjit } = await asdf.activate(); @@ -114,7 +114,7 @@ suite("Asdf", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); - sandbox.stub(Asdf, "detect").resolves(vscode.Uri.file("/opt/homebrew/bin/asdf")); + sandbox.stub(Asdf, "detect").resolves({ type: "path", uri: vscode.Uri.file("/opt/homebrew/bin/asdf") }); sandbox.stub(vscode.workspace, "fs").value({ stat: () => Promise.resolve(undefined), diff --git a/vscode/src/test/suite/ruby/mise.test.ts b/vscode/src/test/suite/ruby/mise.test.ts index a7a84d0048..beaa1a26d4 100644 --- a/vscode/src/test/suite/ruby/mise.test.ts +++ b/vscode/src/test/suite/ruby/mise.test.ts @@ -146,7 +146,7 @@ suite("Mise", () => { const result = await Mise.detect(workspaceFolder, outputChannel); - assert.strictEqual(result?.fsPath, vscode.Uri.file(misePath).fsPath); + assert.strictEqual(result.type === "path" ? result.uri.fsPath : undefined, vscode.Uri.file(misePath).fsPath); getPossiblePathsStub.restore(); fs.rmSync(tempDir, { recursive: true, force: true }); @@ -171,7 +171,7 @@ suite("Mise", () => { const result = await Mise.detect(workspaceFolder, outputChannel); - assert.strictEqual(result, undefined); + assert.strictEqual(result.type, "none"); getPossiblePathsStub.restore(); fs.rmSync(tempDir, { recursive: true, force: true }); @@ -200,7 +200,7 @@ suite("Mise", () => { const result = await Mise.detect(workspaceFolder, outputChannel); - assert.strictEqual(result?.fsPath, vscode.Uri.file(secondPath).fsPath); + assert.strictEqual(result.type === "path" ? result.uri.fsPath : undefined, vscode.Uri.file(secondPath).fsPath); getPossiblePathsStub.restore(); fs.rmSync(tempDir, { recursive: true, force: true }); From 44851999d85bbd04c96b3c7995edc2cabde942c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 22 Jan 2026 15:15:13 -0500 Subject: [PATCH 14/14] Make sure Auto doesn't behave as None and fail for developers This situation can only happen if discoverVersionManager() fails to detect any version manager and leaves identifier as 'auto', which should never happen since `None` is the fallback. This is just an additional safety check to catch bugs early. --- vscode/src/ruby.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index 303d8d6050..c7658c7e20 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -45,7 +45,7 @@ interface ManagerClass { detect: (workspaceFolder: vscode.WorkspaceFolder, outputChannel: WorkspaceChannel) => Promise; } -const VERSION_MANAGERS: Record = { +const VERSION_MANAGERS: Record = { [ManagerIdentifier.Shadowenv]: Shadowenv, [ManagerIdentifier.Asdf]: Asdf, [ManagerIdentifier.Chruby]: Chruby, @@ -55,8 +55,8 @@ const VERSION_MANAGERS: Record = { [ManagerIdentifier.Mise]: Mise, [ManagerIdentifier.RubyInstaller]: RubyInstaller, [ManagerIdentifier.Custom]: Custom, - [ManagerIdentifier.Auto]: None, // Auto is handled specially [ManagerIdentifier.None]: None, // None is last as the fallback + [ManagerIdentifier.Auto]: undefined, // Auto is handled specially }; export class Ruby implements RubyInterface { @@ -294,6 +294,15 @@ export class Ruby implements RubyInterface { private async runManagerActivation() { const ManagerClass = VERSION_MANAGERS[this.versionManager.identifier]; + + if (!ManagerClass) { + throw new Error( + `BUG: No ManagerClass found for identifier '${this.versionManager.identifier}'. ` + + `This indicates either: (1) discoverVersionManager() failed to detect any version manager and left identifier as 'auto', ` + + `or (2) a new ManagerIdentifier enum value was added without updating VERSION_MANAGERS.`, + ); + } + const manager = new ManagerClass( this.workspaceFolder, this.outputChannel,