From ed9b783909cfe37e785c1ca9051ffadf5d7b8eec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 11 Feb 2026 14:10:08 -0500 Subject: [PATCH 1/3] Expand ${workspaceFolder} in bundleGemfile setting VS Code does not auto-expand predefined variables in extension settings retrieved via getConfiguration(), only in launch.json and tasks.json. This caused ENOENT errors when users configured bundleGemfile with ${workspaceFolder}. Add an expandPath helper to resolve the variable before using the path. Fixes #3829 --- vscode/src/common.ts | 6 +++++ vscode/src/ruby.ts | 5 ++-- vscode/src/ruby/versionManager.ts | 5 ++-- vscode/src/test/suite/common.test.ts | 34 ++++++++++++++++++++++++++-- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/vscode/src/common.ts b/vscode/src/common.ts index d7f38cbd50..2e43d7e0fd 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -88,6 +88,12 @@ export const FEATURE_FLAGS = { type FeatureFlagConfigurationKey = keyof typeof FEATURE_FLAGS | "all"; +// Expands VS Code predefined variables (e.g., `${workspaceFolder}`) in a configuration string, since VS Code does not +// automatically expand variables in extension settings retrieved via `getConfiguration()` +export function expandPath(value: string, workspaceFolder: vscode.WorkspaceFolder): string { + return value.replace(/\$\{workspaceFolder\}/g, workspaceFolder.uri.fsPath); +} + // Creates a debounced version of a function with the specified delay. If the function is invoked before the delay runs // out, then the previous invocation of the function gets cancelled and a new one is scheduled. // diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index d0a6d83716..958d06aa40 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 { asyncExec, expandPath, RubyInterface } from "./common"; import { WorkspaceChannel } from "./workspaceChannel"; import { Shadowenv, UntrustedWorkspaceError } from "./ruby/shadowenv"; import { Chruby } from "./ruby/chruby"; @@ -83,7 +83,8 @@ export class Ruby implements RubyInterface { this.outputChannel = outputChannel; this.telemetry = telemetry; - const customBundleGemfile: string = vscode.workspace.getConfiguration("rubyLsp").get("bundleGemfile")!; + const rawBundleGemfile: string = vscode.workspace.getConfiguration("rubyLsp").get("bundleGemfile")!; + const customBundleGemfile = expandPath(rawBundleGemfile, this.workspaceFolder); if (customBundleGemfile.length > 0) { this.customBundleGemfile = path.isAbsolute(customBundleGemfile) diff --git a/vscode/src/ruby/versionManager.ts b/vscode/src/ruby/versionManager.ts index 66c591ce7f..f5d4aacd2b 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -4,7 +4,7 @@ import os from "os"; import * as vscode from "vscode"; import { WorkspaceChannel } from "../workspaceChannel"; -import { asyncExec } from "../common"; +import { asyncExec, expandPath } from "../common"; export interface ActivationResult { env: NodeJS.ProcessEnv; @@ -36,7 +36,8 @@ export abstract class VersionManager { this.outputChannel = outputChannel; this.context = context; this.manuallySelectRuby = manuallySelectRuby; - const customBundleGemfile: string = vscode.workspace.getConfiguration("rubyLsp").get("bundleGemfile")!; + const rawBundleGemfile: string = vscode.workspace.getConfiguration("rubyLsp").get("bundleGemfile")!; + const customBundleGemfile = expandPath(rawBundleGemfile, this.workspaceFolder); if (customBundleGemfile.length > 0) { this.customBundleGemfile = path.isAbsolute(customBundleGemfile) diff --git a/vscode/src/test/suite/common.test.ts b/vscode/src/test/suite/common.test.ts index b4588bc7c8..1d83deb205 100644 --- a/vscode/src/test/suite/common.test.ts +++ b/vscode/src/test/suite/common.test.ts @@ -3,9 +3,39 @@ import * as assert from "assert"; import * as vscode from "vscode"; import sinon from "sinon"; -import { featureEnabled, FEATURE_FLAGS } from "../../common"; +import { expandPath, featureEnabled, FEATURE_FLAGS } from "../../common"; + +suite("expandPath", () => { + const workspaceFolder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file("/home/user/project"), + name: "project", + index: 0, + }; + + // eslint-disable-next-line no-template-curly-in-string + test("replaces ${workspaceFolder} with the workspace folder path", () => { + // eslint-disable-next-line no-template-curly-in-string + const result = expandPath("${workspaceFolder}/Gemfile", workspaceFolder); + assert.strictEqual(result, "/home/user/project/Gemfile"); + }); + + // eslint-disable-next-line no-template-curly-in-string + test("replaces multiple occurrences of ${workspaceFolder}", () => { + // eslint-disable-next-line no-template-curly-in-string + const result = expandPath("${workspaceFolder}/a:${workspaceFolder}/b", workspaceFolder); + assert.strictEqual(result, "/home/user/project/a:/home/user/project/b"); + }); + + test("returns the string unchanged when there is no variable", () => { + assert.strictEqual(expandPath("Gemfile", workspaceFolder), "Gemfile"); + }); + + test("returns empty string unchanged", () => { + assert.strictEqual(expandPath("", workspaceFolder), ""); + }); +}); -suite("Common", () => { +suite("featureEnabled", () => { let sandbox: sinon.SinonSandbox; setup(() => { From 3e216b8a3a7a3ddaf130f0f0c7d0d326cb34e1c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 11 Feb 2026 14:58:18 -0500 Subject: [PATCH 2/3] Validate bundleGemfile existence early in activateRuby Move the existence check for the configured bundle gemfile to the top of activateRuby, before any version manager runs. This consolidates the validation into a single place and gives users a clear error message upfront instead of a confusing shell error later. --- vscode/src/ruby.ts | 21 ++++++++++--------- vscode/src/test/suite/ruby.test.ts | 33 ++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 10 deletions(-) diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index 958d06aa40..75452a6673 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -121,6 +121,14 @@ export class Ruby implements RubyInterface { this.versionManager = versionManager; this._error = false; + if (this.customBundleGemfile) { + try { + await vscode.workspace.fs.stat(vscode.Uri.file(this.customBundleGemfile)); + } catch (_error: any) { + throw new Error(`The configured bundle gemfile ${this.customBundleGemfile} does not exist`); + } + } + const workspaceRubyPath = await this.cachedWorkspaceRubyPath(); if (workspaceRubyPath) { @@ -184,7 +192,7 @@ export class Ruby implements RubyInterface { if (!this.error) { this.fetchRubyVersionInfo(); - await this.setupBundlePath(); + this.setupBundlePath(); } } @@ -343,18 +351,11 @@ export class Ruby implements RubyInterface { } } - private async setupBundlePath() { + private setupBundlePath() { // Some users like to define a completely separate Gemfile for development tools. We allow them to use // `rubyLsp.bundleGemfile` to configure that and need to inject it into the environment - if (!this.customBundleGemfile) { - return; - } - - try { - await vscode.workspace.fs.stat(vscode.Uri.file(this.customBundleGemfile)); + if (this.customBundleGemfile) { this._env.BUNDLE_GEMFILE = this.customBundleGemfile; - } catch (_error: any) { - throw new Error(`The configured bundle gemfile ${this.customBundleGemfile} does not exist`); } } diff --git a/vscode/src/test/suite/ruby.test.ts b/vscode/src/test/suite/ruby.test.ts index b53f4b1390..7f7a8fbb8c 100644 --- a/vscode/src/test/suite/ruby.test.ts +++ b/vscode/src/test/suite/ruby.test.ts @@ -1,5 +1,7 @@ import * as assert from "assert"; import * as path from "path"; +import * as os from "os"; +import * as fs from "fs"; import * as vscode from "vscode"; import sinon from "sinon"; @@ -169,4 +171,35 @@ suite("Ruby environment activation", () => { assert.strictEqual(context.workspaceState.get(`rubyLsp.workspaceRubyPath.${workspaceFolder.name}`), undefined); }); + + test("Raises an error if the configured bundleGemfile does not exist", async () => { + const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const tmpWorkspaceFolder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file(tmpPath), + name: path.basename(tmpPath), + index: 0, + }; + + const nonExistentGemfile = path.join(tmpPath, "nonexistent", "Gemfile"); + + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: (name: string) => { + if (name === "bundleGemfile") { + return nonExistentGemfile; + } else if (name === "rubyVersionManager") { + return { identifier: ManagerIdentifier.None }; + } + + return undefined; + }, + } as unknown as vscode.WorkspaceConfiguration); + + const ruby = new Ruby(context, tmpWorkspaceFolder, outputChannel, FAKE_TELEMETRY); + + await assert.rejects(() => ruby.activateRuby(), { + message: `The configured bundle gemfile ${nonExistentGemfile} does not exist`, + }); + + fs.rmSync(tmpPath, { recursive: true, force: true }); + }); }); From 7a48e40c6fe875fdb3402e504f8830118df2bd51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Wed, 11 Feb 2026 15:10:27 -0500 Subject: [PATCH 3/3] Pass customBundleGemfile from Ruby to VersionManager Instead of having both Ruby and VersionManager independently read, expand, and resolve the bundleGemfile setting, Ruby now passes the resolved value down to VersionManager through the constructor. --- vscode/src/common.ts | 6 --- vscode/src/ruby.ts | 64 ++++++++++++++-------------- vscode/src/ruby/chruby.ts | 3 +- vscode/src/ruby/none.ts | 3 +- vscode/src/ruby/versionManager.ts | 12 ++---- vscode/src/test/suite/common.test.ts | 32 +------------- vscode/src/test/suite/ruby.test.ts | 46 ++++++++++++++++++++ 7 files changed, 85 insertions(+), 81 deletions(-) diff --git a/vscode/src/common.ts b/vscode/src/common.ts index 2e43d7e0fd..d7f38cbd50 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -88,12 +88,6 @@ export const FEATURE_FLAGS = { type FeatureFlagConfigurationKey = keyof typeof FEATURE_FLAGS | "all"; -// Expands VS Code predefined variables (e.g., `${workspaceFolder}`) in a configuration string, since VS Code does not -// automatically expand variables in extension settings retrieved via `getConfiguration()` -export function expandPath(value: string, workspaceFolder: vscode.WorkspaceFolder): string { - return value.replace(/\$\{workspaceFolder\}/g, workspaceFolder.uri.fsPath); -} - // Creates a debounced version of a function with the specified delay. If the function is invoked before the delay runs // out, then the previous invocation of the function gets cancelled and a new one is scheduled. // diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index 75452a6673..c1230cfd74 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, expandPath, RubyInterface } from "./common"; +import { asyncExec, RubyInterface } from "./common"; import { WorkspaceChannel } from "./workspaceChannel"; import { Shadowenv, UntrustedWorkspaceError } from "./ruby/shadowenv"; import { Chruby } from "./ruby/chruby"; @@ -84,12 +84,10 @@ export class Ruby implements RubyInterface { this.telemetry = telemetry; const rawBundleGemfile: string = vscode.workspace.getConfiguration("rubyLsp").get("bundleGemfile")!; - const customBundleGemfile = expandPath(rawBundleGemfile, this.workspaceFolder); + const customBundleGemfile = rawBundleGemfile.replace(/\$\{workspaceFolder\}/g, this.workspaceFolder.uri.fsPath); if (customBundleGemfile.length > 0) { - this.customBundleGemfile = path.isAbsolute(customBundleGemfile) - ? customBundleGemfile - : path.resolve(path.join(this.workspaceFolder.uri.fsPath, customBundleGemfile)); + this.customBundleGemfile = path.resolve(this.workspaceFolder.uri.fsPath, customBundleGemfile); } } @@ -140,6 +138,7 @@ export class Ruby implements RubyInterface { this.context, this.manuallySelectRuby.bind(this), workspaceRubyPath, + this.customBundleGemfile, ), ); } else { @@ -174,6 +173,7 @@ export class Ruby implements RubyInterface { this.context, this.manuallySelectRuby.bind(this), globalRubyPath, + this.customBundleGemfile, ), ); } else { @@ -297,56 +297,54 @@ export class Ruby implements RubyInterface { } private async runManagerActivation() { + const manuallySelectRuby = this.manuallySelectRuby.bind(this); + const args = [ + this.workspaceFolder, + this.outputChannel, + this.context, + manuallySelectRuby, + this.customBundleGemfile, + ] as const; + switch (this.versionManager.identifier) { case ManagerIdentifier.Asdf: - await this.runActivation( - new Asdf(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); + await this.runActivation(new Asdf(...args)); break; case ManagerIdentifier.Chruby: - await this.runActivation( - new Chruby(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); + await this.runActivation(new Chruby(...args)); break; case ManagerIdentifier.Rbenv: - await this.runActivation( - new Rbenv(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); + await this.runActivation(new Rbenv(...args)); break; case ManagerIdentifier.Rvm: - await this.runActivation( - new Rvm(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); + await this.runActivation(new Rvm(...args)); break; case ManagerIdentifier.Mise: - await this.runActivation( - new Mise(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); + await this.runActivation(new Mise(...args)); break; case ManagerIdentifier.Rv: - await this.runActivation( - new Rv(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); + await this.runActivation(new Rv(...args)); break; case ManagerIdentifier.RubyInstaller: - await this.runActivation( - new RubyInstaller(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); + await this.runActivation(new RubyInstaller(...args)); break; case ManagerIdentifier.Custom: - await this.runActivation( - new Custom(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); + await this.runActivation(new Custom(...args)); break; case ManagerIdentifier.None: await this.runActivation( - new None(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), + new None( + this.workspaceFolder, + this.outputChannel, + this.context, + manuallySelectRuby, + undefined, + this.customBundleGemfile, + ), ); break; default: - await this.runActivation( - new Shadowenv(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); + await this.runActivation(new Shadowenv(...args)); break; } } diff --git a/vscode/src/ruby/chruby.ts b/vscode/src/ruby/chruby.ts index 0036efb672..dc1f73a589 100644 --- a/vscode/src/ruby/chruby.ts +++ b/vscode/src/ruby/chruby.ts @@ -28,8 +28,9 @@ export class Chruby extends VersionManager { outputChannel: WorkspaceChannel, context: vscode.ExtensionContext, manuallySelectRuby: () => Promise, + customBundleGemfile?: string, ) { - super(workspaceFolder, outputChannel, context, manuallySelectRuby); + super(workspaceFolder, outputChannel, context, manuallySelectRuby, customBundleGemfile); const configuredRubies = vscode.workspace .getConfiguration("rubyLsp") diff --git a/vscode/src/ruby/none.ts b/vscode/src/ruby/none.ts index 0c6a8c7e54..4db0e80857 100644 --- a/vscode/src/ruby/none.ts +++ b/vscode/src/ruby/none.ts @@ -21,8 +21,9 @@ export class None extends VersionManager { context: vscode.ExtensionContext, manuallySelectRuby: () => Promise, rubyPath?: string, + customBundleGemfile?: string, ) { - super(workspaceFolder, outputChannel, context, manuallySelectRuby); + super(workspaceFolder, outputChannel, context, manuallySelectRuby, customBundleGemfile); this.rubyPath = rubyPath ?? "ruby"; } diff --git a/vscode/src/ruby/versionManager.ts b/vscode/src/ruby/versionManager.ts index f5d4aacd2b..52fa4b94f0 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -4,7 +4,7 @@ import os from "os"; import * as vscode from "vscode"; import { WorkspaceChannel } from "../workspaceChannel"; -import { asyncExec, expandPath } from "../common"; +import { asyncExec } from "../common"; export interface ActivationResult { env: NodeJS.ProcessEnv; @@ -31,19 +31,13 @@ export abstract class VersionManager { outputChannel: WorkspaceChannel, context: vscode.ExtensionContext, manuallySelectRuby: () => Promise, + customBundleGemfile?: string, ) { this.workspaceFolder = workspaceFolder; this.outputChannel = outputChannel; this.context = context; this.manuallySelectRuby = manuallySelectRuby; - const rawBundleGemfile: string = vscode.workspace.getConfiguration("rubyLsp").get("bundleGemfile")!; - const customBundleGemfile = expandPath(rawBundleGemfile, this.workspaceFolder); - - if (customBundleGemfile.length > 0) { - this.customBundleGemfile = path.isAbsolute(customBundleGemfile) - ? customBundleGemfile - : path.resolve(path.join(this.workspaceFolder.uri.fsPath, customBundleGemfile)); - } + this.customBundleGemfile = customBundleGemfile; this.bundleUri = this.customBundleGemfile ? vscode.Uri.file(path.dirname(this.customBundleGemfile)) diff --git a/vscode/src/test/suite/common.test.ts b/vscode/src/test/suite/common.test.ts index 1d83deb205..954797fe45 100644 --- a/vscode/src/test/suite/common.test.ts +++ b/vscode/src/test/suite/common.test.ts @@ -3,37 +3,7 @@ import * as assert from "assert"; import * as vscode from "vscode"; import sinon from "sinon"; -import { expandPath, featureEnabled, FEATURE_FLAGS } from "../../common"; - -suite("expandPath", () => { - const workspaceFolder: vscode.WorkspaceFolder = { - uri: vscode.Uri.file("/home/user/project"), - name: "project", - index: 0, - }; - - // eslint-disable-next-line no-template-curly-in-string - test("replaces ${workspaceFolder} with the workspace folder path", () => { - // eslint-disable-next-line no-template-curly-in-string - const result = expandPath("${workspaceFolder}/Gemfile", workspaceFolder); - assert.strictEqual(result, "/home/user/project/Gemfile"); - }); - - // eslint-disable-next-line no-template-curly-in-string - test("replaces multiple occurrences of ${workspaceFolder}", () => { - // eslint-disable-next-line no-template-curly-in-string - const result = expandPath("${workspaceFolder}/a:${workspaceFolder}/b", workspaceFolder); - assert.strictEqual(result, "/home/user/project/a:/home/user/project/b"); - }); - - test("returns the string unchanged when there is no variable", () => { - assert.strictEqual(expandPath("Gemfile", workspaceFolder), "Gemfile"); - }); - - test("returns empty string unchanged", () => { - assert.strictEqual(expandPath("", workspaceFolder), ""); - }); -}); +import { featureEnabled, FEATURE_FLAGS } from "../../common"; suite("featureEnabled", () => { let sandbox: sinon.SinonSandbox; diff --git a/vscode/src/test/suite/ruby.test.ts b/vscode/src/test/suite/ruby.test.ts index 7f7a8fbb8c..09bfda8b50 100644 --- a/vscode/src/test/suite/ruby.test.ts +++ b/vscode/src/test/suite/ruby.test.ts @@ -172,6 +172,52 @@ suite("Ruby environment activation", () => { assert.strictEqual(context.workspaceState.get(`rubyLsp.workspaceRubyPath.${workspaceFolder.name}`), undefined); }); + // eslint-disable-next-line no-template-curly-in-string + test("Expands ${workspaceFolder} in bundleGemfile setting", async () => { + const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + // Use the URI's fsPath to normalize the drive letter casing on Windows (e.g. c: -> C:) + const normalizedTmpPath = vscode.Uri.file(tmpPath).fsPath; + const gemfilePath = path.resolve(normalizedTmpPath, "Gemfile"); + fs.writeFileSync(gemfilePath, ""); + + const tmpWorkspaceFolder: vscode.WorkspaceFolder = { + uri: vscode.Uri.file(tmpPath), + name: path.basename(tmpPath), + index: 0, + }; + + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: (name: string) => { + if (name === "rubyVersionManager") { + return { identifier: ManagerIdentifier.None }; + } else if (name === "bundleGemfile") { + // eslint-disable-next-line no-template-curly-in-string + return "${workspaceFolder}/Gemfile"; + } + + return undefined; + }, + } as unknown as vscode.WorkspaceConfiguration); + + const envStub = [ + "3.3.5", + "~/.gem/ruby/3.3.5,/opt/rubies/3.3.5/lib/ruby/gems/3.3.0", + "true", + `ANY${VALUE_SEPARATOR}true`, + ].join(FIELD_SEPARATOR); + + sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + + const ruby = new Ruby(context, tmpWorkspaceFolder, outputChannel, FAKE_TELEMETRY); + await ruby.activateRuby(); + + assert.strictEqual(ruby.env.BUNDLE_GEMFILE, gemfilePath); + fs.rmSync(tmpPath, { recursive: true, force: true }); + }); + test("Raises an error if the configured bundleGemfile does not exist", async () => { const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); const tmpWorkspaceFolder: vscode.WorkspaceFolder = {