From 3430d9dda2710bd2ac9e080aa6d821e680310eac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 26 Jan 2026 12:15:04 -0500 Subject: [PATCH 01/14] Introduce the first version manager: ConfiguredRuby, which reads Ruby version from VS Code settings --- src/configuredRuby.ts | 49 ++++++++++++++++++++ src/extension.ts | 32 ++++--------- src/test/configuredRuby.test.ts | 80 +++++++++++++++++++++++++++++++++ src/versionManager.ts | 21 +++++++++ 4 files changed, 158 insertions(+), 24 deletions(-) create mode 100644 src/configuredRuby.ts create mode 100644 src/test/configuredRuby.test.ts create mode 100644 src/versionManager.ts diff --git a/src/configuredRuby.ts b/src/configuredRuby.ts new file mode 100644 index 0000000..b8b6d96 --- /dev/null +++ b/src/configuredRuby.ts @@ -0,0 +1,49 @@ +import * as vscode from "vscode"; +import { RubyDefinition } from "./types"; +import { VersionManager } from "./versionManager"; + +/** + * Interface for workspace configuration + */ +export interface WorkspaceConfigurationInterface { + get(section: string, defaultValue?: T): T | undefined; +} + +/** + * Interface for workspace configuration access + */ +export interface WorkspaceInterface { + getConfiguration(section: string): WorkspaceConfigurationInterface; +} + +/** + * A version manager that respects the configured Ruby in the Workspace or Global configuration. + * This manager doesn't interact with any external version managers - it simply reads from VS Code settings. + */ +export class ConfiguredRuby implements VersionManager { + readonly identifier = "configured"; + readonly name = "Configured Ruby"; + private readonly workspace: WorkspaceInterface; + + constructor(workspace: WorkspaceInterface = vscode.workspace) { + this.workspace = workspace; + } + + getRubyDefinition(): RubyDefinition | null { + const config = this.workspace.getConfiguration("rubyEnvironments"); + const rubyVersion = config.get("rubyVersion"); + + if (!rubyVersion) { + // Return null if not configured - let the caller decide how to handle this + return null; + } + + return { + error: false, + rubyVersion, + availableJITs: [], + env: {}, + gemPath: [], + }; + } +} diff --git a/src/extension.ts b/src/extension.ts index 4abccb8..2a07a05 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,31 +1,15 @@ import * as vscode from "vscode"; import { RubyStatus } from "./status"; -import { RubyChangeEvent, RubyDefinition, RubyEnvironmentsApi } from "./types"; +import { RubyChangeEvent, RubyEnvironmentsApi } from "./types"; +import { ConfiguredRuby } from "./configuredRuby"; // Event emitter for Ruby environment changes const rubyChangeEmitter = new vscode.EventEmitter(); -function getRubyDefinitionFromConfig(): RubyDefinition { - const config = vscode.workspace.getConfiguration("rubyEnvironments"); - const rubyVersion = config.get("rubyVersion"); - - if (!rubyVersion) { - // Return mock data if not configured - return { - error: false, - rubyVersion: "3.3.0", - availableJITs: [], - env: {}, - gemPath: [], - }; - } - - return { - error: true, - }; -} - export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi { + // Create the version manager + const versionManager = new ConfiguredRuby(); + // Ensure the event emitter is disposed when the extension is deactivated context.subscriptions.push(rubyChangeEmitter); @@ -33,14 +17,14 @@ export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi const status = new RubyStatus(); context.subscriptions.push(status); - // Load Ruby definition from configuration - let currentRubyDefinition = getRubyDefinitionFromConfig(); + // Load Ruby definition from version manager + let currentRubyDefinition = versionManager.getRubyDefinition(); status.refresh(currentRubyDefinition); // Watch for configuration changes const configWatcher = vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("rubyEnvironments")) { - currentRubyDefinition = getRubyDefinitionFromConfig(); + currentRubyDefinition = versionManager.getRubyDefinition(); status.refresh(currentRubyDefinition); } }); diff --git a/src/test/configuredRuby.test.ts b/src/test/configuredRuby.test.ts new file mode 100644 index 0000000..f8ad3aa --- /dev/null +++ b/src/test/configuredRuby.test.ts @@ -0,0 +1,80 @@ +import * as assert from "assert"; +import { ConfiguredRuby, WorkspaceInterface } from "../configuredRuby"; + +type MockWorkspaceConfiguration = { + get(section: string, defaultValue?: T): T | undefined; +}; + +function createMockWorkspace(config: Record): WorkspaceInterface { + return { + getConfiguration: (_section: string) => + ({ + get: (key: string, defaultValue?: T): T | undefined => { + const value = config[key]; + return value !== undefined ? (value as T) : defaultValue; + }, + }) as MockWorkspaceConfiguration, + }; +} + +suite("ConfiguredRuby", () => { + let versionManager: ConfiguredRuby; + + test("has correct identifier and name", () => { + const mockWorkspace = createMockWorkspace({}); + versionManager = new ConfiguredRuby(mockWorkspace); + + assert.strictEqual(versionManager.identifier, "configured"); + assert.strictEqual(versionManager.name, "Configured Ruby"); + }); + + test("returns null when no configuration is set", () => { + const mockWorkspace = createMockWorkspace({}); + versionManager = new ConfiguredRuby(mockWorkspace); + + const result = versionManager.getRubyDefinition(); + + assert.strictEqual(result, null); + }); + + test("returns Ruby definition when version is configured", () => { + const mockWorkspace = createMockWorkspace({ + rubyVersion: "3.3.0", + }); + versionManager = new ConfiguredRuby(mockWorkspace); + + const result = versionManager.getRubyDefinition(); + + assert.ok(result, "Should return a RubyDefinition"); + assert.strictEqual(result.error, false); + assert.strictEqual(result.rubyVersion, "3.3.0"); + assert.deepStrictEqual(result.availableJITs, []); + }); + + test("returns Ruby definition with required fields", () => { + const mockWorkspace = createMockWorkspace({ + rubyVersion: "3.2.0", + }); + versionManager = new ConfiguredRuby(mockWorkspace); + + const result = versionManager.getRubyDefinition(); + + assert.ok(result, "Should return a RubyDefinition"); + assert.strictEqual(result.error, false); + assert.strictEqual(result.rubyVersion, "3.2.0"); + assert.deepStrictEqual(result.availableJITs, []); + assert.deepStrictEqual(result.env, {}); + assert.deepStrictEqual(result.gemPath, []); + }); + + test("returns null when version is empty string", () => { + const mockWorkspace = createMockWorkspace({ + rubyVersion: "", + }); + versionManager = new ConfiguredRuby(mockWorkspace); + + const result = versionManager.getRubyDefinition(); + + assert.strictEqual(result, null); + }); +}); diff --git a/src/versionManager.ts b/src/versionManager.ts new file mode 100644 index 0000000..8a6e8a8 --- /dev/null +++ b/src/versionManager.ts @@ -0,0 +1,21 @@ +import { RubyDefinition } from "./types"; + +/** + * Base interface for Ruby version managers + */ +export interface VersionManager { + /** + * The identifier for this version manager + */ + readonly identifier: string; + + /** + * The display name for this version manager + */ + readonly name: string; + + /** + * Get the Ruby definition from this version manager + */ + getRubyDefinition(): RubyDefinition | null; +} From 3da63f91a068d75ff4fe5a234ab1fe44d721e492 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 26 Jan 2026 15:13:31 -0500 Subject: [PATCH 02/14] Implement the first version manager Implement the custom executable path version manager that allow users to set rubyExecutablePath in settings. Update the extension to use this new version manager. --- activation.rb | 9 +++ package.json | 32 +++++--- src/common.ts | 18 +++++ src/configuredRuby.ts | 73 +++++++++++++++--- src/extension.ts | 123 ++++++++++++++++++++++++------ src/test/configuredRuby.test.ts | 130 +++++++++++++++++++++++++------- src/versionManager.ts | 4 +- yarn.lock | 62 ++++++++++++++- 8 files changed, 378 insertions(+), 73 deletions(-) create mode 100644 activation.rb create mode 100644 src/common.ts diff --git a/activation.rb b/activation.rb new file mode 100644 index 0000000..799fa67 --- /dev/null +++ b/activation.rb @@ -0,0 +1,9 @@ +// Using .map.compact just so that it doesn't crash immediately on Ruby 2.6 +env = ENV.map do |k, v| + utf_8_value = v.dup.force_encoding(Encoding::UTF_8) + "#{k}RUBY_ENVIRONMENTS_VS#{utf_8_value}" if utf_8_value.valid_encoding? +end.compact + +env.unshift(RUBY_VERSION, Gem.path.join(","), !!defined?(RubyVM::YJIT)) + +STDERR.print("RUBY_ENVIRONMENTS_ACTIVATION_SEPARATOR#{env.join("RUBY_ENVIRONMENTS_FS")}RUBY_ENVIRONMENTS_ACTIVATION_SEPARATOR") diff --git a/package.json b/package.json index 3de16b1..54be936 100644 --- a/package.json +++ b/package.json @@ -32,10 +32,22 @@ "configuration": { "title": "Ruby Environments", "properties": { - "rubyEnvironments.rubyVersion": { + "rubyEnvironments.versionManager": { "type": "string", - "default": null, - "description": "The Ruby version to use (e.g., '3.3.0'). If not set, will attempt to detect automatically.", + "default": "configured", + "enum": [ + "configured" + ], + "enumDescriptions": [ + "Use the Ruby executable path configured in settings" + ], + "description": "The version manager to use for Ruby environment activation.", + "scope": "window" + }, + "rubyEnvironments.rubyExecutablePath": { + "type": "string", + "default": "ruby", + "description": "Path to the Ruby executable. Can be an absolute path or a command in PATH.", "scope": "window" } } @@ -61,20 +73,22 @@ "test": "vscode-test" }, "devDependencies": { + "@eslint/js": "^9.32.0", "@types/mocha": "^10.0.10", "@types/node": "20.x", + "@types/sinon": "^21.0.0", "@types/vscode": "^1.102.0", - "@eslint/js": "^9.32.0", - "eslint": "^9.30.1", - "typescript-eslint": "^8.38.0", - "eslint-plugin-prettier": "^5.5.3", - "prettier": "^3.6.2", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.6.0", "esbuild": "^0.25.3", + "eslint": "^9.30.1", + "eslint-plugin-prettier": "^5.5.3", "npm-run-all": "^4.1.5", "ovsx": "^0.10.5", - "typescript": "^5.8.3" + "prettier": "^3.6.2", + "sinon": "^21.0.1", + "typescript": "^5.8.3", + "typescript-eslint": "^8.38.0" } } diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 0000000..6044459 --- /dev/null +++ b/src/common.ts @@ -0,0 +1,18 @@ +import * as childProcess from "child_process"; +import * as util from "util"; +import * as os from "os"; + +const execAsync = util.promisify(childProcess.exec); + +export interface ExecResult { + stdout: string; + stderr: string; +} + +export async function asyncExec(command: string, options: childProcess.ExecOptions): Promise { + return execAsync(command, options); +} + +export function isWindows(): boolean { + return os.platform() === "win32"; +} diff --git a/src/configuredRuby.ts b/src/configuredRuby.ts index b8b6d96..97b524d 100644 --- a/src/configuredRuby.ts +++ b/src/configuredRuby.ts @@ -1,6 +1,12 @@ import * as vscode from "vscode"; -import { RubyDefinition } from "./types"; +import { JitType, RubyDefinition } from "./types"; import { VersionManager } from "./versionManager"; +import { asyncExec, isWindows } from "./common"; + +// Separators for parsing activation script output +export const ACTIVATION_SEPARATOR = "RUBY_ENVIRONMENTS_ACTIVATION_SEPARATOR"; +export const VALUE_SEPARATOR = "RUBY_ENVIRONMENTS_VS"; +export const FIELD_SEPARATOR = "RUBY_ENVIRONMENTS_FS"; /** * Interface for workspace configuration @@ -24,26 +30,71 @@ export class ConfiguredRuby implements VersionManager { readonly identifier = "configured"; readonly name = "Configured Ruby"; private readonly workspace: WorkspaceInterface; + private readonly context: vscode.ExtensionContext; + private readonly workspaceFolder: vscode.WorkspaceFolder | undefined; - constructor(workspace: WorkspaceInterface = vscode.workspace) { + constructor( + workspace: WorkspaceInterface = vscode.workspace, + context: vscode.ExtensionContext, + workspaceFolder?: vscode.WorkspaceFolder, + ) { this.workspace = workspace; + this.context = context; + this.workspaceFolder = workspaceFolder; } - getRubyDefinition(): RubyDefinition | null { + async activate(): Promise { const config = this.workspace.getConfiguration("rubyEnvironments"); - const rubyVersion = config.get("rubyVersion"); + const rubyExecutable = config.get("rubyExecutablePath", "ruby"); + + try { + const activationScriptUri = vscode.Uri.joinPath(this.context.extensionUri, "activation.rb"); + + const command = `${rubyExecutable} -W0 -EUTF-8:UTF-8 '${activationScriptUri.fsPath}'`; + + let shell: string | undefined; + // Use the user's preferred shell (except on Windows) to ensure proper environment sourcing + if (vscode.env.shell.length > 0 && !isWindows()) { + shell = vscode.env.shell; + } - if (!rubyVersion) { - // Return null if not configured - let the caller decide how to handle this - return null; + const cwd = this.workspaceFolder?.uri.fsPath || process.cwd(); + + const result = await asyncExec(command, { + cwd, + shell, + env: process.env, + }); + + return this.parseActivationResult(result.stderr); + } catch (_error: unknown) { + return { + error: true, + }; } + } + + private parseActivationResult(stderr: string): RubyDefinition { + const activationContent = new RegExp(`${ACTIVATION_SEPARATOR}([^]*)${ACTIVATION_SEPARATOR}`).exec(stderr); + + if (!activationContent) { + return { + error: true, + }; + } + + const [version, gemPath, yjit, ...envEntries] = activationContent[1].split(FIELD_SEPARATOR); + + const availableJITs: JitType[] = []; + + if (yjit) availableJITs.push(JitType.YJIT); return { error: false, - rubyVersion, - availableJITs: [], - env: {}, - gemPath: [], + rubyVersion: version, + gemPath: gemPath.split(","), + availableJITs: availableJITs, + env: Object.fromEntries(envEntries.map((entry: string) => entry.split(VALUE_SEPARATOR))) as NodeJS.ProcessEnv, }; } } diff --git a/src/extension.ts b/src/extension.ts index 2a07a05..23b08b9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,14 +1,33 @@ import * as vscode from "vscode"; import { RubyStatus } from "./status"; -import { RubyChangeEvent, RubyEnvironmentsApi } from "./types"; +import { RubyChangeEvent, RubyDefinition, RubyEnvironmentsApi } from "./types"; import { ConfiguredRuby } from "./configuredRuby"; +import { VersionManager } from "./versionManager"; + +function createVersionManager( + context: vscode.ExtensionContext, + workspaceFolder: vscode.WorkspaceFolder | undefined, +): VersionManager { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + const versionManager = config.get("versionManager", "configured"); + + switch (versionManager) { + case "configured": + return new ConfiguredRuby(vscode.workspace, context, workspaceFolder); + default: + // Default to configured if unknown version manager + return new ConfiguredRuby(vscode.workspace, context, workspaceFolder); + } +} // Event emitter for Ruby environment changes const rubyChangeEmitter = new vscode.EventEmitter(); export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + // Create the version manager - const versionManager = new ConfiguredRuby(); + let versionManager = createVersionManager(context, workspaceFolder); // Ensure the event emitter is disposed when the extension is deactivated context.subscriptions.push(rubyChangeEmitter); @@ -18,37 +37,99 @@ export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi context.subscriptions.push(status); // Load Ruby definition from version manager - let currentRubyDefinition = versionManager.getRubyDefinition(); - status.refresh(currentRubyDefinition); + let currentRubyDefinition: RubyDefinition | null = null; + + // Activate Ruby environment asynchronously + const activateRuby = async () => { + currentRubyDefinition = await versionManager.activate(); + status.refresh(currentRubyDefinition); + }; + + // Initial activation + void activateRuby(); // Watch for configuration changes const configWatcher = vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("rubyEnvironments")) { - currentRubyDefinition = versionManager.getRubyDefinition(); - status.refresh(currentRubyDefinition); + // Recreate version manager if the version manager type changed + if (e.affectsConfiguration("rubyEnvironments.versionManager")) { + versionManager = createVersionManager(context, workspaceFolder); + } + void activateRuby(); } }); context.subscriptions.push(configWatcher); // Register command to select Ruby version const selectRubyVersion = vscode.commands.registerCommand("ruby-environments.selectRubyVersion", async () => { - const items = [ - { label: "Ruby 3.3.0 (YJIT)", version: "3.3.0", yjit: true }, - { label: "Ruby 3.3.0", version: "3.3.0", yjit: false }, - { label: "Ruby 3.2.0 (YJIT)", version: "3.2.0", yjit: true }, - { label: "Ruby 3.2.0", version: "3.2.0", yjit: false }, - { label: "Ruby 3.1.0", version: "3.1.0", yjit: false }, - ]; - - const selected = await vscode.window.showQuickPick(items, { - placeHolder: "Select Ruby installation", + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + + // First, let the user select the version manager + const versionManagerItems = [{ label: "Configured Ruby", value: "configured" }]; + + const selectedManager = await vscode.window.showQuickPick(versionManagerItems, { + placeHolder: "Select version manager", }); - if (selected) { - const config = vscode.workspace.getConfiguration("rubyEnvironments"); - await config.update("rubyVersion", selected.version, vscode.ConfigurationTarget.Workspace); - await config.update("yjitEnabled", selected.yjit, vscode.ConfigurationTarget.Workspace); - vscode.window.showInformationMessage(`Switched to ${selected.label}`); + if (selectedManager) { + await config.update("versionManager", selectedManager.value, vscode.ConfigurationTarget.Workspace); + + // If configured, also ask for the Ruby executable path + if (selectedManager.value === "configured") { + const currentPath = config.get("rubyExecutablePath", "ruby"); + + // Show options for how to set the path + const option = await vscode.window.showQuickPick( + [ + { label: "$(folder) Browse for file...", value: "browse" }, + { label: "$(edit) Enter path manually...", value: "manual" }, + ], + { + placeHolder: `Current path: ${currentPath}`, + }, + ); + + if (option) { + if (option.value === "browse") { + const uris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + openLabel: "Select Ruby Executable", + title: "Select Ruby Executable", + filters: { + // No extension filter to support executables without extensions + "All Files": ["*"], + }, + }); + + if (uris && uris.length > 0) { + const selectedPath = uris[0].fsPath; + await config.update("rubyExecutablePath", selectedPath, vscode.ConfigurationTarget.Workspace); + vscode.window.showInformationMessage(`Ruby executable path updated to ${selectedPath}`); + } + } else if (option.value === "manual") { + const newPath = await vscode.window.showInputBox({ + prompt: "Enter Ruby executable path", + value: currentPath, + placeHolder: "ruby", + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return "Path cannot be empty"; + } + return null; + }, + }); + + if (newPath) { + await config.update("rubyExecutablePath", newPath, vscode.ConfigurationTarget.Workspace); + vscode.window.showInformationMessage(`Ruby executable path updated to ${newPath}`); + } + } + } + } else { + vscode.window.showInformationMessage(`Switched to ${selectedManager.label} version manager`); + } } }); diff --git a/src/test/configuredRuby.test.ts b/src/test/configuredRuby.test.ts index f8ad3aa..326a316 100644 --- a/src/test/configuredRuby.test.ts +++ b/src/test/configuredRuby.test.ts @@ -1,5 +1,14 @@ import * as assert from "assert"; +import { beforeEach, afterEach } from "mocha"; +import * as vscode from "vscode"; +import sinon from "sinon"; import { ConfiguredRuby, WorkspaceInterface } from "../configuredRuby"; +import * as common from "../common"; +import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../configuredRuby"; +import { JitType } from "../types"; + +// Re-export the constants for testing +export { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR }; type MockWorkspaceConfiguration = { get(section: string, defaultValue?: T): T | undefined; @@ -19,62 +28,125 @@ function createMockWorkspace(config: Record): WorkspaceInterfac suite("ConfiguredRuby", () => { let versionManager: ConfiguredRuby; + let mockContext: vscode.ExtensionContext; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + // Create a minimal mock context + mockContext = { + extensionUri: vscode.Uri.file(__dirname + "/../.."), + } as vscode.ExtensionContext; + }); + + afterEach(() => { + sandbox.restore(); + }); test("has correct identifier and name", () => { const mockWorkspace = createMockWorkspace({}); - versionManager = new ConfiguredRuby(mockWorkspace); + versionManager = new ConfiguredRuby(mockWorkspace, mockContext); assert.strictEqual(versionManager.identifier, "configured"); assert.strictEqual(versionManager.name, "Configured Ruby"); }); - test("returns null when no configuration is set", () => { - const mockWorkspace = createMockWorkspace({}); - versionManager = new ConfiguredRuby(mockWorkspace); + test("activate executes Ruby and returns result", async () => { + const mockWorkspace = createMockWorkspace({ + rubyExecutablePath: "ruby", + }); + const workspaceFolder = { + uri: vscode.Uri.file("/test/workspace"), + name: "test", + index: 0, + }; + versionManager = new ConfiguredRuby(mockWorkspace, mockContext, workspaceFolder); + + const envStub = [ + "3.3.0", + "/path/to/gems,/another/path", + "true", + `PATH${VALUE_SEPARATOR}/usr/bin`, + `HOME${VALUE_SEPARATOR}/home/user`, + ].join(FIELD_SEPARATOR); + + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + + const result = await versionManager.activate(); + + const activationUri = vscode.Uri.joinPath(mockContext.extensionUri, "activation.rb"); + const expectedCommand = `ruby -W0 -EUTF-8:UTF-8 '${activationUri.fsPath}'`; + + // We must not set the shell on Windows + const shell = common.isWindows() ? undefined : vscode.env.shell; - const result = versionManager.getRubyDefinition(); + assert.ok( + execStub.calledOnceWithExactly(expectedCommand, { + cwd: workspaceFolder.uri.fsPath, + shell, + env: process.env, + }), + `Expected asyncExec to be called with correct arguments`, + ); - assert.strictEqual(result, null); + assert.strictEqual(result.error, false); + assert.strictEqual(result.rubyVersion, "3.3.0"); + assert.deepStrictEqual(result.availableJITs, [JitType.YJIT]); + assert.deepStrictEqual(result.gemPath, ["/path/to/gems", "/another/path"]); + assert.strictEqual(result.env?.PATH, "/usr/bin"); + assert.strictEqual(result.env?.HOME, "/home/user"); }); - test("returns Ruby definition when version is configured", () => { + test("activate returns error when Ruby executable fails", async () => { const mockWorkspace = createMockWorkspace({ - rubyVersion: "3.3.0", + rubyExecutablePath: "/nonexistent/ruby", }); - versionManager = new ConfiguredRuby(mockWorkspace); + versionManager = new ConfiguredRuby(mockWorkspace, mockContext); - const result = versionManager.getRubyDefinition(); + sandbox.stub(common, "asyncExec").rejects(new Error("Command failed")); - assert.ok(result, "Should return a RubyDefinition"); - assert.strictEqual(result.error, false); - assert.strictEqual(result.rubyVersion, "3.3.0"); - assert.deepStrictEqual(result.availableJITs, []); + const result = await versionManager.activate(); + + assert.strictEqual(result.error, true); }); - test("returns Ruby definition with required fields", () => { + test("activate uses custom Ruby executable path", async () => { const mockWorkspace = createMockWorkspace({ - rubyVersion: "3.2.0", + rubyExecutablePath: "/custom/path/to/ruby", }); - versionManager = new ConfiguredRuby(mockWorkspace); + versionManager = new ConfiguredRuby(mockWorkspace, mockContext); - const result = versionManager.getRubyDefinition(); + const envStub = ["3.2.0", "/gems", "false"].join(FIELD_SEPARATOR); + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); - assert.ok(result, "Should return a RubyDefinition"); - assert.strictEqual(result.error, false); - assert.strictEqual(result.rubyVersion, "3.2.0"); - assert.deepStrictEqual(result.availableJITs, []); - assert.deepStrictEqual(result.env, {}); - assert.deepStrictEqual(result.gemPath, []); + await versionManager.activate(); + + const activationUri = vscode.Uri.joinPath(mockContext.extensionUri, "activation.rb"); + const expectedCommand = `/custom/path/to/ruby -W0 -EUTF-8:UTF-8 '${activationUri.fsPath}'`; + + assert.ok(execStub.calledOnce); + assert.ok(execStub.firstCall.args[0] === expectedCommand); }); - test("returns null when version is empty string", () => { + test("activate returns error when activation output is malformed", async () => { const mockWorkspace = createMockWorkspace({ - rubyVersion: "", + rubyExecutablePath: "ruby", + }); + versionManager = new ConfiguredRuby(mockWorkspace, mockContext); + + sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: "Invalid output without separators", }); - versionManager = new ConfiguredRuby(mockWorkspace); - const result = versionManager.getRubyDefinition(); + const result = await versionManager.activate(); - assert.strictEqual(result, null); + assert.strictEqual(result.error, true); }); }); diff --git a/src/versionManager.ts b/src/versionManager.ts index 8a6e8a8..bc29d1d 100644 --- a/src/versionManager.ts +++ b/src/versionManager.ts @@ -15,7 +15,7 @@ export interface VersionManager { readonly name: string; /** - * Get the Ruby definition from this version manager + * Activate the Ruby environment and return the Ruby definition */ - getRubyDefinition(): RubyDefinition | null; + activate(): Promise; } diff --git a/yarn.lock b/yarn.lock index 7369805..d9af7f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -669,6 +669,28 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz#719df7fb41766bc143369eaa0dd56d8dc87c9958" integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg== +"@sinonjs/commons@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^15.1.0": + version "15.1.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-15.1.0.tgz#f42e713425d4eb1a7bc88ef5d7f76c4546586c25" + integrity sha512-cqfapCxwTGsrR80FEgOoPsTonoefMBY7dnUEbQ+GRcved0jvkJLzvX6F4WtN+HBqbPX/SiFsIRUp+IrCW/2I2w== + dependencies: + "@sinonjs/commons" "^3.0.1" + +"@sinonjs/samsam@^8.0.3": + version "8.0.3" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-8.0.3.tgz#eb6ffaef421e1e27783cc9b52567de20cb28072d" + integrity sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ== + dependencies: + "@sinonjs/commons" "^3.0.1" + type-detect "^4.1.0" + "@textlint/ast-node-types@15.2.1": version "15.2.1" resolved "https://registry.yarnpkg.com/@textlint/ast-node-types/-/ast-node-types-15.2.1.tgz#b98ce5bdf9e39941caa02e4cfcee459656c82b21" @@ -755,6 +777,18 @@ resolved "https://registry.yarnpkg.com/@types/sarif/-/sarif-2.1.7.tgz#dab4d16ba7568e9846c454a8764f33c5d98e5524" integrity sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ== +"@types/sinon@^21.0.0": + version "21.0.0" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-21.0.0.tgz#3a598a29b3aec0512a21e57ae0fd4c09aa013ca9" + integrity sha512-+oHKZ0lTI+WVLxx1IbJDNmReQaIsQJjN2e7UUrJHEeByG7bFeKJYsv1E75JxTQ9QKJDp21bAa/0W2Xo4srsDnw== + dependencies: + "@types/sinonjs__fake-timers" "*" + +"@types/sinonjs__fake-timers@*": + version "15.0.1" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-15.0.1.tgz#49f731d9453f52d64dd79f5a5626c1cf1b81bea4" + integrity sha512-Ko2tjWJq8oozHzHV+reuvS5KYIRAokHnGbDwGh/J64LntgpbuylF74ipEL24HCyRjf9FOlBiBHWBR1RlVKsI1w== + "@types/vscode@^1.102.0": version "1.102.0" resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.102.0.tgz#186dd6d4755807754a18ca869384c93b821039f2" @@ -1586,6 +1620,11 @@ diff@^7.0.0: resolved "https://registry.yarnpkg.com/diff/-/diff-7.0.0.tgz#3fb34d387cd76d803f6eebea67b921dab0182a9a" integrity sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw== +diff@^8.0.2: + version "8.0.3" + resolved "https://registry.yarnpkg.com/diff/-/diff-8.0.3.tgz#c7da3d9e0e8c283bb548681f8d7174653720c2d5" + integrity sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ== + dom-serializer@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" @@ -3943,6 +3982,17 @@ simple-invariant@^2.0.1: resolved "https://registry.yarnpkg.com/simple-invariant/-/simple-invariant-2.0.1.tgz#b8935284d31bc0c2719582f9cddf17bee8f57526" integrity sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg== +sinon@^21.0.1: + version "21.0.1" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-21.0.1.tgz#36b9126065a44906f7ba4a47b723b99315a8c356" + integrity sha512-Z0NVCW45W8Mg5oC/27/+fCqIHFnW8kpkFOq0j9XJIev4Ld0mKmERaZv5DMLAb9fGCevjKwaEeIQz5+MBXfZcDw== + dependencies: + "@sinonjs/commons" "^3.0.1" + "@sinonjs/fake-timers" "^15.1.0" + "@sinonjs/samsam" "^8.0.3" + diff "^8.0.2" + supports-color "^7.2.0" + slash@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/slash/-/slash-5.1.0.tgz#be3adddcdf09ac38eebe8dcdc7b1a57a75b095ce" @@ -4143,7 +4193,7 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^7.0.0, supports-color@^7.1.0: +supports-color@^7.0.0, supports-color@^7.1.0, supports-color@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== @@ -4289,6 +4339,16 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-detect@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c" + integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== + type-fest@^4.39.1, type-fest@^4.6.0: version "4.41.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58" From c78308ad4d8e892a55d254d0858a0cfa8e5f3301 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 26 Jan 2026 15:45:27 -0500 Subject: [PATCH 03/14] Add output channel to debug problems --- src/configuredRuby.ts | 35 ++++++++++++++++++-- src/extension.ts | 57 ++++++++++++++++++++++++++++++--- src/test/configuredRuby.test.ts | 22 ++++++++++--- src/test/extension.test.ts | 25 ++++++++++----- 4 files changed, 119 insertions(+), 20 deletions(-) diff --git a/src/configuredRuby.ts b/src/configuredRuby.ts index 97b524d..833627d 100644 --- a/src/configuredRuby.ts +++ b/src/configuredRuby.ts @@ -32,14 +32,17 @@ export class ConfiguredRuby implements VersionManager { private readonly workspace: WorkspaceInterface; private readonly context: vscode.ExtensionContext; private readonly workspaceFolder: vscode.WorkspaceFolder | undefined; + private readonly logger: vscode.LogOutputChannel; constructor( - workspace: WorkspaceInterface = vscode.workspace, + workspace: WorkspaceInterface, context: vscode.ExtensionContext, + logger: vscode.LogOutputChannel, workspaceFolder?: vscode.WorkspaceFolder, ) { this.workspace = workspace; this.context = context; + this.logger = logger; this.workspaceFolder = workspaceFolder; } @@ -47,18 +50,26 @@ export class ConfiguredRuby implements VersionManager { const config = this.workspace.getConfiguration("rubyEnvironments"); const rubyExecutable = config.get("rubyExecutablePath", "ruby"); + this.logger.info(`Configured Ruby: using executable '${rubyExecutable}'`); + try { const activationScriptUri = vscode.Uri.joinPath(this.context.extensionUri, "activation.rb"); + this.logger.debug(`Activation script path: ${activationScriptUri.fsPath}`); const command = `${rubyExecutable} -W0 -EUTF-8:UTF-8 '${activationScriptUri.fsPath}'`; + this.logger.debug(`Executing command: ${command}`); let shell: string | undefined; // Use the user's preferred shell (except on Windows) to ensure proper environment sourcing if (vscode.env.shell.length > 0 && !isWindows()) { shell = vscode.env.shell; + this.logger.debug(`Using shell: ${shell}`); + } else { + this.logger.debug("Using default shell"); } const cwd = this.workspaceFolder?.uri.fsPath || process.cwd(); + this.logger.debug(`Working directory: ${cwd}`); const result = await asyncExec(command, { cwd, @@ -66,8 +77,20 @@ export class ConfiguredRuby implements VersionManager { env: process.env, }); + this.logger.debug(`Command stdout length: ${result.stdout.length}`); + this.logger.debug(`Command stderr length: ${result.stderr.length}`); + + if (result.stderr) { + this.logger.trace(`Activation output (stderr): ${result.stderr}`); + } + return this.parseActivationResult(result.stderr); - } catch (_error: unknown) { + } catch (error: unknown) { + const errorMessage = error instanceof Error ? error.message : String(error); + this.logger.error(`Failed to activate Ruby: ${errorMessage}`); + if (error instanceof Error && error.stack) { + this.logger.trace(`Error stack: ${error.stack}`); + } return { error: true, }; @@ -78,13 +101,21 @@ export class ConfiguredRuby implements VersionManager { const activationContent = new RegExp(`${ACTIVATION_SEPARATOR}([^]*)${ACTIVATION_SEPARATOR}`).exec(stderr); if (!activationContent) { + this.logger.error("Failed to parse activation result: separator not found in output"); + this.logger.trace(`Raw stderr content: ${stderr}`); return { error: true, }; } + this.logger.debug("Successfully parsed activation separator"); const [version, gemPath, yjit, ...envEntries] = activationContent[1].split(FIELD_SEPARATOR); + this.logger.debug(`Parsed Ruby version: ${version}`); + this.logger.debug(`Parsed gem paths: ${gemPath}`); + this.logger.debug(`Parsed YJIT status: ${yjit}`); + this.logger.debug(`Parsed ${envEntries.length} environment variables`); + const availableJITs: JitType[] = []; if (yjit) availableJITs.push(JitType.YJIT); diff --git a/src/extension.ts b/src/extension.ts index 23b08b9..da30873 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -6,6 +6,7 @@ import { VersionManager } from "./versionManager"; function createVersionManager( context: vscode.ExtensionContext, + logger: vscode.LogOutputChannel, workspaceFolder: vscode.WorkspaceFolder | undefined, ): VersionManager { const config = vscode.workspace.getConfiguration("rubyEnvironments"); @@ -13,21 +14,43 @@ function createVersionManager( switch (versionManager) { case "configured": - return new ConfiguredRuby(vscode.workspace, context, workspaceFolder); + return new ConfiguredRuby(vscode.workspace, context, logger, workspaceFolder); default: // Default to configured if unknown version manager - return new ConfiguredRuby(vscode.workspace, context, workspaceFolder); + return new ConfiguredRuby(vscode.workspace, context, logger, workspaceFolder); } } // Event emitter for Ruby environment changes const rubyChangeEmitter = new vscode.EventEmitter(); -export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi { +// Internal activation function that accepts optional output channel for testing +export function activateInternal( + context: vscode.ExtensionContext, + outputChannel?: vscode.LogOutputChannel, +): RubyEnvironmentsApi { const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + // Use provided output channel or create a no-op one for tests + const logger = + outputChannel || + ({ + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + } as unknown as vscode.LogOutputChannel); + + logger.info("Ruby Environments extension activating..."); + if (workspaceFolder) { + logger.info(`Workspace folder: ${workspaceFolder.uri.fsPath}`); + } else { + logger.warn("No workspace folder found"); + } + // Create the version manager - let versionManager = createVersionManager(context, workspaceFolder); + let versionManager = createVersionManager(context, logger, workspaceFolder); + logger.info(`Using version manager: ${versionManager.name}`); // Ensure the event emitter is disposed when the extension is deactivated context.subscriptions.push(rubyChangeEmitter); @@ -41,7 +64,21 @@ export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi // Activate Ruby environment asynchronously const activateRuby = async () => { + logger.info("Activating Ruby environment..."); currentRubyDefinition = await versionManager.activate(); + + if (currentRubyDefinition.error) { + logger.error("Failed to activate Ruby environment"); + } else { + logger.info(`Ruby activated: ${currentRubyDefinition.rubyVersion || "unknown version"}`); + if (currentRubyDefinition.availableJITs.length > 0) { + logger.info(`JITs available: ${currentRubyDefinition.availableJITs.join(", ")}`); + } + if (currentRubyDefinition.gemPath) { + logger.debug(`Gem paths: ${currentRubyDefinition.gemPath.join(", ")}`); + } + } + status.refresh(currentRubyDefinition); }; @@ -51,9 +88,11 @@ export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi // Watch for configuration changes const configWatcher = vscode.workspace.onDidChangeConfiguration((e) => { if (e.affectsConfiguration("rubyEnvironments")) { + logger.info("Configuration changed, reactivating Ruby environment"); // Recreate version manager if the version manager type changed if (e.affectsConfiguration("rubyEnvironments.versionManager")) { - versionManager = createVersionManager(context, workspaceFolder); + versionManager = createVersionManager(context, logger, workspaceFolder); + logger.info(`Switched to version manager: ${versionManager.name}`); } void activateRuby(); } @@ -142,6 +181,14 @@ export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi }; } +export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi { + // Create log output channel for production use + const outputChannel = vscode.window.createOutputChannel("Ruby Environments", { log: true }); + context.subscriptions.push(outputChannel); + + return activateInternal(context, outputChannel); +} + export function deactivate() { // Extension cleanup happens automatically via context.subscriptions } diff --git a/src/test/configuredRuby.test.ts b/src/test/configuredRuby.test.ts index 326a316..5f8c005 100644 --- a/src/test/configuredRuby.test.ts +++ b/src/test/configuredRuby.test.ts @@ -26,9 +26,20 @@ function createMockWorkspace(config: Record): WorkspaceInterfac }; } +function createMockLogger(): vscode.LogOutputChannel { + return { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + trace: () => {}, + } as unknown as vscode.LogOutputChannel; +} + suite("ConfiguredRuby", () => { let versionManager: ConfiguredRuby; let mockContext: vscode.ExtensionContext; + let mockLogger: vscode.LogOutputChannel; let sandbox: sinon.SinonSandbox; beforeEach(() => { @@ -37,6 +48,7 @@ suite("ConfiguredRuby", () => { mockContext = { extensionUri: vscode.Uri.file(__dirname + "/../.."), } as vscode.ExtensionContext; + mockLogger = createMockLogger(); }); afterEach(() => { @@ -45,7 +57,7 @@ suite("ConfiguredRuby", () => { test("has correct identifier and name", () => { const mockWorkspace = createMockWorkspace({}); - versionManager = new ConfiguredRuby(mockWorkspace, mockContext); + versionManager = new ConfiguredRuby(mockWorkspace, mockContext, mockLogger); assert.strictEqual(versionManager.identifier, "configured"); assert.strictEqual(versionManager.name, "Configured Ruby"); @@ -60,7 +72,7 @@ suite("ConfiguredRuby", () => { name: "test", index: 0, }; - versionManager = new ConfiguredRuby(mockWorkspace, mockContext, workspaceFolder); + versionManager = new ConfiguredRuby(mockWorkspace, mockContext, mockLogger, workspaceFolder); const envStub = [ "3.3.0", @@ -104,7 +116,7 @@ suite("ConfiguredRuby", () => { const mockWorkspace = createMockWorkspace({ rubyExecutablePath: "/nonexistent/ruby", }); - versionManager = new ConfiguredRuby(mockWorkspace, mockContext); + versionManager = new ConfiguredRuby(mockWorkspace, mockContext, mockLogger); sandbox.stub(common, "asyncExec").rejects(new Error("Command failed")); @@ -117,7 +129,7 @@ suite("ConfiguredRuby", () => { const mockWorkspace = createMockWorkspace({ rubyExecutablePath: "/custom/path/to/ruby", }); - versionManager = new ConfiguredRuby(mockWorkspace, mockContext); + versionManager = new ConfiguredRuby(mockWorkspace, mockContext, mockLogger); const envStub = ["3.2.0", "/gems", "false"].join(FIELD_SEPARATOR); const execStub = sandbox.stub(common, "asyncExec").resolves({ @@ -138,7 +150,7 @@ suite("ConfiguredRuby", () => { const mockWorkspace = createMockWorkspace({ rubyExecutablePath: "ruby", }); - versionManager = new ConfiguredRuby(mockWorkspace, mockContext); + versionManager = new ConfiguredRuby(mockWorkspace, mockContext, mockLogger); sandbox.stub(common, "asyncExec").resolves({ stdout: "", diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 5931dd0..b526dc2 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -1,7 +1,7 @@ import * as assert from "assert"; import * as vscode from "vscode"; import { suite, test, beforeEach, afterEach } from "mocha"; -import { activate, deactivate } from "../extension"; +import { activateInternal, deactivate } from "../extension"; import { FakeContext, createContext } from "./helpers"; import { RubyEnvironmentsApi } from "../types"; @@ -18,7 +18,7 @@ suite("Extension Test Suite", () => { }); test("returns an object implementing RubyEnvironmentsApi", async () => { - const api = activate(context); + const api = activateInternal(context); assert.strictEqual(typeof api, "object", "activate should return an object"); assert.strictEqual(typeof api.activate, "function", "API should have an activate method"); @@ -31,20 +31,20 @@ suite("Extension Test Suite", () => { }); test("returned API conforms to RubyEnvironmentsApi interface", () => { - const api = activate(context); + const api = activateInternal(context); const typedApi: RubyEnvironmentsApi = api; assert.ok(typedApi, "API should conform to RubyEnvironmentsApi interface"); }); test("getRuby returns null initially", () => { - const api = activate(context); + const api = activateInternal(context); assert.strictEqual(api.getRuby(), null, "getRuby should return null before activation"); }); test("onDidRubyChange allows subscribing to events", () => { - const api = activate(context); + const api = activateInternal(context); let eventFired = false; const disposable = api.onDidRubyChange(() => { @@ -58,10 +58,10 @@ suite("Extension Test Suite", () => { assert.strictEqual(eventFired, false, "event should not have fired yet"); }); - test("registers emitter, status, config watcher, and command subscriptions", () => { + test("registers emitter, status, config watcher, output channel and command subscriptions", () => { assert.strictEqual(context.subscriptions.length, 0, "subscriptions should be empty initially"); - activate(context); + activateInternal(context); assert.strictEqual( context.subscriptions.length, @@ -69,12 +69,21 @@ suite("Extension Test Suite", () => { "Extension should register four subscriptions (emitter, status, config watcher, and command)", ); }); + + test("returns initial Ruby definition from configuration", () => { + const api = activateInternal(context); + + const result = api.getRuby(); + + // Since no configuration is set in tests, it should return null + assert.strictEqual(result, null, "getRuby should return null when no configuration is set"); + }); }); suite("selectRubyVersion command", () => { test("command is registered", async () => { const mockContext = createContext(); - activate(mockContext); + activateInternal(mockContext); const commands = await vscode.commands.getCommands(true); assert.ok(commands.includes("ruby-environments.selectRubyVersion"), "Command should be registered"); From 2bce2f16ffcb9bf496146f8c4c50483f97e8a457 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 26 Jan 2026 15:45:58 -0500 Subject: [PATCH 04/14] Fix activation script --- activation.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activation.rb b/activation.rb index 799fa67..2d65011 100644 --- a/activation.rb +++ b/activation.rb @@ -1,4 +1,4 @@ -// Using .map.compact just so that it doesn't crash immediately on Ruby 2.6 +# Using .map.compact just so that it doesn't crash immediately on Ruby 2.6 env = ENV.map do |k, v| utf_8_value = v.dup.force_encoding(Encoding::UTF_8) "#{k}RUBY_ENVIRONMENTS_VS#{utf_8_value}" if utf_8_value.valid_encoding? From e5d4b25f9ecca5c2b1ad2dfc67957c86353de132 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 26 Jan 2026 15:57:39 -0500 Subject: [PATCH 05/14] Extract the RubyEnvironment object --- src/extension.ts | 169 +--------------------------- src/rubyEnvironment.ts | 186 +++++++++++++++++++++++++++++++ src/test/extension.test.ts | 91 +++------------ src/test/rubyEnvironment.test.ts | 72 ++++++++++++ 4 files changed, 277 insertions(+), 241 deletions(-) create mode 100644 src/rubyEnvironment.ts create mode 100644 src/test/rubyEnvironment.test.ts diff --git a/src/extension.ts b/src/extension.ts index da30873..2e32f84 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,36 +1,12 @@ import * as vscode from "vscode"; -import { RubyStatus } from "./status"; -import { RubyChangeEvent, RubyDefinition, RubyEnvironmentsApi } from "./types"; -import { ConfiguredRuby } from "./configuredRuby"; -import { VersionManager } from "./versionManager"; - -function createVersionManager( - context: vscode.ExtensionContext, - logger: vscode.LogOutputChannel, - workspaceFolder: vscode.WorkspaceFolder | undefined, -): VersionManager { - const config = vscode.workspace.getConfiguration("rubyEnvironments"); - const versionManager = config.get("versionManager", "configured"); - - switch (versionManager) { - case "configured": - return new ConfiguredRuby(vscode.workspace, context, logger, workspaceFolder); - default: - // Default to configured if unknown version manager - return new ConfiguredRuby(vscode.workspace, context, logger, workspaceFolder); - } -} - -// Event emitter for Ruby environment changes -const rubyChangeEmitter = new vscode.EventEmitter(); +import { RubyEnvironmentsApi } from "./types"; +import { RubyEnvironment } from "./rubyEnvironment"; // Internal activation function that accepts optional output channel for testing export function activateInternal( context: vscode.ExtensionContext, outputChannel?: vscode.LogOutputChannel, ): RubyEnvironmentsApi { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - // Use provided output channel or create a no-op one for tests const logger = outputChannel || @@ -41,144 +17,7 @@ export function activateInternal( debug: () => {}, } as unknown as vscode.LogOutputChannel); - logger.info("Ruby Environments extension activating..."); - if (workspaceFolder) { - logger.info(`Workspace folder: ${workspaceFolder.uri.fsPath}`); - } else { - logger.warn("No workspace folder found"); - } - - // Create the version manager - let versionManager = createVersionManager(context, logger, workspaceFolder); - logger.info(`Using version manager: ${versionManager.name}`); - - // Ensure the event emitter is disposed when the extension is deactivated - context.subscriptions.push(rubyChangeEmitter); - - // Create the status item - const status = new RubyStatus(); - context.subscriptions.push(status); - - // Load Ruby definition from version manager - let currentRubyDefinition: RubyDefinition | null = null; - - // Activate Ruby environment asynchronously - const activateRuby = async () => { - logger.info("Activating Ruby environment..."); - currentRubyDefinition = await versionManager.activate(); - - if (currentRubyDefinition.error) { - logger.error("Failed to activate Ruby environment"); - } else { - logger.info(`Ruby activated: ${currentRubyDefinition.rubyVersion || "unknown version"}`); - if (currentRubyDefinition.availableJITs.length > 0) { - logger.info(`JITs available: ${currentRubyDefinition.availableJITs.join(", ")}`); - } - if (currentRubyDefinition.gemPath) { - logger.debug(`Gem paths: ${currentRubyDefinition.gemPath.join(", ")}`); - } - } - - status.refresh(currentRubyDefinition); - }; - - // Initial activation - void activateRuby(); - - // Watch for configuration changes - const configWatcher = vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration("rubyEnvironments")) { - logger.info("Configuration changed, reactivating Ruby environment"); - // Recreate version manager if the version manager type changed - if (e.affectsConfiguration("rubyEnvironments.versionManager")) { - versionManager = createVersionManager(context, logger, workspaceFolder); - logger.info(`Switched to version manager: ${versionManager.name}`); - } - void activateRuby(); - } - }); - context.subscriptions.push(configWatcher); - - // Register command to select Ruby version - const selectRubyVersion = vscode.commands.registerCommand("ruby-environments.selectRubyVersion", async () => { - const config = vscode.workspace.getConfiguration("rubyEnvironments"); - - // First, let the user select the version manager - const versionManagerItems = [{ label: "Configured Ruby", value: "configured" }]; - - const selectedManager = await vscode.window.showQuickPick(versionManagerItems, { - placeHolder: "Select version manager", - }); - - if (selectedManager) { - await config.update("versionManager", selectedManager.value, vscode.ConfigurationTarget.Workspace); - - // If configured, also ask for the Ruby executable path - if (selectedManager.value === "configured") { - const currentPath = config.get("rubyExecutablePath", "ruby"); - - // Show options for how to set the path - const option = await vscode.window.showQuickPick( - [ - { label: "$(folder) Browse for file...", value: "browse" }, - { label: "$(edit) Enter path manually...", value: "manual" }, - ], - { - placeHolder: `Current path: ${currentPath}`, - }, - ); - - if (option) { - if (option.value === "browse") { - const uris = await vscode.window.showOpenDialog({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: false, - openLabel: "Select Ruby Executable", - title: "Select Ruby Executable", - filters: { - // No extension filter to support executables without extensions - "All Files": ["*"], - }, - }); - - if (uris && uris.length > 0) { - const selectedPath = uris[0].fsPath; - await config.update("rubyExecutablePath", selectedPath, vscode.ConfigurationTarget.Workspace); - vscode.window.showInformationMessage(`Ruby executable path updated to ${selectedPath}`); - } - } else if (option.value === "manual") { - const newPath = await vscode.window.showInputBox({ - prompt: "Enter Ruby executable path", - value: currentPath, - placeHolder: "ruby", - validateInput: (value) => { - if (!value || value.trim().length === 0) { - return "Path cannot be empty"; - } - return null; - }, - }); - - if (newPath) { - await config.update("rubyExecutablePath", newPath, vscode.ConfigurationTarget.Workspace); - vscode.window.showInformationMessage(`Ruby executable path updated to ${newPath}`); - } - } - } - } else { - vscode.window.showInformationMessage(`Switched to ${selectedManager.label} version manager`); - } - } - }); - - context.subscriptions.push(selectRubyVersion); - - return { - activate: async (_workspace: vscode.WorkspaceFolder | undefined) => {}, - getRuby: () => null, - onDidRubyChange: rubyChangeEmitter.event, - }; + return new RubyEnvironment(context, logger); } export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi { @@ -186,7 +25,7 @@ export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi const outputChannel = vscode.window.createOutputChannel("Ruby Environments", { log: true }); context.subscriptions.push(outputChannel); - return activateInternal(context, outputChannel); + return new RubyEnvironment(context, outputChannel); } export function deactivate() { diff --git a/src/rubyEnvironment.ts b/src/rubyEnvironment.ts new file mode 100644 index 0000000..9220c2b --- /dev/null +++ b/src/rubyEnvironment.ts @@ -0,0 +1,186 @@ +import * as vscode from "vscode"; +import { RubyStatus } from "./status"; +import { RubyChangeEvent, RubyDefinition, RubyEnvironmentsApi } from "./types"; +import { ConfiguredRuby } from "./configuredRuby"; +import { VersionManager } from "./versionManager"; + +function createVersionManager( + context: vscode.ExtensionContext, + logger: vscode.LogOutputChannel, + workspaceFolder: vscode.WorkspaceFolder | undefined, +): VersionManager { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + const versionManager = config.get("versionManager", "configured"); + + switch (versionManager) { + case "configured": + return new ConfiguredRuby(vscode.workspace, context, logger, workspaceFolder); + default: + // Default to configured if unknown version manager + return new ConfiguredRuby(vscode.workspace, context, logger, workspaceFolder); + } +} + +/** + * Main class that manages the Ruby environment state and lifecycle + */ +export class RubyEnvironment implements RubyEnvironmentsApi { + private versionManager: VersionManager; + private currentRubyDefinition: RubyDefinition | null = null; + private readonly logger: vscode.LogOutputChannel; + private readonly context: vscode.ExtensionContext; + private readonly workspaceFolder: vscode.WorkspaceFolder | undefined; + private readonly status: RubyStatus; + // Event emitter for Ruby environment changes + private readonly rubyChangeEmitter = new vscode.EventEmitter(); + + constructor(context: vscode.ExtensionContext, logger: vscode.LogOutputChannel) { + this.context = context; + this.logger = logger; + this.workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + + this.logger.info("Ruby Environments extension activating..."); + if (this.workspaceFolder) { + this.logger.info(`Workspace folder: ${this.workspaceFolder.uri.fsPath}`); + } else { + this.logger.warn("No workspace folder found"); + } + + // Create the version manager + this.versionManager = createVersionManager(context, logger, this.workspaceFolder); + this.logger.info(`Using version manager: ${this.versionManager.name}`); + + // Create the status item + this.status = new RubyStatus(); + context.subscriptions.push(this.status); + + // Setup watchers and commands + this.setupConfigWatcher(); + this.registerCommands(); + + // Initial activation + void this.activateRuby(); + } + + async activate(): Promise { + await this.activateRuby(); + } + + getRuby(): RubyDefinition | null { + return this.currentRubyDefinition; + } + + get onDidRubyChange(): vscode.Event { + return this.rubyChangeEmitter.event; + } + + private async activateRuby(): Promise { + this.logger.info("Activating Ruby environment..."); + this.currentRubyDefinition = await this.versionManager.activate(); + + if (this.currentRubyDefinition.error) { + this.logger.error("Failed to activate Ruby environment"); + } else { + this.logger.info(`Ruby activated: ${this.currentRubyDefinition.rubyVersion || "unknown version"}`); + if (this.currentRubyDefinition.availableJITs.length > 0) { + this.logger.info(`JITs available: ${this.currentRubyDefinition.availableJITs.join(", ")}`); + } + if (this.currentRubyDefinition.gemPath) { + this.logger.debug(`Gem paths: ${this.currentRubyDefinition.gemPath.join(", ")}`); + } + } + + this.status.refresh(this.currentRubyDefinition); + } + + private setupConfigWatcher(): void { + const configWatcher = vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("rubyEnvironments")) { + this.logger.info("Configuration changed, reactivating Ruby environment"); + // Recreate version manager if the version manager type changed + if (e.affectsConfiguration("rubyEnvironments.versionManager")) { + this.versionManager = createVersionManager(this.context, this.logger, this.workspaceFolder); + this.logger.info(`Switched to version manager: ${this.versionManager.name}`); + } + void this.activateRuby(); + } + }); + this.context.subscriptions.push(configWatcher); + } + + private registerCommands(): void { + const selectRubyVersion = vscode.commands.registerCommand("ruby-environments.selectRubyVersion", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + + // First, let the user select the version manager + const versionManagerItems = [{ label: "Configured Ruby", value: "configured" }]; + + const selectedManager = await vscode.window.showQuickPick(versionManagerItems, { + placeHolder: "Select version manager", + }); + + if (selectedManager) { + await config.update("versionManager", selectedManager.value, vscode.ConfigurationTarget.Workspace); + + // If configured, also ask for the Ruby executable path + if (selectedManager.value === "configured") { + const currentPath = config.get("rubyExecutablePath", "ruby"); + + // Show options for how to set the path + const option = await vscode.window.showQuickPick( + [ + { label: "$(folder) Browse for file...", value: "browse" }, + { label: "$(edit) Enter path manually...", value: "manual" }, + ], + { + placeHolder: `Current path: ${currentPath}`, + }, + ); + + if (option) { + if (option.value === "browse") { + const uris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + openLabel: "Select Ruby Executable", + title: "Select Ruby Executable", + filters: { + // No extension filter to support executables without extensions + "All Files": ["*"], + }, + }); + + if (uris && uris.length > 0) { + const selectedPath = uris[0].fsPath; + await config.update("rubyExecutablePath", selectedPath, vscode.ConfigurationTarget.Workspace); + vscode.window.showInformationMessage(`Ruby executable path updated to ${selectedPath}`); + } + } else if (option.value === "manual") { + const newPath = await vscode.window.showInputBox({ + prompt: "Enter Ruby executable path", + value: currentPath, + placeHolder: "ruby", + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return "Path cannot be empty"; + } + return null; + }, + }); + + if (newPath) { + await config.update("rubyExecutablePath", newPath, vscode.ConfigurationTarget.Workspace); + vscode.window.showInformationMessage(`Ruby executable path updated to ${newPath}`); + } + } + } + } else { + vscode.window.showInformationMessage(`Switched to ${selectedManager.label} version manager`); + } + } + }); + + this.context.subscriptions.push(selectRubyVersion); + } +} diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index b526dc2..67d2c96 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -1,84 +1,23 @@ import * as assert from "assert"; +import { suite, test } from "mocha"; import * as vscode from "vscode"; -import { suite, test, beforeEach, afterEach } from "mocha"; import { activateInternal, deactivate } from "../extension"; -import { FakeContext, createContext } from "./helpers"; -import { RubyEnvironmentsApi } from "../types"; suite("Extension Test Suite", () => { - suite("activate", () => { - let context: FakeContext; - - beforeEach(() => { - context = createContext(); - }); - - afterEach(() => { - context.dispose(); - }); - - test("returns an object implementing RubyEnvironmentsApi", async () => { - const api = activateInternal(context); - - assert.strictEqual(typeof api, "object", "activate should return an object"); - assert.strictEqual(typeof api.activate, "function", "API should have an activate method"); - assert.strictEqual(typeof api.getRuby, "function", "API should have a getRuby method"); - assert.strictEqual(typeof api.onDidRubyChange, "function", "API should have an onDidRubyChange event"); - - const result = api.activate(undefined); - assert.ok(result instanceof Promise, "activate should return a Promise"); - await result; - }); - - test("returned API conforms to RubyEnvironmentsApi interface", () => { - const api = activateInternal(context); - - const typedApi: RubyEnvironmentsApi = api; - assert.ok(typedApi, "API should conform to RubyEnvironmentsApi interface"); - }); - - test("getRuby returns null initially", () => { - const api = activateInternal(context); - - assert.strictEqual(api.getRuby(), null, "getRuby should return null before activation"); - }); - - test("onDidRubyChange allows subscribing to events", () => { - const api = activateInternal(context); - - let eventFired = false; - const disposable = api.onDidRubyChange(() => { - eventFired = true; - }); - - assert.ok(disposable, "onDidRubyChange should return a disposable"); - assert.strictEqual(typeof disposable.dispose, "function", "disposable should have a dispose method"); - - disposable.dispose(); - assert.strictEqual(eventFired, false, "event should not have fired yet"); - }); - - test("registers emitter, status, config watcher, output channel and command subscriptions", () => { - assert.strictEqual(context.subscriptions.length, 0, "subscriptions should be empty initially"); - - activateInternal(context); - - assert.strictEqual( - context.subscriptions.length, - 4, - "Extension should register four subscriptions (emitter, status, config watcher, and command)", - ); - }); - - test("returns initial Ruby definition from configuration", () => { - const api = activateInternal(context); - - const result = api.getRuby(); - - // Since no configuration is set in tests, it should return null - assert.strictEqual(result, null, "getRuby should return null when no configuration is set"); - }); - }); + type FakeContext = vscode.ExtensionContext & { dispose: () => void }; + + function createContext() { + const subscriptions: vscode.Disposable[] = []; + + return { + subscriptions, + dispose: () => { + subscriptions.forEach((subscription) => { + subscription.dispose(); + }); + }, + } as unknown as FakeContext; + } suite("selectRubyVersion command", () => { test("command is registered", async () => { diff --git a/src/test/rubyEnvironment.test.ts b/src/test/rubyEnvironment.test.ts new file mode 100644 index 0000000..8c461bf --- /dev/null +++ b/src/test/rubyEnvironment.test.ts @@ -0,0 +1,72 @@ +import * as assert from "assert"; +import { suite, test, beforeEach, afterEach } from "mocha"; +import * as vscode from "vscode"; +import { RubyEnvironment } from "../rubyEnvironment"; + +suite("RubyEnvironment Test Suite", () => { + type FakeContext = vscode.ExtensionContext & { dispose: () => void }; + + function createContext() { + const subscriptions: vscode.Disposable[] = []; + + return { + subscriptions, + dispose: () => { + subscriptions.forEach((subscription) => { + subscription.dispose(); + }); + }, + } as unknown as FakeContext; + } + + function createMockLogger(): vscode.LogOutputChannel { + return { + info: () => {}, + warn: () => {}, + error: () => {}, + debug: () => {}, + trace: () => {}, + } as unknown as vscode.LogOutputChannel; + } + + suite("RubyEnvironment", () => { + let context: FakeContext; + let mockLogger: vscode.LogOutputChannel; + + beforeEach(() => { + context = createContext(); + mockLogger = createMockLogger(); + }); + + afterEach(() => { + context.dispose(); + }); + + test("returns an API object with activate and getRuby methods", () => { + const rubyEnvironment = new RubyEnvironment(context, mockLogger); + + assert.ok(rubyEnvironment, "RubyEnvironment should be defined"); + assert.strictEqual(typeof rubyEnvironment.activate, "function", "activate should be a function"); + assert.strictEqual(typeof rubyEnvironment.getRuby, "function", "getRuby should be a function"); + }); + + test("registers config watcher, status item, and command subscriptions", () => { + new RubyEnvironment(context, mockLogger); + + assert.strictEqual( + context.subscriptions.length, + 3, + "Extension should register three subscriptions (status item, config watcher, and command)", + ); + }); + + test("returns initial Ruby definition from configuration", () => { + const rubyEnvironment = new RubyEnvironment(context, mockLogger); + + const result = rubyEnvironment.getRuby(); + + // Since no configuration is set in tests, it should return null + assert.strictEqual(result, null, "getRuby should return null when no configuration is set"); + }); + }); +}); From d553099712bb325a52e31e9dbcc0d809bf6373f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 26 Jan 2026 16:05:14 -0500 Subject: [PATCH 06/14] Move createVersionManager to inside the object --- src/rubyEnvironment.ts | 34 +++++++++++++++------------------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/src/rubyEnvironment.ts b/src/rubyEnvironment.ts index 9220c2b..e489117 100644 --- a/src/rubyEnvironment.ts +++ b/src/rubyEnvironment.ts @@ -4,23 +4,6 @@ import { RubyChangeEvent, RubyDefinition, RubyEnvironmentsApi } from "./types"; import { ConfiguredRuby } from "./configuredRuby"; import { VersionManager } from "./versionManager"; -function createVersionManager( - context: vscode.ExtensionContext, - logger: vscode.LogOutputChannel, - workspaceFolder: vscode.WorkspaceFolder | undefined, -): VersionManager { - const config = vscode.workspace.getConfiguration("rubyEnvironments"); - const versionManager = config.get("versionManager", "configured"); - - switch (versionManager) { - case "configured": - return new ConfiguredRuby(vscode.workspace, context, logger, workspaceFolder); - default: - // Default to configured if unknown version manager - return new ConfiguredRuby(vscode.workspace, context, logger, workspaceFolder); - } -} - /** * Main class that manages the Ruby environment state and lifecycle */ @@ -47,7 +30,7 @@ export class RubyEnvironment implements RubyEnvironmentsApi { } // Create the version manager - this.versionManager = createVersionManager(context, logger, this.workspaceFolder); + this.versionManager = this.createVersionManager(); this.logger.info(`Using version manager: ${this.versionManager.name}`); // Create the status item @@ -70,6 +53,19 @@ export class RubyEnvironment implements RubyEnvironmentsApi { return this.currentRubyDefinition; } + private createVersionManager(): VersionManager { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + const versionManager = config.get("versionManager", "configured"); + + switch (versionManager) { + case "configured": + return new ConfiguredRuby(vscode.workspace, this.context, this.logger, this.workspaceFolder); + default: + // Default to configured if unknown version manager + return new ConfiguredRuby(vscode.workspace, this.context, this.logger, this.workspaceFolder); + } + } + get onDidRubyChange(): vscode.Event { return this.rubyChangeEmitter.event; } @@ -99,7 +95,7 @@ export class RubyEnvironment implements RubyEnvironmentsApi { this.logger.info("Configuration changed, reactivating Ruby environment"); // Recreate version manager if the version manager type changed if (e.affectsConfiguration("rubyEnvironments.versionManager")) { - this.versionManager = createVersionManager(this.context, this.logger, this.workspaceFolder); + this.versionManager = this.createVersionManager(); this.logger.info(`Switched to version manager: ${this.versionManager.name}`); } void this.activateRuby(); From 47fb03e3bd34cf214c0b2e4e3ddb8da7679f34e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 26 Jan 2026 16:06:23 -0500 Subject: [PATCH 07/14] Remove activateInternal --- src/extension.ts | 18 ------------------ src/test/extension.test.ts | 30 +----------------------------- src/test/rubyEnvironment.test.ts | 7 +++++++ 3 files changed, 8 insertions(+), 47 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 2e32f84..4475eeb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,24 +2,6 @@ import * as vscode from "vscode"; import { RubyEnvironmentsApi } from "./types"; import { RubyEnvironment } from "./rubyEnvironment"; -// Internal activation function that accepts optional output channel for testing -export function activateInternal( - context: vscode.ExtensionContext, - outputChannel?: vscode.LogOutputChannel, -): RubyEnvironmentsApi { - // Use provided output channel or create a no-op one for tests - const logger = - outputChannel || - ({ - info: () => {}, - warn: () => {}, - error: () => {}, - debug: () => {}, - } as unknown as vscode.LogOutputChannel); - - return new RubyEnvironment(context, logger); -} - export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi { // Create log output channel for production use const outputChannel = vscode.window.createOutputChannel("Ruby Environments", { log: true }); diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 67d2c96..7d67638 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -1,36 +1,8 @@ import * as assert from "assert"; import { suite, test } from "mocha"; -import * as vscode from "vscode"; -import { activateInternal, deactivate } from "../extension"; +import { deactivate } from "../extension"; suite("Extension Test Suite", () => { - type FakeContext = vscode.ExtensionContext & { dispose: () => void }; - - function createContext() { - const subscriptions: vscode.Disposable[] = []; - - return { - subscriptions, - dispose: () => { - subscriptions.forEach((subscription) => { - subscription.dispose(); - }); - }, - } as unknown as FakeContext; - } - - suite("selectRubyVersion command", () => { - test("command is registered", async () => { - const mockContext = createContext(); - activateInternal(mockContext); - - const commands = await vscode.commands.getCommands(true); - assert.ok(commands.includes("ruby-environments.selectRubyVersion"), "Command should be registered"); - - mockContext.dispose(); - }); - }); - suite("deactivate", () => { test("can be called without errors", () => { assert.doesNotThrow(() => { diff --git a/src/test/rubyEnvironment.test.ts b/src/test/rubyEnvironment.test.ts index 8c461bf..e0d6f42 100644 --- a/src/test/rubyEnvironment.test.ts +++ b/src/test/rubyEnvironment.test.ts @@ -68,5 +68,12 @@ suite("RubyEnvironment Test Suite", () => { // Since no configuration is set in tests, it should return null assert.strictEqual(result, null, "getRuby should return null when no configuration is set"); }); + + test("registers selectRubyVersion command", async () => { + new RubyEnvironment(context, mockLogger); + + const commands = await vscode.commands.getCommands(true); + assert.ok(commands.includes("ruby-environments.selectRubyVersion"), "Command should be registered"); + }); }); }); From 1e62dd4d0f16998112fbc5c3a494a239494f64ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 26 Jan 2026 16:06:48 -0500 Subject: [PATCH 08/14] Move actiavation of Ruby environment to after construction --- src/extension.ts | 5 +++-- src/rubyEnvironment.ts | 3 --- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 4475eeb..2792832 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,11 +3,12 @@ import { RubyEnvironmentsApi } from "./types"; import { RubyEnvironment } from "./rubyEnvironment"; export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi { - // Create log output channel for production use const outputChannel = vscode.window.createOutputChannel("Ruby Environments", { log: true }); context.subscriptions.push(outputChannel); - return new RubyEnvironment(context, outputChannel); + const rubyEnvironment = new RubyEnvironment(context, outputChannel); + rubyEnvironment.activate(); + return rubyEnvironment; } export function deactivate() { diff --git a/src/rubyEnvironment.ts b/src/rubyEnvironment.ts index e489117..a0f040d 100644 --- a/src/rubyEnvironment.ts +++ b/src/rubyEnvironment.ts @@ -40,9 +40,6 @@ export class RubyEnvironment implements RubyEnvironmentsApi { // Setup watchers and commands this.setupConfigWatcher(); this.registerCommands(); - - // Initial activation - void this.activateRuby(); } async activate(): Promise { From 08febe9f871a3999c602ca2d0a0734a7bb80fa79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 26 Jan 2026 16:14:22 -0500 Subject: [PATCH 09/14] Use a more discriminated type for RubyDefinition --- src/extension.ts | 4 ++-- src/rubyEnvironment.ts | 6 ++---- src/status.ts | 2 +- 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index 2792832..49efab9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,12 +2,12 @@ import * as vscode from "vscode"; import { RubyEnvironmentsApi } from "./types"; import { RubyEnvironment } from "./rubyEnvironment"; -export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi { +export async function activate(context: vscode.ExtensionContext): Promise { const outputChannel = vscode.window.createOutputChannel("Ruby Environments", { log: true }); context.subscriptions.push(outputChannel); const rubyEnvironment = new RubyEnvironment(context, outputChannel); - rubyEnvironment.activate(); + await rubyEnvironment.activate(); return rubyEnvironment; } diff --git a/src/rubyEnvironment.ts b/src/rubyEnvironment.ts index a0f040d..f0fb3b3 100644 --- a/src/rubyEnvironment.ts +++ b/src/rubyEnvironment.ts @@ -74,13 +74,11 @@ export class RubyEnvironment implements RubyEnvironmentsApi { if (this.currentRubyDefinition.error) { this.logger.error("Failed to activate Ruby environment"); } else { - this.logger.info(`Ruby activated: ${this.currentRubyDefinition.rubyVersion || "unknown version"}`); + this.logger.info(`Ruby activated: ${this.currentRubyDefinition.rubyVersion}`); if (this.currentRubyDefinition.availableJITs.length > 0) { this.logger.info(`JITs available: ${this.currentRubyDefinition.availableJITs.join(", ")}`); } - if (this.currentRubyDefinition.gemPath) { - this.logger.debug(`Gem paths: ${this.currentRubyDefinition.gemPath.join(", ")}`); - } + this.logger.debug(`Gem paths: ${this.currentRubyDefinition.gemPath.join(", ")}`); } this.status.refresh(this.currentRubyDefinition); diff --git a/src/status.ts b/src/status.ts index 889d894..7edcb1f 100644 --- a/src/status.ts +++ b/src/status.ts @@ -27,7 +27,7 @@ export class RubyStatus { this.item.detail = "Error detecting Ruby environment"; this.item.severity = vscode.LanguageStatusSeverity.Error; } else { - const version = rubyDefinition.rubyVersion || "unknown"; + const version = rubyDefinition.rubyVersion; const jitStatus = rubyDefinition.availableJITs.length > 0 ? ` (${rubyDefinition.availableJITs.join(", ")})` : ""; this.item.text = `Ruby ${version}${jitStatus}`; this.item.severity = vscode.LanguageStatusSeverity.Information; From 2c2ed69f8f12f898a6d8cf38bc80a1dda2be8d2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 26 Jan 2026 16:25:17 -0500 Subject: [PATCH 10/14] Remove unneeded sync version of activate --- src/rubyEnvironment.ts | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/src/rubyEnvironment.ts b/src/rubyEnvironment.ts index f0fb3b3..580dc11 100644 --- a/src/rubyEnvironment.ts +++ b/src/rubyEnvironment.ts @@ -43,7 +43,20 @@ export class RubyEnvironment implements RubyEnvironmentsApi { } async activate(): Promise { - await this.activateRuby(); + this.logger.info("Activating Ruby environment..."); + this.currentRubyDefinition = await this.versionManager.activate(); + + if (this.currentRubyDefinition.error) { + this.logger.error("Failed to activate Ruby environment"); + } else { + this.logger.info(`Ruby activated: ${this.currentRubyDefinition.rubyVersion}`); + if (this.currentRubyDefinition.availableJITs.length > 0) { + this.logger.info(`JITs available: ${this.currentRubyDefinition.availableJITs.join(", ")}`); + } + this.logger.debug(`Gem paths: ${this.currentRubyDefinition.gemPath.join(", ")}`); + } + + this.status.refresh(this.currentRubyDefinition); } getRuby(): RubyDefinition | null { @@ -67,25 +80,8 @@ export class RubyEnvironment implements RubyEnvironmentsApi { return this.rubyChangeEmitter.event; } - private async activateRuby(): Promise { - this.logger.info("Activating Ruby environment..."); - this.currentRubyDefinition = await this.versionManager.activate(); - - if (this.currentRubyDefinition.error) { - this.logger.error("Failed to activate Ruby environment"); - } else { - this.logger.info(`Ruby activated: ${this.currentRubyDefinition.rubyVersion}`); - if (this.currentRubyDefinition.availableJITs.length > 0) { - this.logger.info(`JITs available: ${this.currentRubyDefinition.availableJITs.join(", ")}`); - } - this.logger.debug(`Gem paths: ${this.currentRubyDefinition.gemPath.join(", ")}`); - } - - this.status.refresh(this.currentRubyDefinition); - } - private setupConfigWatcher(): void { - const configWatcher = vscode.workspace.onDidChangeConfiguration((e) => { + const configWatcher = vscode.workspace.onDidChangeConfiguration(async (e) => { if (e.affectsConfiguration("rubyEnvironments")) { this.logger.info("Configuration changed, reactivating Ruby environment"); // Recreate version manager if the version manager type changed @@ -93,7 +89,7 @@ export class RubyEnvironment implements RubyEnvironmentsApi { this.versionManager = this.createVersionManager(); this.logger.info(`Switched to version manager: ${this.versionManager.name}`); } - void this.activateRuby(); + await this.activate(); } }); this.context.subscriptions.push(configWatcher); From 8505dc206410637fddc57ce048b46fa2b39d82df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 26 Jan 2026 16:31:13 -0500 Subject: [PATCH 11/14] Update README --- README.md | 101 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 94 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index cfbee6f..92baf8d 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,45 @@ -# Ruby environments +# Ruby Environments -This extension provides Ruby environment management for VS Code, providing ways for detecting the Ruby interpreter in the -user's machine with integrations to version managers. It is intended to be used as a dependency of other extensions that -need to activate the Ruby environment in order to launch Ruby executables, such as the -[Ruby LSP](https://github.com/Shopify/ruby-lsp) and [Ruby debug](https://github.com/ruby/vscode-rdbg). +This extension provides Ruby environment management for VS Code, enabling detection and management of Ruby interpreters with proper environment variable composition. It supports multiple version managers and is designed to be used as a dependency by other extensions that need to activate the Ruby environment, such as the [Ruby LSP](https://github.com/Shopify/ruby-lsp) and [Ruby Debug](https://github.com/ruby/vscode-rdbg). ## Features +### Ruby Environment Detection + +- **Automatic Detection**: Detects your Ruby installation and gathers environment information +- **Version Display**: Shows current Ruby version in the status bar +- **YJIT Status**: Indicates whether YJIT is enabled in your Ruby installation +- **Environment Variables**: Properly composes environment variables to match your shell environment + +### Version Manager Support + +Currently supported version managers: + +- **Configured Ruby**: Uses a Ruby executable path from VS Code settings + +Coming soon: + +- chruby +- rbenv +- asdf +- mise +- rvm + +### Interactive Commands + +- **Select Ruby Version** (`ruby-environments.selectRubyVersion`): Choose your version manager and configure the Ruby executable path + - Browse file system for Ruby executable + - Enter path manually + - Automatic environment reactivation on configuration changes + +### Status Bar Integration + +A language status item for Ruby files that shows: + +- Current Ruby version (e.g., "Ruby 3.3.0") +- YJIT status indicator when enabled (e.g., "Ruby 3.3.0 (YJIT)") +- Quick access to version selection +- Error states for failed activation - **Automatic Ruby detection**: Discovers the Ruby interpreter installed on your machine - **Version manager integrations**: Supports popular version managers including: - [chruby](https://github.com/postmodern/chruby) @@ -21,9 +54,21 @@ need to activate the Ruby environment in order to launch Ruby executables, such ## Extension Settings -TODO +This extension contributes the following settings: + +- `rubyEnvironments.versionManager`: Version manager to use for Ruby environment detection. Default: `"configured"` +- `rubyEnvironments.rubyExecutablePath`: Path to the Ruby executable when using the "Configured Ruby" version manager. Default: `"ruby"` + +## Usage + +### For End Users -## API +1. Install the extension +2. Open a Ruby file +3. Click on the Ruby version in the status bar to configure your Ruby environment +4. Select your preferred version manager or configure a specific Ruby executable path + +## For Extension Developers This extension exposes an API that other extensions can use to access the activated Ruby environment. @@ -108,3 +153,45 @@ To ensure your extension loads after Ruby Environments, add it as a dependency i "extensionDependencies": ["Shopify.ruby-environments"] } ``` + + +## Development + +### Prerequisites + +- Node.js and Yarn +- VS Code + +### Building + +```bash +# Install dependencies +yarn install + +# Compile TypeScript and watch for changes +yarn run compile + +# Or use the watch task +yarn run watch +``` + +### Testing + +```bash +# Run tests +yarn run test +``` + +### Debugging + +1. Open the project in VS Code +2. Press F5 to launch the Extension Development Host +3. Open a Ruby file to see the extension in action + +## Contributing + +Bug reports and pull requests are welcome on GitHub at https://github.com/Shopify/ruby-environments. This project is intended to be a safe, welcoming space for collaboration. + +## License + +This extension is available as open source under the terms of the [MIT License](LICENSE.txt) From 17c9929e54d9dbc533181d5aae819130def17ddd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 26 Jan 2026 16:50:21 -0500 Subject: [PATCH 12/14] Test on windows and MacOS --- .github/workflows/ci.yml | 14 ++++++++++++-- .prettierrc.json | 3 ++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae163a2..63fa097 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,9 +4,16 @@ on: [push, pull_request] jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] steps: + - name: Configure Git line endings + run: git config --global core.autocrlf false + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: Build @@ -18,7 +25,10 @@ jobs: - name: 📦 Install dependencies run: yarn --frozen-lockfile - - run: /usr/bin/Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + - name: Start Xvfb (Linux only) + if: runner.os == 'Linux' + run: /usr/bin/Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & + - run: yarn run test env: DISPLAY: ":99.0" diff --git a/.prettierrc.json b/.prettierrc.json index 186ff98..3d796c9 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -3,5 +3,6 @@ "printWidth": 120, "semi": true, "singleQuote": false, - "bracketSpacing": true + "bracketSpacing": true, + "endOfLine": "lf" } From 6dc5b2972d836a4e4c5eeb08d927635f67581b38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 26 Jan 2026 17:32:48 -0500 Subject: [PATCH 13/14] Show all available JITs in the Ruby environment --- README.md | 1 - activation.rb | 2 +- src/configuredRuby.ts | 9 +++++---- src/rubyEnvironment.ts | 2 +- src/test/configuredRuby.test.ts | 3 ++- src/test/status.test.ts | 15 +++++++++++++++ 6 files changed, 24 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 92baf8d..bc945e5 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,6 @@ To ensure your extension loads after Ruby Environments, add it as a dependency i } ``` - ## Development ### Prerequisites diff --git a/activation.rb b/activation.rb index 2d65011..5191cc1 100644 --- a/activation.rb +++ b/activation.rb @@ -4,6 +4,6 @@ "#{k}RUBY_ENVIRONMENTS_VS#{utf_8_value}" if utf_8_value.valid_encoding? end.compact -env.unshift(RUBY_VERSION, Gem.path.join(","), !!defined?(RubyVM::YJIT)) +env.unshift(RUBY_VERSION, Gem.path.join(","), !!defined?(RubyVM::YJIT), !!defined?(RubyVM::ZJIT)) STDERR.print("RUBY_ENVIRONMENTS_ACTIVATION_SEPARATOR#{env.join("RUBY_ENVIRONMENTS_FS")}RUBY_ENVIRONMENTS_ACTIVATION_SEPARATOR") diff --git a/src/configuredRuby.ts b/src/configuredRuby.ts index 833627d..3960ef7 100644 --- a/src/configuredRuby.ts +++ b/src/configuredRuby.ts @@ -109,22 +109,23 @@ export class ConfiguredRuby implements VersionManager { } this.logger.debug("Successfully parsed activation separator"); - const [version, gemPath, yjit, ...envEntries] = activationContent[1].split(FIELD_SEPARATOR); + const [version, gemPath, yjit, zjit, ...envEntries] = activationContent[1].split(FIELD_SEPARATOR); this.logger.debug(`Parsed Ruby version: ${version}`); this.logger.debug(`Parsed gem paths: ${gemPath}`); this.logger.debug(`Parsed YJIT status: ${yjit}`); + this.logger.debug(`Parsed ZJIT status: ${zjit}`); this.logger.debug(`Parsed ${envEntries.length} environment variables`); const availableJITs: JitType[] = []; - - if (yjit) availableJITs.push(JitType.YJIT); + if (yjit === "true") availableJITs.push(JitType.YJIT); + if (zjit === "true") availableJITs.push(JitType.ZJIT); return { error: false, rubyVersion: version, gemPath: gemPath.split(","), - availableJITs: availableJITs, + availableJITs, env: Object.fromEntries(envEntries.map((entry: string) => entry.split(VALUE_SEPARATOR))) as NodeJS.ProcessEnv, }; } diff --git a/src/rubyEnvironment.ts b/src/rubyEnvironment.ts index 580dc11..b8df158 100644 --- a/src/rubyEnvironment.ts +++ b/src/rubyEnvironment.ts @@ -51,7 +51,7 @@ export class RubyEnvironment implements RubyEnvironmentsApi { } else { this.logger.info(`Ruby activated: ${this.currentRubyDefinition.rubyVersion}`); if (this.currentRubyDefinition.availableJITs.length > 0) { - this.logger.info(`JITs available: ${this.currentRubyDefinition.availableJITs.join(", ")}`); + this.logger.info(`Available JITs: ${this.currentRubyDefinition.availableJITs.join(", ")}`); } this.logger.debug(`Gem paths: ${this.currentRubyDefinition.gemPath.join(", ")}`); } diff --git a/src/test/configuredRuby.test.ts b/src/test/configuredRuby.test.ts index 5f8c005..b13df9e 100644 --- a/src/test/configuredRuby.test.ts +++ b/src/test/configuredRuby.test.ts @@ -78,6 +78,7 @@ suite("ConfiguredRuby", () => { "3.3.0", "/path/to/gems,/another/path", "true", + "false", `PATH${VALUE_SEPARATOR}/usr/bin`, `HOME${VALUE_SEPARATOR}/home/user`, ].join(FIELD_SEPARATOR); @@ -131,7 +132,7 @@ suite("ConfiguredRuby", () => { }); versionManager = new ConfiguredRuby(mockWorkspace, mockContext, mockLogger); - const envStub = ["3.2.0", "/gems", "false"].join(FIELD_SEPARATOR); + const envStub = ["3.2.0", "/gems", "false", "false"].join(FIELD_SEPARATOR); const execStub = sandbox.stub(common, "asyncExec").resolves({ stdout: "", stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, diff --git a/src/test/status.test.ts b/src/test/status.test.ts index 9d58105..a46b385 100644 --- a/src/test/status.test.ts +++ b/src/test/status.test.ts @@ -110,4 +110,19 @@ suite("RubyStatus", () => { assert.strictEqual(status.item.text, "Ruby 3.2.0"); }); + + test("Refresh with multiple JITs displays all", () => { + status = new RubyStatus(); + const rubyDefinition: RubyDefinition = { + error: false, + rubyVersion: "4.0.0", + availableJITs: [JitType.YJIT, JitType.ZJIT], + env: {}, + gemPath: [], + }; + status.refresh(rubyDefinition); + + assert.strictEqual(status.item.text, "Ruby 4.0.0 (YJIT, ZJIT)"); + assert.strictEqual(status.item.severity, vscode.LanguageStatusSeverity.Information); + }); }); From 5502e4ce3e843506281f0c98057ce9528718ad5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Mon, 26 Jan 2026 17:56:44 -0500 Subject: [PATCH 14/14] Remove all activation logic from this extension Other extension should be the ones chosing when to activate it. --- package.json | 6 +--- src/extension.ts | 3 +- src/rubyEnvironment.ts | 14 +++++---- src/test/extension.test.ts | 35 ++++++++++++++++++++-- src/test/rubyEnvironment.test.ts | 50 ++++++++++++++------------------ 5 files changed, 64 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index 54be936..b64115c 100644 --- a/package.json +++ b/package.json @@ -15,11 +15,7 @@ "categories": [ "Programming Languages" ], - "activationEvents": [ - "onLanguage:ruby", - "workspaceContains:Gemfile", - "workspaceContains:gems.rb" - ], + "activationEvents": [], "main": "./dist/extension.js", "contributes": { "commands": [ diff --git a/src/extension.ts b/src/extension.ts index 49efab9..9663e69 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,12 +2,11 @@ import * as vscode from "vscode"; import { RubyEnvironmentsApi } from "./types"; import { RubyEnvironment } from "./rubyEnvironment"; -export async function activate(context: vscode.ExtensionContext): Promise { +export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi { const outputChannel = vscode.window.createOutputChannel("Ruby Environments", { log: true }); context.subscriptions.push(outputChannel); const rubyEnvironment = new RubyEnvironment(context, outputChannel); - await rubyEnvironment.activate(); return rubyEnvironment; } diff --git a/src/rubyEnvironment.ts b/src/rubyEnvironment.ts index b8df158..0eaea07 100644 --- a/src/rubyEnvironment.ts +++ b/src/rubyEnvironment.ts @@ -8,18 +8,22 @@ import { VersionManager } from "./versionManager"; * Main class that manages the Ruby environment state and lifecycle */ export class RubyEnvironment implements RubyEnvironmentsApi { - private versionManager: VersionManager; + private versionManager: VersionManager | null = null; private currentRubyDefinition: RubyDefinition | null = null; + private workspaceFolder: vscode.WorkspaceFolder | undefined; + private status: RubyStatus | null = null; + private readonly logger: vscode.LogOutputChannel; private readonly context: vscode.ExtensionContext; - private readonly workspaceFolder: vscode.WorkspaceFolder | undefined; - private readonly status: RubyStatus; // Event emitter for Ruby environment changes private readonly rubyChangeEmitter = new vscode.EventEmitter(); constructor(context: vscode.ExtensionContext, logger: vscode.LogOutputChannel) { this.context = context; this.logger = logger; + } + + async activate(): Promise { this.workspaceFolder = vscode.workspace.workspaceFolders?.[0]; this.logger.info("Ruby Environments extension activating..."); @@ -35,14 +39,12 @@ export class RubyEnvironment implements RubyEnvironmentsApi { // Create the status item this.status = new RubyStatus(); - context.subscriptions.push(this.status); + this.context.subscriptions.push(this.status); // Setup watchers and commands this.setupConfigWatcher(); this.registerCommands(); - } - async activate(): Promise { this.logger.info("Activating Ruby environment..."); this.currentRubyDefinition = await this.versionManager.activate(); diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 7d67638..667da56 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -1,8 +1,39 @@ import * as assert from "assert"; -import { suite, test } from "mocha"; -import { deactivate } from "../extension"; +import { suite, test, beforeEach, afterEach } from "mocha"; +import { activate, deactivate } from "../extension"; +import { FakeContext, createContext } from "./helpers"; +import { RubyEnvironmentsApi } from "../types"; suite("Extension Test Suite", () => { + suite("activate", () => { + let context: FakeContext; + + beforeEach(() => { + context = createContext(); + }); + + afterEach(() => { + context.dispose(); + }); + + test("returns an object implementing RubyEnvironmentsApi", () => { + const api = activate(context); + + // Verify the returned object has the required API methods + assert.strictEqual(typeof api, "object", "activate should return an object"); + assert.strictEqual(typeof api.activate, "function", "API should have an activate method"); + assert.strictEqual(typeof api.getRuby, "function", "API should have a getRuby method"); + }); + + test("returned API conforms to RubyEnvironmentsApi interface", () => { + const api = activate(context); + + // Type assertion to ensure the return value conforms to the interface + const typedApi: RubyEnvironmentsApi = api; + assert.ok(typedApi, "API should conform to RubyEnvironmentsApi interface"); + }); + }); + suite("deactivate", () => { test("can be called without errors", () => { assert.doesNotThrow(() => { diff --git a/src/test/rubyEnvironment.test.ts b/src/test/rubyEnvironment.test.ts index e0d6f42..ef84c4b 100644 --- a/src/test/rubyEnvironment.test.ts +++ b/src/test/rubyEnvironment.test.ts @@ -2,23 +2,9 @@ import * as assert from "assert"; import { suite, test, beforeEach, afterEach } from "mocha"; import * as vscode from "vscode"; import { RubyEnvironment } from "../rubyEnvironment"; +import { FakeContext, createContext } from "./helpers"; suite("RubyEnvironment Test Suite", () => { - type FakeContext = vscode.ExtensionContext & { dispose: () => void }; - - function createContext() { - const subscriptions: vscode.Disposable[] = []; - - return { - subscriptions, - dispose: () => { - subscriptions.forEach((subscription) => { - subscription.dispose(); - }); - }, - } as unknown as FakeContext; - } - function createMockLogger(): vscode.LogOutputChannel { return { info: () => {}, @@ -50,14 +36,27 @@ suite("RubyEnvironment Test Suite", () => { assert.strictEqual(typeof rubyEnvironment.getRuby, "function", "getRuby should be a function"); }); - test("registers config watcher, status item, and command subscriptions", () => { - new RubyEnvironment(context, mockLogger); + suite("activate", () => { + test("registers config watcher, status item, and command subscriptions", async () => { + const rubyEnvironment = new RubyEnvironment(context, mockLogger); + + await rubyEnvironment.activate(); - assert.strictEqual( - context.subscriptions.length, - 3, - "Extension should register three subscriptions (status item, config watcher, and command)", - ); + assert.strictEqual( + context.subscriptions.length, + 3, + "Extension should register three subscriptions (status item, config watcher, and command)", + ); + }); + + test("registers selectRubyVersion command", async () => { + const rubyEnvironment = new RubyEnvironment(context, mockLogger); + + await rubyEnvironment.activate(); + + const commands = await vscode.commands.getCommands(true); + assert.ok(commands.includes("ruby-environments.selectRubyVersion"), "Command should be registered"); + }); }); test("returns initial Ruby definition from configuration", () => { @@ -68,12 +67,5 @@ suite("RubyEnvironment Test Suite", () => { // Since no configuration is set in tests, it should return null assert.strictEqual(result, null, "getRuby should return null when no configuration is set"); }); - - test("registers selectRubyVersion command", async () => { - new RubyEnvironment(context, mockLogger); - - const commands = await vscode.commands.getCommands(true); - assert.ok(commands.includes("ruby-environments.selectRubyVersion"), "Command should be registered"); - }); }); });