From 256a44d3c9c3b2f3f19dd31cec705459dff1794d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 8 Jan 2026 00:36:27 -0500 Subject: [PATCH 1/7] Extract logic to check for Windows platform into a helper function and use it across the codebase --- vscode/src/client.ts | 12 ++++++++++-- vscode/src/common.ts | 6 ++++++ vscode/src/debugger.ts | 5 ++--- vscode/src/rails.ts | 5 ++--- vscode/src/ruby/chruby.ts | 5 ++++- vscode/src/ruby/rubyInstaller.ts | 4 ++-- vscode/src/ruby/versionManager.ts | 10 ++++++---- vscode/src/test/suite/client.test.ts | 3 ++- vscode/src/test/suite/debugger.test.ts | 5 ++--- vscode/src/test/suite/launch.test.ts | 3 +-- vscode/src/test/suite/rails.test.ts | 4 ++-- vscode/src/test/suite/ruby/asdf.test.ts | 2 +- vscode/src/test/suite/ruby/chruby.test.ts | 4 ++-- vscode/src/test/suite/ruby/custom.test.ts | 2 +- vscode/src/test/suite/ruby/mise.test.ts | 2 +- vscode/src/test/suite/ruby/none.test.ts | 2 +- vscode/src/test/suite/ruby/rbenv.test.ts | 2 +- vscode/src/test/suite/ruby/rubyInstaller.test.ts | 2 +- vscode/src/test/suite/ruby/rvm.test.ts | 2 +- vscode/src/test/suite/ruby/shadowenv.test.ts | 2 +- vscode/src/test/suite/testController.test.ts | 5 +++-- 21 files changed, 52 insertions(+), 35 deletions(-) diff --git a/vscode/src/client.ts b/vscode/src/client.ts index d7cf40784d..a676f2f1ee 100644 --- a/vscode/src/client.ts +++ b/vscode/src/client.ts @@ -30,7 +30,15 @@ import { ErrorCodes, } from "vscode-languageclient/node"; -import { LSP_NAME, ClientInterface, Addon, SUPPORTED_LANGUAGE_IDS, FEATURE_FLAGS, featureEnabled } from "./common"; +import { + LSP_NAME, + ClientInterface, + Addon, + SUPPORTED_LANGUAGE_IDS, + FEATURE_FLAGS, + featureEnabled, + isWindows, +} from "./common"; import { Ruby } from "./ruby"; import { WorkspaceChannel } from "./workspaceChannel"; @@ -121,7 +129,7 @@ function getLspExecutables(workspaceFolder: vscode.WorkspaceFolder, env: NodeJS. } else { const workspacePath = workspaceFolder.uri.fsPath; const command = - path.basename(workspacePath) === "ruby-lsp" && os.platform() !== "win32" + path.basename(workspacePath) === "ruby-lsp" && !isWindows() ? path.join(workspacePath, "exe", "ruby-lsp") : "ruby-lsp"; diff --git a/vscode/src/common.ts b/vscode/src/common.ts index e85f376db1..1de9c64c10 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -1,6 +1,7 @@ import { exec } from "child_process"; import { createHash } from "crypto"; import { promisify } from "util"; +import os from "os"; import * as vscode from "vscode"; import { State } from "vscode-languageclient"; @@ -150,3 +151,8 @@ export function featureEnabled(feature: keyof typeof FEATURE_FLAGS): boolean { export function pathToUri(basePath: string, ...segments: string[]): vscode.Uri { return vscode.Uri.joinPath(vscode.Uri.file(basePath), ...segments); } + +// Helper to check if running on Windows platform +export function isWindows(): boolean { + return os.platform() === "win32"; +} diff --git a/vscode/src/debugger.ts b/vscode/src/debugger.ts index 6b2ed7d35b..17ef07ef09 100644 --- a/vscode/src/debugger.ts +++ b/vscode/src/debugger.ts @@ -1,10 +1,9 @@ import net from "net"; -import os from "os"; import { ChildProcessWithoutNullStreams, spawn } from "child_process"; import * as vscode from "vscode"; -import { LOG_CHANNEL, asyncExec } from "./common"; +import { LOG_CHANNEL, asyncExec, isWindows } from "./common"; import { Workspace } from "./workspace"; class TerminalLogger { @@ -220,7 +219,7 @@ export class Debugger implements vscode.DebugAdapterDescriptorFactory, vscode.De const configuration = session.configuration; const workspaceFolder = configuration.targetFolder; const cwd = workspaceFolder.path; - const port = os.platform() === "win32" ? await this.availablePort() : undefined; + const port = isWindows() ? await this.availablePort() : undefined; return new Promise((resolve, reject) => { const args = ["exec", "rdbg"]; diff --git a/vscode/src/rails.ts b/vscode/src/rails.ts index a7c9b9549d..a066d68d8d 100644 --- a/vscode/src/rails.ts +++ b/vscode/src/rails.ts @@ -1,10 +1,9 @@ -import os from "os"; - import * as vscode from "vscode"; import { Workspace } from "./workspace"; +import { isWindows } from "./common"; -const BASE_COMMAND = os.platform() === "win32" ? "ruby bin/rails" : "bin/rails"; +const BASE_COMMAND = isWindows() ? "ruby bin/rails" : "bin/rails"; export class Rails { private readonly showWorkspacePick: () => Promise; diff --git a/vscode/src/ruby/chruby.ts b/vscode/src/ruby/chruby.ts index c0f2daa21c..8b9b795f1c 100644 --- a/vscode/src/ruby/chruby.ts +++ b/vscode/src/ruby/chruby.ts @@ -15,6 +15,9 @@ interface RubyVersion { class RubyActivationCancellationError extends Error {} +// Delay before attempting fallback Ruby activation (in milliseconds) +const FALLBACK_DELAY_MS = 10000; + // A tool to change the current Ruby version // Learn more: https://github.com/postmodern/chruby export class Chruby extends VersionManager { @@ -301,7 +304,7 @@ export class Chruby extends VersionManager { // If they don't cancel, we wait 10 seconds before falling back so that they are aware of what's happening await new Promise((resolve) => { - setTimeout(resolve, 10000); + setTimeout(resolve, FALLBACK_DELAY_MS); // If the user cancels the fallback, resolve immediately so that they don't have to wait 10 seconds token.onCancellationRequested(() => { diff --git a/vscode/src/ruby/rubyInstaller.ts b/vscode/src/ruby/rubyInstaller.ts index ec538f982c..51aa254876 100644 --- a/vscode/src/ruby/rubyInstaller.ts +++ b/vscode/src/ruby/rubyInstaller.ts @@ -3,7 +3,7 @@ import os from "os"; import * as vscode from "vscode"; import { Chruby } from "./chruby"; -import { pathToUri } from "../common"; +import { pathToUri, isWindows } from "../common"; import { DetectionResult } from "./versionManager"; interface RubyVersion { @@ -23,7 +23,7 @@ export class RubyInstaller extends Chruby { _workspaceFolder: vscode.WorkspaceFolder, _outputChannel: vscode.LogOutputChannel, ): Promise { - return os.platform() === "win32" ? { type: "semantic", marker: "RubyInstaller" } : { type: "none" }; + return isWindows() ? { 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/versionManager.ts b/vscode/src/ruby/versionManager.ts index 353db6dac5..26d4a6a72e 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -1,10 +1,9 @@ import path from "path"; -import os from "os"; import * as vscode from "vscode"; import { WorkspaceChannel } from "../workspaceChannel"; -import { asyncExec } from "../common"; +import { asyncExec, isWindows } from "../common"; export interface ActivationResult { env: NodeJS.ProcessEnv; @@ -21,6 +20,9 @@ export type DetectionResult = // 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"; + +// Timeout for tool detection commands (in milliseconds) +const TOOL_DETECTION_TIMEOUT_MS = 1000; export const VALUE_SEPARATOR = "RUBY_LSP_VS"; export const FIELD_SEPARATOR = "RUBY_LSP_FS"; @@ -87,7 +89,7 @@ export abstract class VersionManager { await asyncExec(command, { cwd: workspaceFolder.uri.fsPath, - timeout: 1000, + timeout: TOOL_DETECTION_TIMEOUT_MS, }); return true; } catch { @@ -120,7 +122,7 @@ export abstract class VersionManager { // If the user has configured a default shell, we use that one since they are probably sourcing their version // manager scripts in that shell's configuration files. On Windows, we never set the shell no matter what to ensure // that activation runs on `cmd.exe` and not PowerShell, which avoids complex quoting and escaping issues. - if (vscode.env.shell.length > 0 && os.platform() !== "win32") { + if (vscode.env.shell.length > 0 && !isWindows()) { shell = vscode.env.shell; } diff --git a/vscode/src/test/suite/client.test.ts b/vscode/src/test/suite/client.test.ts index e5c966b66e..ecd63a7cea 100644 --- a/vscode/src/test/suite/client.test.ts +++ b/vscode/src/test/suite/client.test.ts @@ -33,6 +33,7 @@ import { MAJOR, MINOR } from "../rubyVersion"; import { FAKE_TELEMETRY, FakeLogger } from "./fakeTelemetry"; import { createContext, createRubySymlinks } from "./helpers"; +import { isWindows } from "../../common"; async function launchClient(workspaceUri: vscode.Uri) { const workspaceFolder: vscode.WorkspaceFolder = { @@ -54,7 +55,7 @@ async function launchClient(workspaceUri: vscode.Uri) { createRubySymlinks(); - if (os.platform() === "win32") { + if (isWindows()) { managerConfig = { identifier: ManagerIdentifier.RubyInstaller }; } else { managerConfig = { identifier: ManagerIdentifier.Chruby }; diff --git a/vscode/src/test/suite/debugger.test.ts b/vscode/src/test/suite/debugger.test.ts index af62d29126..2b8703b454 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, pathToUri } from "../../common"; +import { LOG_CHANNEL, asyncExec, pathToUri, isWindows } from "../../common"; import { RUBY_VERSION } from "../rubyVersion"; import { FAKE_TELEMETRY } from "./fakeTelemetry"; @@ -167,8 +167,7 @@ suite("Debugger", () => { }); test("Launching the debugger", async () => { - const manager = - os.platform() === "win32" ? { identifier: ManagerIdentifier.None } : { identifier: ManagerIdentifier.Chruby }; + const manager = isWindows() ? { identifier: ManagerIdentifier.None } : { identifier: ManagerIdentifier.Chruby }; if (process.env.CI) { createRubySymlinks(); diff --git a/vscode/src/test/suite/launch.test.ts b/vscode/src/test/suite/launch.test.ts index e2909c68a0..eab03a31d0 100644 --- a/vscode/src/test/suite/launch.test.ts +++ b/vscode/src/test/suite/launch.test.ts @@ -1,6 +1,5 @@ import assert from "assert"; import path from "path"; -import os from "os"; import * as vscode from "vscode"; import { State } from "vscode-languageclient/node"; @@ -43,7 +42,7 @@ suite("Launch integrations", () => { async function createClient() { const ruby = new Ruby(context, workspaceFolder, outputChannel, FAKE_TELEMETRY); - if (process.env.CI && os.platform() === "win32") { + if (process.env.CI && common.isWindows()) { await ruby.activateRuby({ identifier: ManagerIdentifier.RubyInstaller }); } else if (process.env.CI) { await ruby.activateRuby({ identifier: ManagerIdentifier.Chruby }); diff --git a/vscode/src/test/suite/rails.test.ts b/vscode/src/test/suite/rails.test.ts index 69d66cf84a..fdd0d6a81b 100644 --- a/vscode/src/test/suite/rails.test.ts +++ b/vscode/src/test/suite/rails.test.ts @@ -1,5 +1,4 @@ import * as assert from "assert"; -import os from "os"; import path from "path"; import * as vscode from "vscode"; @@ -8,8 +7,9 @@ import { beforeEach, afterEach } from "mocha"; import { Rails } from "../../rails"; import { Workspace } from "../../workspace"; +import { isWindows } from "../../common"; -const BASE_COMMAND = os.platform() === "win32" ? "ruby bin/rails" : "bin/rails"; +const BASE_COMMAND = isWindows() ? "ruby bin/rails" : "bin/rails"; suite("Rails", () => { const workspacePath = path.dirname(path.dirname(path.dirname(path.dirname(__dirname)))); diff --git a/vscode/src/test/suite/ruby/asdf.test.ts b/vscode/src/test/suite/ruby/asdf.test.ts index 99936bf138..7a058e0a1d 100644 --- a/vscode/src/test/suite/ruby/asdf.test.ts +++ b/vscode/src/test/suite/ruby/asdf.test.ts @@ -13,7 +13,7 @@ import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR, VersionManager import { createContext, FakeContext } from "../helpers"; suite("Asdf", () => { - if (os.platform() === "win32") { + if (common.isWindows()) { // eslint-disable-next-line no-console console.log("Skipping Asdf tests on Windows"); return; diff --git a/vscode/src/test/suite/ruby/chruby.test.ts b/vscode/src/test/suite/ruby/chruby.test.ts index 58d1f65715..64919e565e 100644 --- a/vscode/src/test/suite/ruby/chruby.test.ts +++ b/vscode/src/test/suite/ruby/chruby.test.ts @@ -9,7 +9,7 @@ import sinon from "sinon"; import { Chruby } from "../../../ruby/chruby"; import { WorkspaceChannel } from "../../../workspaceChannel"; -import { LOG_CHANNEL } from "../../../common"; +import { isWindows, LOG_CHANNEL } from "../../../common"; import { RUBY_VERSION, MAJOR, MINOR, VERSION_REGEX } from "../../rubyVersion"; import { ActivationResult } from "../../../ruby/versionManager"; import { createContext, FakeContext } from "../helpers"; @@ -38,7 +38,7 @@ function createRubySymlinks(destination: string) { } suite("Chruby", () => { - if (os.platform() === "win32") { + if (isWindows()) { // eslint-disable-next-line no-console console.log("Skipping Chruby tests on Windows"); return; diff --git a/vscode/src/test/suite/ruby/custom.test.ts b/vscode/src/test/suite/ruby/custom.test.ts index 0eec0275e8..aee241e159 100644 --- a/vscode/src/test/suite/ruby/custom.test.ts +++ b/vscode/src/test/suite/ruby/custom.test.ts @@ -50,7 +50,7 @@ suite("Custom", () => { const activationUri = vscode.Uri.joinPath(context.extensionUri, "activation.rb"); // We must not set the shell on Windows - const shell = os.platform() === "win32" ? undefined : vscode.env.shell; + const shell = common.isWindows() ? undefined : vscode.env.shell; assert.ok( execStub.calledOnceWithExactly( diff --git a/vscode/src/test/suite/ruby/mise.test.ts b/vscode/src/test/suite/ruby/mise.test.ts index beaa1a26d4..d3c3e11441 100644 --- a/vscode/src/test/suite/ruby/mise.test.ts +++ b/vscode/src/test/suite/ruby/mise.test.ts @@ -14,7 +14,7 @@ import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../../.. import { createContext, FakeContext } from "../helpers"; suite("Mise", () => { - if (os.platform() === "win32") { + if (common.isWindows()) { // eslint-disable-next-line no-console console.log("Skipping Mise tests on Windows"); return; diff --git a/vscode/src/test/suite/ruby/none.test.ts b/vscode/src/test/suite/ruby/none.test.ts index 787993072f..38a68749e5 100644 --- a/vscode/src/test/suite/ruby/none.test.ts +++ b/vscode/src/test/suite/ruby/none.test.ts @@ -49,7 +49,7 @@ suite("None", () => { const activationUri = vscode.Uri.joinPath(context.extensionUri, "activation.rb"); // We must not set the shell on Windows - const shell = os.platform() === "win32" ? undefined : vscode.env.shell; + const shell = common.isWindows() ? undefined : vscode.env.shell; assert.ok( execStub.calledOnceWithExactly(`ruby -EUTF-8:UTF-8 '${activationUri.fsPath}'`, { diff --git a/vscode/src/test/suite/ruby/rbenv.test.ts b/vscode/src/test/suite/ruby/rbenv.test.ts index 3ed1a72076..e76d5e2cf6 100644 --- a/vscode/src/test/suite/ruby/rbenv.test.ts +++ b/vscode/src/test/suite/ruby/rbenv.test.ts @@ -14,7 +14,7 @@ import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../../.. import { createContext, FakeContext } from "../helpers"; suite("Rbenv", () => { - if (os.platform() === "win32") { + if (common.isWindows()) { // eslint-disable-next-line no-console console.log("Skipping Rbenv tests on Windows"); return; diff --git a/vscode/src/test/suite/ruby/rubyInstaller.test.ts b/vscode/src/test/suite/ruby/rubyInstaller.test.ts index a962d334f0..3fa73f3eaf 100644 --- a/vscode/src/test/suite/ruby/rubyInstaller.test.ts +++ b/vscode/src/test/suite/ruby/rubyInstaller.test.ts @@ -16,7 +16,7 @@ import { ACTIVATION_SEPARATOR } from "../../../ruby/versionManager"; import { createRubySymlinks, createContext, FakeContext } from "../helpers"; suite("RubyInstaller", () => { - if (os.platform() !== "win32") { + if (!common.isWindows()) { // eslint-disable-next-line no-console console.log("This test can only run on Windows"); return; diff --git a/vscode/src/test/suite/ruby/rvm.test.ts b/vscode/src/test/suite/ruby/rvm.test.ts index 1d2904f2d2..9275c9487d 100644 --- a/vscode/src/test/suite/ruby/rvm.test.ts +++ b/vscode/src/test/suite/ruby/rvm.test.ts @@ -13,7 +13,7 @@ import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../../.. import { createContext, FakeContext } from "../helpers"; suite("RVM", () => { - if (os.platform() === "win32") { + if (common.isWindows()) { // eslint-disable-next-line no-console console.log("Skipping RVM tests on Windows"); return; diff --git a/vscode/src/test/suite/ruby/shadowenv.test.ts b/vscode/src/test/suite/ruby/shadowenv.test.ts index 8e18b71cb4..d36e6ac903 100644 --- a/vscode/src/test/suite/ruby/shadowenv.test.ts +++ b/vscode/src/test/suite/ruby/shadowenv.test.ts @@ -16,7 +16,7 @@ import * as common from "../../../common"; import { createContext, FakeContext } from "../helpers"; suite("Shadowenv", () => { - if (os.platform() === "win32") { + if (common.isWindows()) { // eslint-disable-next-line no-console console.log("Skipping Shadowenv tests on Windows"); return; diff --git a/vscode/src/test/suite/testController.test.ts b/vscode/src/test/suite/testController.test.ts index be904e49a1..762914f49e 100644 --- a/vscode/src/test/suite/testController.test.ts +++ b/vscode/src/test/suite/testController.test.ts @@ -573,8 +573,9 @@ suite("TestController", () => { test("debugging a test", async () => { await controller.testController.resolveHandler!(undefined); - const manager = - os.platform() === "win32" ? { identifier: ManagerIdentifier.None } : { identifier: ManagerIdentifier.Chruby }; + const manager = common.isWindows() + ? { identifier: ManagerIdentifier.None } + : { identifier: ManagerIdentifier.Chruby }; if (process.env.CI) { createRubySymlinks(); From c0c13b662dd4a84440edbe1ce63271bc28f6d919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 8 Jan 2026 01:00:21 -0500 Subject: [PATCH 2/7] Make sure all errors interface are the same for the ruby version managers --- vscode/src/ruby.ts | 3 +- vscode/src/ruby/asdf.ts | 13 ++- vscode/src/ruby/chruby.ts | 31 +++--- vscode/src/ruby/custom.ts | 6 +- vscode/src/ruby/errors.ts | 161 +++++++++++++++++++++++++++++ vscode/src/ruby/mise.ts | 9 +- vscode/src/ruby/rbenv.ts | 3 +- vscode/src/ruby/rubyInstaller.ts | 8 +- vscode/src/ruby/rvm.ts | 6 +- vscode/src/ruby/shadowenv.ts | 26 ++--- vscode/src/test/suite/ruby.test.ts | 3 +- 11 files changed, 218 insertions(+), 51 deletions(-) create mode 100644 vscode/src/ruby/errors.ts diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index c7658c7e20..f8a7b1a9b9 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -4,7 +4,8 @@ import * as vscode from "vscode"; import { RubyInterface } from "./common"; import { WorkspaceChannel } from "./workspaceChannel"; -import { Shadowenv, UntrustedWorkspaceError } from "./ruby/shadowenv"; +import { Shadowenv } from "./ruby/shadowenv"; +import { UntrustedWorkspaceError } from "./ruby/errors"; import { Chruby } from "./ruby/chruby"; import { VersionManager, DetectionResult } from "./ruby/versionManager"; import { Mise } from "./ruby/mise"; diff --git a/vscode/src/ruby/asdf.ts b/vscode/src/ruby/asdf.ts index f8b970bb97..9385e8a30f 100644 --- a/vscode/src/ruby/asdf.ts +++ b/vscode/src/ruby/asdf.ts @@ -6,6 +6,7 @@ import * as vscode from "vscode"; import { VersionManager, ActivationResult, DetectionResult } from "./versionManager"; import { WorkspaceChannel } from "../workspaceChannel"; import { pathToUri } from "../common"; +import { ExecutableNotFoundError } from "./errors"; // A tool to manage multiple runtime versions with a single CLI tool // @@ -75,12 +76,10 @@ export class Asdf extends VersionManager { } else if (result.type === "semantic") { // Use ASDF from PATH } else { - throw new Error( - `Could not find ASDF installation. Searched in ${[ - ...Asdf.getPossibleExecutablePaths(), - ...Asdf.getPossibleScriptPaths(), - ].join(", ")}`, - ); + throw new ExecutableNotFoundError("asdf", [ + ...Asdf.getPossibleExecutablePaths().map((uri) => uri.fsPath), + ...Asdf.getPossibleScriptPaths().map((uri) => uri.fsPath), + ]); } } @@ -120,7 +119,7 @@ export class Asdf extends VersionManager { this.outputChannel.info(`Using configured ASDF executable path: ${asdfPath}`); return configuredPath.fsPath; } catch (_error: any) { - throw new Error(`ASDF executable configured as ${configuredPath.fsPath}, but that file doesn't exist`); + throw new ExecutableNotFoundError("asdf", [configuredPath.fsPath], configuredPath.fsPath); } } } diff --git a/vscode/src/ruby/chruby.ts b/vscode/src/ruby/chruby.ts index 8b9b795f1c..e658926aa4 100644 --- a/vscode/src/ruby/chruby.ts +++ b/vscode/src/ruby/chruby.ts @@ -5,6 +5,12 @@ import * as vscode from "vscode"; import { WorkspaceChannel } from "../workspaceChannel"; import { pathToUri } from "../common"; +import { + RubyVersionFileError, + RubyInstallationNotFoundError, + RubyVersionFileNotFoundError, + ActivationCancellationError, +} from "./errors"; import { ActivationResult, VersionManager, ACTIVATION_SEPARATOR, DetectionResult } from "./versionManager"; @@ -13,8 +19,6 @@ interface RubyVersion { version: string; } -class RubyActivationCancellationError extends Error {} - // Delay before attempting fallback Ruby activation (in milliseconds) const FALLBACK_DELAY_MS = 10000; @@ -70,7 +74,7 @@ export class Chruby extends VersionManager { rubyUri = fallback.uri; } } catch (error: any) { - if (error instanceof RubyActivationCancellationError) { + if (error instanceof ActivationCancellationError) { // Try to re-activate if the user has configured a fallback during cancellation return this.activate(); } @@ -95,7 +99,7 @@ export class Chruby extends VersionManager { rubyUri = fallback.uri; } } catch (error: any) { - if (error instanceof RubyActivationCancellationError) { + if (error instanceof ActivationCancellationError) { // Try to re-activate if the user has configured a fallback during cancellation return this.activate(); } @@ -234,7 +238,7 @@ export class Chruby extends VersionManager { return closest; } - throw new Error("Cannot find any Ruby installations"); + throw new RubyInstallationNotFoundError("chruby"); } // Returns the Ruby version information including version and engine. E.g.: ruby-3.3.0, truffleruby-21.3.0 @@ -256,15 +260,13 @@ export class Chruby extends VersionManager { } if (version === "") { - throw new Error(`Ruby version file ${rubyVersionUri.fsPath} is empty`); + throw new RubyVersionFileError(rubyVersionUri.fsPath, "empty", version); } const match = /((?[A-Za-z]+)-)?(?\d+\.\d+(\.\d+)?(-[A-Za-z0-9]+)?)/.exec(version); if (!match?.groups) { - throw new Error( - `Ruby version file ${rubyVersionUri.fsPath} contains invalid format. Expected (engine-)?version, got ${version}`, - ); + throw new RubyVersionFileError(rubyVersionUri.fsPath, "invalid_format", version); } this.outputChannel.info(`Discovered Ruby version ${version} from ${rubyVersionUri.fsPath}`); @@ -316,7 +318,7 @@ export class Chruby extends VersionManager { await this.handleCancelledFallback(errorFn); // We throw this error to be able to catch and re-run activation after the user has configured a fallback - throw new RubyActivationCancellationError(); + throw new ActivationCancellationError("chruby"); } return fallbackFn(); @@ -433,17 +435,14 @@ export class Chruby extends VersionManager { } } - throw new Error("Cannot find any Ruby installations"); + throw new RubyInstallationNotFoundError("chruby"); } private missingRubyError(version: string) { - return new Error(`Cannot find Ruby installation for version ${version}`); + return new RubyInstallationNotFoundError("chruby", version); } private rubyVersionError() { - return new Error( - `Cannot find .ruby-version file. Please specify the Ruby version in a - .ruby-version either in ${this.bundleUri.fsPath} or in a parent directory`, - ); + return new RubyVersionFileNotFoundError("chruby", this.bundleUri.fsPath); } } diff --git a/vscode/src/ruby/custom.ts b/vscode/src/ruby/custom.ts index df86d94260..8e8831ca36 100644 --- a/vscode/src/ruby/custom.ts +++ b/vscode/src/ruby/custom.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import { VersionManager, ActivationResult, DetectionResult } from "./versionManager"; +import { MissingConfigurationError } from "./errors"; // Custom // @@ -32,10 +33,7 @@ export class Custom extends VersionManager { const customCommand: string | undefined = configuration.get("customRubyCommand"); if (customCommand === undefined) { - throw new Error( - "The customRubyCommand configuration must be set when 'custom' is selected as the version manager. \ - See the [README](https://shopify.github.io/ruby-lsp/version-managers.html) for instructions.", - ); + throw new MissingConfigurationError("custom", "rubyLsp.customRubyCommand"); } return customCommand; diff --git a/vscode/src/ruby/errors.ts b/vscode/src/ruby/errors.ts new file mode 100644 index 0000000000..d3b103c47d --- /dev/null +++ b/vscode/src/ruby/errors.ts @@ -0,0 +1,161 @@ +// Custom error types for Ruby version manager operations + +/** + * Base class for all Ruby version manager errors. + * Provides structured error information including the version manager name and error code. + */ +export abstract class RubyVersionManagerError extends Error { + public readonly versionManager: string; + public readonly errorCode: string; + + constructor(message: string, versionManager: string, errorCode: string) { + super(message); + this.name = this.constructor.name; + this.versionManager = versionManager; + this.errorCode = errorCode; + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, this.constructor); + } + } +} + +/** + * Thrown when a version manager executable cannot be found at expected locations. + */ +export class ExecutableNotFoundError extends RubyVersionManagerError { + public readonly searchedPaths: string[]; + public readonly configuredPath?: string; + + constructor(versionManager: string, searchedPaths: string[], configuredPath?: string) { + const message = configuredPath + ? `${versionManager} executable configured as ${configuredPath}, but that file doesn't exist` + : `Cannot find ${versionManager} installation. Searched in ${searchedPaths.join(", ")}`; + super(message, versionManager, "EXECUTABLE_NOT_FOUND"); + this.searchedPaths = searchedPaths; + this.configuredPath = configuredPath; + } +} + +/** + * Thrown when required configuration is missing for a version manager. + */ +export class MissingConfigurationError extends RubyVersionManagerError { + public readonly configKey: string; + + constructor(versionManager: string, configKey: string) { + super( + `The ${configKey} configuration must be set when '${versionManager}' is selected as the version manager. See the [README](https://shopify.github.io/ruby-lsp/version-managers.html) for instructions.`, + versionManager, + "MISSING_CONFIGURATION", + ); + this.configKey = configKey; + } +} + +/** + * Thrown when a Ruby installation cannot be found for the requested version. + */ +export class RubyInstallationNotFoundError extends RubyVersionManagerError { + public readonly requestedVersion?: string; + public readonly searchedPaths?: string[]; + + constructor(versionManager: string, requestedVersion?: string, searchedPaths?: string[]) { + let message: string; + if (searchedPaths && searchedPaths.length > 0) { + message = requestedVersion + ? `Cannot find Ruby installation for version ${requestedVersion}. Searched in ${searchedPaths.join(", ")}` + : `Cannot find any Ruby installations. Searched in ${searchedPaths.join(", ")}`; + } else { + message = requestedVersion + ? `Cannot find Ruby installation for version ${requestedVersion}` + : "Cannot find any Ruby installations"; + } + super(message, versionManager, "RUBY_NOT_FOUND"); + this.requestedVersion = requestedVersion; + this.searchedPaths = searchedPaths; + } +} + +/** + * Thrown when a version manager's required directory structure is not found. + */ +export class VersionManagerDirectoryNotFoundError extends RubyVersionManagerError { + public readonly directoryName: string; + + constructor(versionManager: string, directoryName: string) { + super( + `The Ruby LSP version manager is configured to be ${versionManager}, but no ${directoryName} directory was found in the workspace`, + versionManager, + "MANAGER_DIR_NOT_FOUND", + ); + this.directoryName = directoryName; + } +} + +/** + * Thrown when a .ruby-version file is empty or contains invalid format. + */ +export class RubyVersionFileError extends RubyVersionManagerError { + public readonly filePath: string; + public readonly issue: "empty" | "invalid_format"; + public readonly content?: string; + + constructor(filePath: string, issue: "empty" | "invalid_format", content?: string) { + const message = + issue === "empty" + ? `Ruby version file ${filePath} is empty` + : `Ruby version file ${filePath} contains invalid format. Expected (engine-)?version, got ${content}`; + super(message, "chruby", "VERSION_FILE_ERROR"); + this.filePath = filePath; + this.issue = issue; + this.content = content; + } +} + +/** + * Base class for errors that occur during Ruby environment activation. + */ +export class ActivationError extends RubyVersionManagerError { + public readonly cause?: Error; + + constructor(message: string, versionManager: string, cause?: Error) { + super(message, versionManager, "ACTIVATION_ERROR"); + this.cause = cause; + } +} + +/** + * Thrown when attempting to activate Ruby in an untrusted workspace (e.g., for Shadowenv). + */ +export class UntrustedWorkspaceError extends ActivationError { + constructor(versionManager: string = "shadowenv") { + super("Cannot activate Ruby environment in an untrusted workspace", versionManager); + } +} + +/** + * Thrown when user cancels a Ruby activation fallback operation. + */ +export class ActivationCancellationError extends ActivationError { + constructor(versionManager: string) { + super("Ruby activation was cancelled by user", versionManager); + } +} + +/** + * Thrown when no .ruby-version file can be found in the workspace hierarchy. + */ +export class RubyVersionFileNotFoundError extends RubyVersionManagerError { + public readonly searchedPath: string; + + constructor(versionManager: string, searchedPath: string) { + super( + `Cannot find .ruby-version file. Please specify the Ruby version in a .ruby-version either in ${searchedPath} or in a parent directory`, + versionManager, + "VERSION_FILE_NOT_FOUND", + ); + this.searchedPath = searchedPath; + } +} diff --git a/vscode/src/ruby/mise.ts b/vscode/src/ruby/mise.ts index 90a7d902fa..95ed6c91db 100644 --- a/vscode/src/ruby/mise.ts +++ b/vscode/src/ruby/mise.ts @@ -5,6 +5,7 @@ import * as vscode from "vscode"; import { VersionManager, ActivationResult, DetectionResult } from "./versionManager"; import { WorkspaceChannel } from "../workspaceChannel"; import { pathToUri } from "../common"; +import { ExecutableNotFoundError } from "./errors"; // Mise (mise en place) is a manager for dev tools, environment variables and tasks // @@ -72,7 +73,7 @@ export class Mise extends VersionManager { 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`); + throw new ExecutableNotFoundError(managerName, [uri.fsPath], uri.fsPath); } } @@ -83,9 +84,9 @@ export class Mise extends VersionManager { } 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(", ")}`, + throw new ExecutableNotFoundError( + managerName, + possiblePaths.map((uri) => uri.fsPath), ); } } diff --git a/vscode/src/ruby/rbenv.ts b/vscode/src/ruby/rbenv.ts index d145d48000..1866c73963 100644 --- a/vscode/src/ruby/rbenv.ts +++ b/vscode/src/ruby/rbenv.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode"; import { VersionManager, ActivationResult, DetectionResult } from "./versionManager"; import { WorkspaceChannel } from "../workspaceChannel"; +import { ExecutableNotFoundError } from "./errors"; // Seamlessly manage your app’s Ruby environment with rbenv. // @@ -45,7 +46,7 @@ export class Rbenv extends VersionManager { return path; } catch (_error: any) { - throw new Error(`The Ruby LSP version manager is configured to be rbenv, but ${path} does not exist`); + throw new ExecutableNotFoundError("rbenv", [path], path); } } } diff --git a/vscode/src/ruby/rubyInstaller.ts b/vscode/src/ruby/rubyInstaller.ts index 51aa254876..8e1d877ddd 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, isWindows } from "../common"; +import { RubyInstallationNotFoundError } from "./errors"; import { DetectionResult } from "./versionManager"; interface RubyVersion { @@ -50,9 +51,10 @@ export class RubyInstaller extends Chruby { } } - throw new Error( - `Cannot find installation directory for Ruby version ${rubyVersion.version}.\ - Searched in ${possibleInstallationUris.map((uri) => uri.fsPath).join(", ")}`, + throw new RubyInstallationNotFoundError( + "rubyInstaller", + rubyVersion.version, + possibleInstallationUris.map((uri) => uri.fsPath), ); } diff --git a/vscode/src/ruby/rvm.ts b/vscode/src/ruby/rvm.ts index b8bd24709d..b3d7a5f3e3 100644 --- a/vscode/src/ruby/rvm.ts +++ b/vscode/src/ruby/rvm.ts @@ -5,6 +5,7 @@ import * as vscode from "vscode"; import { ActivationResult, VersionManager, DetectionResult } from "./versionManager"; import { WorkspaceChannel } from "../workspaceChannel"; import { pathToUri } from "../common"; +import { ExecutableNotFoundError } from "./errors"; // Ruby enVironment Manager. It manages Ruby application environments and enables switching between them. // Learn more: @@ -53,8 +54,9 @@ export class Rvm extends VersionManager { } } - throw new Error( - `Cannot find RVM installation directory. Searched in ${possiblePaths.map((uri) => uri.fsPath).join(",")}`, + throw new ExecutableNotFoundError( + "rvm", + possiblePaths.map((uri) => uri.fsPath), ); } } diff --git a/vscode/src/ruby/shadowenv.ts b/vscode/src/ruby/shadowenv.ts index 6a33ac3ec2..6f9b9275ae 100644 --- a/vscode/src/ruby/shadowenv.ts +++ b/vscode/src/ruby/shadowenv.ts @@ -1,6 +1,12 @@ import * as vscode from "vscode"; import { asyncExec } from "../common"; +import { + VersionManagerDirectoryNotFoundError, + UntrustedWorkspaceError, + ExecutableNotFoundError, + ActivationError, +} from "./errors"; import { VersionManager, ActivationResult, DetectionResult } from "./versionManager"; @@ -8,7 +14,6 @@ import { VersionManager, ActivationResult, DetectionResult } from "./versionMana // which Ruby version should be used for each project, in addition to other customizations such as GEM_HOME. // // Learn more: https://github.com/Shopify/shadowenv -export class UntrustedWorkspaceError extends Error {} export class Shadowenv extends VersionManager { private static async shadowenvDirExists(workspaceUri: vscode.Uri): Promise { @@ -34,10 +39,7 @@ export class Shadowenv extends VersionManager { 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", - ); + throw new VersionManagerDirectoryNotFoundError("shadowenv", ".shadowenv.d"); } const shadowenvExec = await this.findExec([vscode.Uri.file("/opt/homebrew/bin")], "shadowenv"); @@ -71,21 +73,21 @@ export class Shadowenv extends VersionManager { return this.activate(); } - throw new UntrustedWorkspaceError("Cannot activate Ruby environment in an untrusted workspace"); + throw new UntrustedWorkspaceError("shadowenv"); } try { await asyncExec("shadowenv --version"); } catch (_error: any) { - throw new Error( - `Shadowenv executable not found. Ensure it is installed and available in the PATH. - This error may happen if your shell configuration is failing to be sourced from the editor or if - another extension is mutating the process PATH.`, - ); + throw new ExecutableNotFoundError("shadowenv", ["PATH"]); } // If it failed for some other reason, present the error to the user - throw new Error(`Failed to activate Ruby environment with Shadowenv: ${error.message}`); + throw new ActivationError( + `Failed to activate Ruby environment with Shadowenv: ${error.message}`, + "shadowenv", + error, + ); } } } diff --git a/vscode/src/test/suite/ruby.test.ts b/vscode/src/test/suite/ruby.test.ts index b53f4b1390..5c90e55a7f 100644 --- a/vscode/src/test/suite/ruby.test.ts +++ b/vscode/src/test/suite/ruby.test.ts @@ -9,7 +9,8 @@ import { Ruby, ManagerIdentifier } from "../../ruby"; import { WorkspaceChannel } from "../../workspaceChannel"; import { LOG_CHANNEL } from "../../common"; import * as common from "../../common"; -import { Shadowenv, UntrustedWorkspaceError } from "../../ruby/shadowenv"; +import { Shadowenv } from "../../ruby/shadowenv"; +import { UntrustedWorkspaceError } from "../../ruby/errors"; import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../../ruby/versionManager"; import { createContext, FakeContext } from "./helpers"; From 155a28fbc02f86430291709706fa84d4f550c4b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 8 Jan 2026 17:28:22 -0500 Subject: [PATCH 3/7] Use `unknown` instead of `error` Using unknown is safer than any because it forces you to narrow the type (like casting to Error) before accessing properties, preventing runtime errors. --- vscode/src/ruby/asdf.ts | 2 +- vscode/src/ruby/chruby.ts | 14 +++++++------- vscode/src/ruby/rubyInstaller.ts | 2 +- vscode/src/ruby/shadowenv.ts | 12 ++++-------- 4 files changed, 13 insertions(+), 17 deletions(-) diff --git a/vscode/src/ruby/asdf.ts b/vscode/src/ruby/asdf.ts index 9385e8a30f..e19485e784 100644 --- a/vscode/src/ruby/asdf.ts +++ b/vscode/src/ruby/asdf.ts @@ -118,7 +118,7 @@ export class Asdf extends VersionManager { await vscode.workspace.fs.stat(configuredPath); this.outputChannel.info(`Using configured ASDF executable path: ${asdfPath}`); return configuredPath.fsPath; - } catch (_error: any) { + } catch (_error: unknown) { throw new ExecutableNotFoundError("asdf", [configuredPath.fsPath], configuredPath.fsPath); } } diff --git a/vscode/src/ruby/chruby.ts b/vscode/src/ruby/chruby.ts index e658926aa4..a4a6d339dc 100644 --- a/vscode/src/ruby/chruby.ts +++ b/vscode/src/ruby/chruby.ts @@ -73,7 +73,7 @@ export class Chruby extends VersionManager { versionInfo = fallback.rubyVersion; rubyUri = fallback.uri; } - } catch (error: any) { + } catch (error: unknown) { if (error instanceof ActivationCancellationError) { // Try to re-activate if the user has configured a fallback during cancellation return this.activate(); @@ -98,7 +98,7 @@ export class Chruby extends VersionManager { versionInfo = fallback.rubyVersion; rubyUri = fallback.uri; } - } catch (error: any) { + } catch (error: unknown) { if (error instanceof ActivationCancellationError) { // Try to re-activate if the user has configured a fallback during cancellation return this.activate(); @@ -147,7 +147,7 @@ export class Chruby extends VersionManager { directories = (await vscode.workspace.fs.readDirectory(uri)).sort((left, right) => right[0].localeCompare(left[0]), ); - } catch (_error: any) { + } catch (_error: unknown) { // If the directory doesn't exist, keep searching this.outputChannel.debug(`Tried searching for Ruby installation in ${uri.fsPath} but it doesn't exist`); continue; @@ -210,7 +210,7 @@ export class Chruby extends VersionManager { }); } }); - } catch (_error: any) { + } catch (_error: unknown) { // If the directory doesn't exist, keep searching this.outputChannel.debug(`Tried searching for Ruby installation in ${uri.fsPath} but it doesn't exist`); continue; @@ -286,7 +286,7 @@ export class Chruby extends VersionManager { try { gemfileContents = await vscode.workspace.fs.readFile(vscode.Uri.joinPath(this.workspaceFolder.uri, "Gemfile")); - } catch (_error: any) { + } catch (_error: unknown) { // The Gemfile doesn't exist } @@ -361,7 +361,7 @@ export class Chruby extends VersionManager { label: directory[0], }); }); - } catch (_error: any) { + } catch (_error: unknown) { continue; } } @@ -428,7 +428,7 @@ export class Chruby extends VersionManager { }, }; } - } catch (_error: any) { + } catch (_error: unknown) { // If the directory doesn't exist, keep searching this.outputChannel.debug(`Tried searching for Ruby installation in ${uri.fsPath} but it doesn't exist`); continue; diff --git a/vscode/src/ruby/rubyInstaller.ts b/vscode/src/ruby/rubyInstaller.ts index 8e1d877ddd..f21aacf1dc 100644 --- a/vscode/src/ruby/rubyInstaller.ts +++ b/vscode/src/ruby/rubyInstaller.ts @@ -46,7 +46,7 @@ export class RubyInstaller extends Chruby { try { await vscode.workspace.fs.stat(installationUri); return vscode.Uri.joinPath(installationUri, "bin", "ruby"); - } catch (_error: any) { + } catch (_error: unknown) { // Continue searching } } diff --git a/vscode/src/ruby/shadowenv.ts b/vscode/src/ruby/shadowenv.ts index 6f9b9275ae..c86dcc0a76 100644 --- a/vscode/src/ruby/shadowenv.ts +++ b/vscode/src/ruby/shadowenv.ts @@ -20,7 +20,7 @@ export class Shadowenv extends VersionManager { try { await vscode.workspace.fs.stat(vscode.Uri.joinPath(workspaceUri, ".shadowenv.d")); return true; - } catch (_error: any) { + } catch (_error: unknown) { return false; } } @@ -57,7 +57,7 @@ export class Shadowenv extends VersionManager { version: parsedResult.version, gemPath: parsedResult.gemPath, }; - } catch (error: any) { + } catch (error: unknown) { const err = error as Error; // If the workspace is untrusted, offer to trust it for the user if (err.message.includes("untrusted shadowenv program")) { @@ -78,16 +78,12 @@ export class Shadowenv extends VersionManager { try { await asyncExec("shadowenv --version"); - } catch (_error: any) { + } catch (_error: unknown) { throw new ExecutableNotFoundError("shadowenv", ["PATH"]); } // If it failed for some other reason, present the error to the user - throw new ActivationError( - `Failed to activate Ruby environment with Shadowenv: ${error.message}`, - "shadowenv", - error, - ); + throw new ActivationError(`Failed to activate Ruby environment with Shadowenv: ${err.message}`, "shadowenv", err); } } } From 10355cffd3c1590af42f4584596c30ee20911ab4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 8 Jan 2026 17:37:41 -0500 Subject: [PATCH 4/7] Extract helper to build the ruby executable uri --- vscode/src/ruby/chruby.ts | 6 +++--- vscode/src/ruby/rubyInstaller.ts | 2 +- vscode/src/ruby/versionManager.ts | 11 +++++++++++ 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/vscode/src/ruby/chruby.ts b/vscode/src/ruby/chruby.ts index a4a6d339dc..a17bbc8e34 100644 --- a/vscode/src/ruby/chruby.ts +++ b/vscode/src/ruby/chruby.ts @@ -157,7 +157,7 @@ export class Chruby extends VersionManager { const targetDirectory = directories.find(([name]) => name.startsWith(versionName)); if (targetDirectory) { - return vscode.Uri.joinPath(uri, targetDirectory[0], "bin", "ruby"); + return this.rubyExecutableUri(uri, targetDirectory[0]); } } } @@ -202,7 +202,7 @@ export class Chruby extends VersionManager { if (match?.groups && match.groups.version.startsWith(major)) { directories.push({ - uri: vscode.Uri.joinPath(uri, name, "bin", "ruby"), + uri: this.rubyExecutableUri(uri, name), rubyVersion: { engine: match.groups.engine, version: match.groups.version, @@ -421,7 +421,7 @@ export class Chruby extends VersionManager { if (targetDirectory && groups) { return { - uri: vscode.Uri.joinPath(uri, targetDirectory[0], "bin", "ruby"), + uri: this.rubyExecutableUri(uri, targetDirectory[0]), rubyVersion: { engine: groups.engine, version: groups.version, diff --git a/vscode/src/ruby/rubyInstaller.ts b/vscode/src/ruby/rubyInstaller.ts index f21aacf1dc..282c80badb 100644 --- a/vscode/src/ruby/rubyInstaller.ts +++ b/vscode/src/ruby/rubyInstaller.ts @@ -45,7 +45,7 @@ export class RubyInstaller extends Chruby { for (const installationUri of possibleInstallationUris) { try { await vscode.workspace.fs.stat(installationUri); - return vscode.Uri.joinPath(installationUri, "bin", "ruby"); + return this.rubyExecutableUri(installationUri); } catch (_error: unknown) { // Continue searching } diff --git a/vscode/src/ruby/versionManager.ts b/vscode/src/ruby/versionManager.ts index 26d4a6a72e..eced12e44b 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -152,4 +152,15 @@ export abstract class VersionManager { return execName; } + + /** + * Constructs a URI for a Ruby executable within a Ruby installation directory + * @param installationUri - The root directory of the Ruby installation + * @param versionDirectory - Optional subdirectory name (e.g., "ruby-3.3.0") + * @returns URI pointing to the Ruby executable (e.g., /path/to/ruby-3.3.0/bin/ruby) + */ + protected rubyExecutableUri(installationUri: vscode.Uri, versionDirectory?: string): vscode.Uri { + const basePath = versionDirectory ? vscode.Uri.joinPath(installationUri, versionDirectory) : installationUri; + return vscode.Uri.joinPath(basePath, "bin", "ruby"); + } } From 93f9cac3054c7c6daebdb4d0cd69700dd25f92ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 8 Jan 2026 17:44:56 -0500 Subject: [PATCH 5/7] Extract method to check if a path exists in the filesystem --- vscode/src/ruby/asdf.ts | 9 ++++----- vscode/src/ruby/rubyInstaller.ts | 7 ++----- vscode/src/ruby/shadowenv.ts | 7 +------ vscode/src/ruby/versionManager.ts | 19 +++++++++++++++---- 4 files changed, 22 insertions(+), 20 deletions(-) diff --git a/vscode/src/ruby/asdf.ts b/vscode/src/ruby/asdf.ts index e19485e784..b8a03449ec 100644 --- a/vscode/src/ruby/asdf.ts +++ b/vscode/src/ruby/asdf.ts @@ -114,12 +114,11 @@ export class Asdf extends VersionManager { const configuredPath = vscode.Uri.file(asdfPath); - try { - await vscode.workspace.fs.stat(configuredPath); - this.outputChannel.info(`Using configured ASDF executable path: ${asdfPath}`); - return configuredPath.fsPath; - } catch (_error: unknown) { + if (!(await VersionManager.pathExists(configuredPath))) { throw new ExecutableNotFoundError("asdf", [configuredPath.fsPath], configuredPath.fsPath); } + + this.outputChannel.info(`Using configured ASDF executable path: ${asdfPath}`); + return configuredPath.fsPath; } } diff --git a/vscode/src/ruby/rubyInstaller.ts b/vscode/src/ruby/rubyInstaller.ts index 282c80badb..289a869783 100644 --- a/vscode/src/ruby/rubyInstaller.ts +++ b/vscode/src/ruby/rubyInstaller.ts @@ -5,7 +5,7 @@ import * as vscode from "vscode"; import { Chruby } from "./chruby"; import { pathToUri, isWindows } from "../common"; import { RubyInstallationNotFoundError } from "./errors"; -import { DetectionResult } from "./versionManager"; +import { DetectionResult, VersionManager } from "./versionManager"; interface RubyVersion { engine?: string; @@ -43,11 +43,8 @@ export class RubyInstaller extends Chruby { ]; for (const installationUri of possibleInstallationUris) { - try { - await vscode.workspace.fs.stat(installationUri); + if (await VersionManager.pathExists(installationUri)) { return this.rubyExecutableUri(installationUri); - } catch (_error: unknown) { - // Continue searching } } diff --git a/vscode/src/ruby/shadowenv.ts b/vscode/src/ruby/shadowenv.ts index c86dcc0a76..e1c61f6ae4 100644 --- a/vscode/src/ruby/shadowenv.ts +++ b/vscode/src/ruby/shadowenv.ts @@ -17,12 +17,7 @@ import { VersionManager, ActivationResult, DetectionResult } from "./versionMana export class Shadowenv extends VersionManager { private static async shadowenvDirExists(workspaceUri: vscode.Uri): Promise { - try { - await vscode.workspace.fs.stat(vscode.Uri.joinPath(workspaceUri, ".shadowenv.d")); - return true; - } catch (_error: unknown) { - return false; - } + return VersionManager.pathExists(vscode.Uri.joinPath(workspaceUri, ".shadowenv.d")); } static async detect( diff --git a/vscode/src/ruby/versionManager.ts b/vscode/src/ruby/versionManager.ts index eced12e44b..fc63cc2dc3 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -64,17 +64,28 @@ export abstract class VersionManager { // 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); + if (await this.pathExists(possiblePath)) { return possiblePath; - } catch (_error: any) { - // Continue looking } } return undefined; } + /** + * Checks if a path exists in the filesystem + * @param uri - The URI to check for existence + * @returns true if the path exists, false otherwise + */ + protected static async pathExists(uri: vscode.Uri): Promise { + try { + await vscode.workspace.fs.stat(uri); + return true; + } catch (_error: unknown) { + return false; + } + } + // Checks if a tool exists by running `tool --version` static async toolExists( tool: string, From fab2340bac031a488981e70a4f1af8e12efe59a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 8 Jan 2026 18:02:55 -0500 Subject: [PATCH 6/7] Improve sorting logic in Chruby version selection Just make this implementation clearer by introducing intermediate variables. --- vscode/src/ruby/chruby.ts | 28 ++++++++++++++++++---------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/vscode/src/ruby/chruby.ts b/vscode/src/ruby/chruby.ts index a17bbc8e34..009867021b 100644 --- a/vscode/src/ruby/chruby.ts +++ b/vscode/src/ruby/chruby.ts @@ -191,6 +191,7 @@ export class Chruby extends VersionManager { rubyVersion: RubyVersion; }> { const [major, minor, _patch] = rubyVersion.version.split("."); + const targetMinor = Number(minor); const directories: { uri: vscode.Uri; rubyVersion: RubyVersion }[] = []; for (const uri of this.rubyInstallationUris) { @@ -217,21 +218,28 @@ export class Chruby extends VersionManager { } } - // Sort the directories based on the difference between the minor version and the requested minor version. On - // conflicts, we use the patch version to break the tie. If there's no distance, we prefer the higher patch version + // Sort to find the closest match: + // 1. Prefer versions with minimal minor version distance + // 2. For equal minor version distance, prefer higher patch version const closest = directories.sort((left, right) => { - const leftVersion = left.rubyVersion.version.split("."); - const rightVersion = right.rubyVersion.version.split("."); + const leftParts = left.rubyVersion.version.split("."); + const rightParts = right.rubyVersion.version.split("."); - const leftDiff = Math.abs(Number(leftVersion[1]) - Number(minor)); - const rightDiff = Math.abs(Number(rightVersion[1]) - Number(minor)); + const leftMinor = Number(leftParts[1]); + const rightMinor = Number(rightParts[1]); - // If the distance to minor version is the same, prefer higher patch number - if (leftDiff === rightDiff) { - return Number(rightVersion[2] || 0) - Number(leftVersion[2] || 0); + const leftMinorDistance = Math.abs(leftMinor - targetMinor); + const rightMinorDistance = Math.abs(rightMinor - targetMinor); + + // Primary sort: prefer closer minor version + if (leftMinorDistance !== rightMinorDistance) { + return leftMinorDistance - rightMinorDistance; } - return leftDiff - rightDiff; + // Secondary sort: for same minor distance, prefer higher patch + const leftPatch = Number(leftParts[2] || 0); + const rightPatch = Number(rightParts[2] || 0); + return rightPatch - leftPatch; })[0]; if (closest) { From 63cb2e6600e0ce983484309e90d1c6234429c225 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Thu, 8 Jan 2026 18:04:59 -0500 Subject: [PATCH 7/7] Improve documentation of Chruby and VersionManager --- vscode/src/ruby/chruby.ts | 65 +++++++++++++++++++++-- vscode/src/ruby/versionManager.ts | 86 +++++++++++++++++++++++++++---- 2 files changed, 137 insertions(+), 14 deletions(-) diff --git a/vscode/src/ruby/chruby.ts b/vscode/src/ruby/chruby.ts index 009867021b..cc1658797e 100644 --- a/vscode/src/ruby/chruby.ts +++ b/vscode/src/ruby/chruby.ts @@ -134,7 +134,16 @@ export class Chruby extends VersionManager { return process.env.PATH; } - // Returns the full URI to the Ruby executable + /** + * Finds the URI to the Ruby executable for a given Ruby version. + * + * This method searches through configured Ruby installation directories + * (like ~/.rubies and /opt/rubies) to find a matching Ruby installation + * based on the provided version information. + * + * @param rubyVersion - The Ruby version to search for, including optional engine name + * @returns URI to the Ruby executable if found, undefined otherwise + */ protected async findRubyUri(rubyVersion: RubyVersion): Promise { const possibleVersionNames = rubyVersion.engine ? [`${rubyVersion.engine}-${rubyVersion.version}`, rubyVersion.version] @@ -165,7 +174,16 @@ export class Chruby extends VersionManager { return undefined; } - // Run the activation script using the Ruby installation we found so that we can discover gem paths + /** + * Runs the activation script to discover Ruby environment information. + * + * This executes a Ruby script (chruby_activation.rb) to determine gem paths, + * YJIT availability, and actual Ruby version for the specified installation. + * + * @param rubyExecutableUri - URI to the Ruby executable to activate + * @param rubyVersion - The Ruby version information + * @returns Object containing default gems path, gem home, YJIT status, and version + */ protected async runActivationScript( rubyExecutableUri: vscode.Uri, rubyVersion: RubyVersion, @@ -186,6 +204,23 @@ export class Chruby extends VersionManager { return { defaultGems, gemHome, yjit: yjit === "true", version }; } + /** + * Finds the closest Ruby installation when exact version match is not available. + * + * This method implements a fallback strategy by: + * 1. Searching for Ruby installations with the same major version + * 2. Sorting to find the version with minimal minor version distance + * 3. For equal distances, preferring higher patch versions + * + * For example, if 3.2.0 is requested but not found: + * - 3.2.1 or 3.2.5 would be preferred (same minor, higher patch) + * - Then 3.1.x or 3.3.x (minor distance of 1) + * - Then 3.0.x or 3.4.x (minor distance of 2) + * + * @param rubyVersion - The requested Ruby version + * @returns Object with URI and version info of the closest installation + * @throws RubyInstallationNotFoundError if no Ruby with matching major version exists + */ private async findClosestRubyInstallation(rubyVersion: RubyVersion): Promise<{ uri: vscode.Uri; rubyVersion: RubyVersion; @@ -249,7 +284,21 @@ export class Chruby extends VersionManager { throw new RubyInstallationNotFoundError("chruby"); } - // Returns the Ruby version information including version and engine. E.g.: ruby-3.3.0, truffleruby-21.3.0 + /** + * Discovers the Ruby version from .ruby-version files. + * + * This method walks up the directory tree starting from the workspace root, + * searching for a .ruby-version file. The first file found is parsed and + * validated using a regex pattern to extract engine (optional) and version. + * + * Supported formats: + * - 3.3.0 (just version) + * - ruby-3.3.0 (engine-version) + * - truffleruby-21.3.0 (alternative engine) + * + * @returns Ruby version information if found, undefined if no .ruby-version exists + * @throws RubyVersionFileError if .ruby-version is empty or has invalid format + */ private async discoverRubyVersion(): Promise { let uri = this.bundleUri; const root = path.parse(uri.fsPath).root; @@ -402,6 +451,16 @@ export class Chruby extends VersionManager { ); } + /** + * Finds a fallback Ruby installation when no .ruby-version file exists. + * + * This searches through configured Ruby installation directories and + * returns the first valid Ruby installation found (sorted by version + * descending, so the latest version is preferred). + * + * @returns Object with URI and version info of the fallback installation + * @throws RubyInstallationNotFoundError if no Ruby installation exists + */ private async findFallbackRuby(): Promise<{ uri: vscode.Uri; rubyVersion: RubyVersion; diff --git a/vscode/src/ruby/versionManager.ts b/vscode/src/ruby/versionManager.ts index fc63cc2dc3..9e3d0cdd84 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -57,11 +57,26 @@ export abstract class VersionManager { : workspaceFolder.uri; } - // Activate the Ruby environment for the version manager, returning all of the necessary information to boot the - // language server + /** + * Activates the Ruby environment for this version manager. + * + * Implementations should discover the Ruby version, locate the Ruby installation, + * and return all necessary environment variables and metadata to boot the Ruby LSP. + * + * @returns Activation result with environment variables, YJIT status, version, and gem paths + * @throws Error if Ruby cannot be activated + */ abstract activate(): Promise; - // Finds the first existing path from a list of possible paths + /** + * Finds the first existing path from a list of possible paths. + * + * This helper iterates through paths in order and returns the first one + * that exists in the filesystem, or undefined if none exist. + * + * @param paths - Array of URIs to check + * @returns First existing URI or undefined if none exist + */ protected static async findFirst(paths: vscode.Uri[]): Promise { for (const possiblePath of paths) { if (await this.pathExists(possiblePath)) { @@ -73,7 +88,8 @@ export abstract class VersionManager { } /** - * Checks if a path exists in the filesystem + * Checks if a path exists in the filesystem. + * * @param uri - The URI to check for existence * @returns true if the path exists, false otherwise */ @@ -86,7 +102,18 @@ export abstract class VersionManager { } } - // Checks if a tool exists by running `tool --version` + /** + * Checks if a version manager tool exists by running its --version command. + * + * This method attempts to execute `tool --version` within the workspace + * to verify the tool is available on the PATH. The command is run in an + * interactive shell to ensure shell initialization files are sourced. + * + * @param tool - Name of the tool to check (e.g., "chruby", "rbenv") + * @param workspaceFolder - Workspace folder to use as working directory + * @param outputChannel - Channel for logging detection attempts + * @returns true if the tool exists and responds to --version, false otherwise + */ static async toolExists( tool: string, workspaceFolder: vscode.WorkspaceFolder, @@ -108,6 +135,17 @@ export abstract class VersionManager { } } + /** + * Runs the Ruby environment activation script to gather environment information. + * + * This executes the activation.rb script using the specified Ruby executable + * to discover environment variables, gem paths, YJIT status, and version. + * The activation script outputs data in a structured format separated by + * ACTIVATION_SEPARATOR and FIELD_SEPARATOR constants. + * + * @param activatedRuby - Command to run Ruby (e.g., "ruby" or full path) + * @returns Activation result with all environment information + */ protected async runEnvActivationScript(activatedRuby: string): Promise { const activationUri = vscode.Uri.joinPath(this.context.extensionUri, "activation.rb"); @@ -125,8 +163,17 @@ export abstract class VersionManager { }; } - // Runs the given command in the directory for the Bundle, using the user's preferred shell and inheriting the current - // process environment + /** + * Runs a shell command in the bundle directory. + * + * This executes the given command using the user's preferred shell (from vscode.env.shell) + * and inherits the current process environment. The shell is used to ensure version manager + * initialization scripts are sourced. On Windows, no shell is specified to avoid PowerShell + * quoting issues. + * + * @param command - Shell command to execute + * @returns Promise resolving to command output + */ protected runScript(command: string) { let shell: string | undefined; @@ -147,8 +194,17 @@ export abstract class VersionManager { }); } - // Tries to find `execName` within the given directories. Prefers the executables found in the given directories over - // finding the executable in the PATH + /** + * Searches for an executable within specified directories. + * + * This method checks each directory for the executable and returns the first + * match found. If not found in any directory, returns the execName itself + * which will attempt to find the executable in the PATH. + * + * @param directories - Array of directory URIs to search + * @param execName - Name of the executable to find + * @returns Full path to executable if found, otherwise the execName itself + */ protected async findExec(directories: vscode.Uri[], execName: string) { for (const uri of directories) { try { @@ -165,10 +221,18 @@ export abstract class VersionManager { } /** - * Constructs a URI for a Ruby executable within a Ruby installation directory + * Constructs a URI for a Ruby executable within a Ruby installation directory. + * + * This helper builds the full path to the ruby executable by combining the + * installation root with an optional version subdirectory and the bin/ruby path. + * + * Examples: + * - rubyExecutableUri(/opt/rubies, "ruby-3.3.0") → /opt/rubies/ruby-3.3.0/bin/ruby + * - rubyExecutableUri(/usr/local) → /usr/local/bin/ruby + * * @param installationUri - The root directory of the Ruby installation * @param versionDirectory - Optional subdirectory name (e.g., "ruby-3.3.0") - * @returns URI pointing to the Ruby executable (e.g., /path/to/ruby-3.3.0/bin/ruby) + * @returns URI pointing to the Ruby executable */ protected rubyExecutableUri(installationUri: vscode.Uri, versionDirectory?: string): vscode.Uri { const basePath = versionDirectory ? vscode.Uri.joinPath(installationUri, versionDirectory) : installationUri;