From 5c785ae122366dc33f7f8fc161e2a4c7dfda741c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 23 Jan 2026 14:44:06 -0500 Subject: [PATCH 1/4] Add initial implementation of Ruby status bar item showing Ruby version --- package.json | 4 +- src/extension.ts | 61 +++----------------- src/status.ts | 36 ++++++++++++ src/test/extension.test.ts | 7 ++- src/test/status.test.ts | 111 +++++++++++++++++++++++++++++++++++++ src/types.ts | 52 +++++++++++++++++ 6 files changed, 215 insertions(+), 56 deletions(-) create mode 100644 src/status.ts create mode 100644 src/test/status.test.ts create mode 100644 src/types.ts diff --git a/package.json b/package.json index 26a2eb0..71a6805 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "categories": [ "Programming Languages" ], - "activationEvents": [], + "activationEvents": [ + "onLanguage:ruby" + ], "main": "./dist/extension.js", "contributes": {}, "scripts": { diff --git a/src/extension.ts b/src/extension.ts index d93dec3..f273ad4 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,55 +1,6 @@ import * as vscode from "vscode"; - -/** - * Represents a Ruby environment that failed to activate - */ -export interface RubyError { - error: true; -} - -/** - * JIT compiler types supported by Ruby - */ -export enum JitType { - YJIT = "YJIT", - ZJIT = "ZJIT", -} - -/** - * Represents a successfully activated Ruby environment - */ -export interface RubyEnvironment { - error: false; - rubyVersion: string; - availableJITs: JitType[]; - env: NodeJS.ProcessEnv; - gemPath: string[]; -} - -/** - * Represents a Ruby environment definition - either an error or a successful environment - */ -export type RubyDefinition = RubyError | RubyEnvironment; - -/** - * Event data emitted when the Ruby environment changes - */ -export interface RubyChangeEvent { - workspace: vscode.WorkspaceFolder | undefined; - ruby: RubyDefinition; -} - -/** - * The public API that gets exposed to other extensions that depend on Ruby environments - */ -export interface RubyEnvironmentsApi { - /** Activate the extension for a specific workspace */ - activate: (workspace: vscode.WorkspaceFolder | undefined) => Promise; - /** Get the current Ruby definition */ - getRuby: () => RubyDefinition | null; - /** Event that fires when the Ruby environment changes */ - onDidRubyChange: vscode.Event; -} +import { RubyStatus } from "./status"; +import { RubyChangeEvent, RubyEnvironmentsApi } from "./types"; // Event emitter for Ruby environment changes const rubyChangeEmitter = new vscode.EventEmitter(); @@ -58,6 +9,10 @@ export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi // 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); + return { activate: async (_workspace: vscode.WorkspaceFolder | undefined) => {}, getRuby: () => null, @@ -65,4 +20,6 @@ export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi }; } -export function deactivate() {} +export function deactivate() { + // Extension cleanup happens automatically via context.subscriptions +} diff --git a/src/status.ts b/src/status.ts new file mode 100644 index 0000000..ad5ba46 --- /dev/null +++ b/src/status.ts @@ -0,0 +1,36 @@ +import * as vscode from "vscode"; +import { RubyDefinition } from "./types"; + +export class RubyStatus { + public readonly item: vscode.LanguageStatusItem; + + constructor() { + this.item = vscode.languages.createLanguageStatusItem("ruby-environments-status", { + language: "ruby", + }); + + this.item.name = "Ruby Environment"; + this.item.severity = vscode.LanguageStatusSeverity.Information; + } + + refresh(rubyDefinition: RubyDefinition | null) { + if (rubyDefinition === null) { + this.item.text = "Ruby: Not detected"; + this.item.detail = "No Ruby environment detected"; + this.item.severity = vscode.LanguageStatusSeverity.Warning; + } else if (rubyDefinition.error) { + this.item.text = "Ruby: Error"; + this.item.detail = "Error detecting Ruby environment"; + this.item.severity = vscode.LanguageStatusSeverity.Error; + } else { + const version = rubyDefinition.rubyVersion || "unknown"; + const jitStatus = rubyDefinition.availableJITs.length > 0 ? ` (${rubyDefinition.availableJITs.join(", ")})` : ""; + this.item.text = `Ruby ${version}${jitStatus}`; + this.item.severity = vscode.LanguageStatusSeverity.Information; + } + } + + dispose() { + this.item.dispose(); + } +} diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 23cc452..4c10c0f 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -1,7 +1,8 @@ import * as assert from "assert"; import { suite, test, beforeEach, afterEach } from "mocha"; -import { activate, deactivate, RubyEnvironmentsApi } from "../extension"; +import { activate, deactivate } from "../extension"; import { FakeContext, createContext } from "./helpers"; +import { RubyEnvironmentsApi } from "../types"; suite("Extension Test Suite", () => { suite("activate", () => { @@ -56,12 +57,12 @@ suite("Extension Test Suite", () => { assert.strictEqual(eventFired, false, "event should not have fired yet"); }); - test("adds event emitter to context subscriptions for disposal", () => { + test("adds disposables to context subscriptions for disposal", () => { assert.strictEqual(context.subscriptions.length, 0, "subscriptions should be empty initially"); activate(context); - assert.strictEqual(context.subscriptions.length, 1, "should add emitter to subscriptions"); + assert.strictEqual(context.subscriptions.length, 2, "should add emitter and status to subscriptions"); }); }); diff --git a/src/test/status.test.ts b/src/test/status.test.ts new file mode 100644 index 0000000..68aaaca --- /dev/null +++ b/src/test/status.test.ts @@ -0,0 +1,111 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import { afterEach } from "mocha"; +import { RubyStatus } from "../status"; +import { RubyDefinition, JitType } from "../types"; + +suite("RubyStatus", () => { + let status: RubyStatus; + + afterEach(() => { + status.dispose(); + }); + + test("Status is initialized with the right values", () => { + status = new RubyStatus(); + + assert.strictEqual(status.item.name, "Ruby Environment"); + assert.strictEqual(status.item.severity, vscode.LanguageStatusSeverity.Information); + }); + + test("Refresh with null displays not detected", () => { + status = new RubyStatus(); + status.refresh(null); + + assert.strictEqual(status.item.text, "Ruby: Not detected"); + assert.strictEqual(status.item.detail, "No Ruby environment detected"); + assert.strictEqual(status.item.severity, vscode.LanguageStatusSeverity.Warning); + }); + + test("Refresh with error displays error state", () => { + status = new RubyStatus(); + const rubyDefinition: RubyDefinition = { + error: true, + }; + status.refresh(rubyDefinition); + + assert.strictEqual(status.item.text, "Ruby: Error"); + assert.strictEqual(status.item.detail, "Error detecting Ruby environment"); + assert.strictEqual(status.item.severity, vscode.LanguageStatusSeverity.Error); + }); + + test("Refresh with Ruby version displays version", () => { + status = new RubyStatus(); + const rubyDefinition: RubyDefinition = { + error: false, + rubyVersion: "3.3.0", + availableJITs: [], + env: {}, + gemPath: [], + }; + status.refresh(rubyDefinition); + + assert.strictEqual(status.item.text, "Ruby 3.3.0"); + assert.strictEqual(status.item.severity, vscode.LanguageStatusSeverity.Information); + }); + + test("Refresh with Ruby version and YJIT displays both", () => { + status = new RubyStatus(); + const rubyDefinition: RubyDefinition = { + error: false, + rubyVersion: "3.3.0", + availableJITs: [JitType.YJIT], + env: {}, + gemPath: [], + }; + status.refresh(rubyDefinition); + + assert.strictEqual(status.item.text, "Ruby 3.3.0 (YJIT)"); + assert.strictEqual(status.item.severity, vscode.LanguageStatusSeverity.Information); + }); + + test("Refresh with multiple JITs displays all", () => { + status = new RubyStatus(); + const rubyDefinition: RubyDefinition = { + error: false, + rubyVersion: "3.4.0", + availableJITs: [JitType.YJIT, JitType.ZJIT], + env: {}, + gemPath: [], + }; + status.refresh(rubyDefinition); + + assert.strictEqual(status.item.text, "Ruby 3.4.0 (YJIT, ZJIT)"); + assert.strictEqual(status.item.severity, vscode.LanguageStatusSeverity.Information); + }); + + test("Refresh updates existing status", () => { + status = new RubyStatus(); + const rubyDefinition1: RubyDefinition = { + error: false, + rubyVersion: "3.3.0", + availableJITs: [JitType.YJIT], + env: {}, + gemPath: [], + }; + status.refresh(rubyDefinition1); + + assert.strictEqual(status.item.text, "Ruby 3.3.0 (YJIT)"); + + const rubyDefinition2: RubyDefinition = { + error: false, + rubyVersion: "3.2.0", + availableJITs: [], + env: {}, + gemPath: [], + }; + status.refresh(rubyDefinition2); + + assert.strictEqual(status.item.text, "Ruby 3.2.0"); + }); +}); diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..13a5af8 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,52 @@ +import * as vscode from "vscode"; + +/** + * Represents a Ruby environment that failed to activate + */ +export interface RubyError { + error: true; +} + +/** + * JIT compiler types supported by Ruby + */ +export enum JitType { + YJIT = "YJIT", + ZJIT = "ZJIT", +} + +/** + * Represents a successfully activated Ruby environment + */ +export interface RubyEnvironment { + error: false; + rubyVersion: string; + availableJITs: JitType[]; + env: NodeJS.ProcessEnv; + gemPath: string[]; +} + +/** + * Represents a Ruby environment definition - either an error or a successful environment + */ +export type RubyDefinition = RubyError | RubyEnvironment; + +/** + * Event data emitted when the Ruby environment changes + */ +export interface RubyChangeEvent { + workspace: vscode.WorkspaceFolder | undefined; + ruby: RubyDefinition; +} + +/** + * The public API that gets exposed to other extensions that depend on Ruby environments + */ +export interface RubyEnvironmentsApi { + /** Activate the extension for a specific workspace */ + activate: (workspace: vscode.WorkspaceFolder | undefined) => Promise; + /** Get the current Ruby definition */ + getRuby: () => RubyDefinition | null; + /** Event that fires when the Ruby environment changes */ + onDidRubyChange: vscode.Event; +} From bed170ac264002ade10843f65ef54ae72f3b847d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 23 Jan 2026 15:12:57 -0500 Subject: [PATCH 2/4] Allow user to select Ruby versions via command palette and store selection in configuration --- package.json | 21 +++- src/extension.ts | 27 ++--- src/rubyEnvironment.ts | 136 +++++++++++++++++++++ src/status.ts | 19 ++- src/test/extension.test.ts | 44 +++++-- src/test/helpers.ts | 11 ++ src/test/rubyEnvironment.test.ts | 199 +++++++++++++++++++++++++++++++ src/test/status.test.ts | 51 ++++---- src/types.ts | 9 +- 9 files changed, 451 insertions(+), 66 deletions(-) create mode 100644 src/rubyEnvironment.ts create mode 100644 src/test/rubyEnvironment.test.ts diff --git a/package.json b/package.json index 71a6805..9bd7392 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,26 @@ "onLanguage:ruby" ], "main": "./dist/extension.js", - "contributes": {}, + "contributes": { + "commands": [ + { + "command": "ruby-environments.selectRuby", + "title": "Select Ruby Installation", + "category": "Ruby" + } + ], + "configuration": { + "title": "Ruby Environments", + "properties": { + "rubyEnvironments.rubyPath": { + "type": "string", + "default": null, + "description": "The path to a Ruby installation (e.g., '/usr/local/bin/ruby' or '/opt/rubies/ruby-3.3.0/bin/ruby'). If not set, will attempt to detect automatically.", + "scope": "machine-overridable" + } + } + } + }, "scripts": { "vscode:prepublish": "yarn run package", "compile": "yarn run check-types && yarn run lint && node esbuild.js && yarn run build:types", diff --git a/src/extension.ts b/src/extension.ts index f273ad4..837dec0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,23 +1,12 @@ import * as vscode from "vscode"; -import { RubyStatus } from "./status"; -import { RubyChangeEvent, RubyEnvironmentsApi } from "./types"; - -// Event emitter for Ruby environment changes -const rubyChangeEmitter = new vscode.EventEmitter(); - -export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi { - // 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); - - return { - activate: async (_workspace: vscode.WorkspaceFolder | undefined) => {}, - getRuby: () => null, - onDidRubyChange: rubyChangeEmitter.event, - }; +import { RubyEnvironmentManager } from "./rubyEnvironment"; +import { RubyEnvironmentsApi } from "./types"; + +export async function activate(context: vscode.ExtensionContext): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const manager = new RubyEnvironmentManager(context); + await manager.activate(workspaceFolder); + return manager; } export function deactivate() { diff --git a/src/rubyEnvironment.ts b/src/rubyEnvironment.ts new file mode 100644 index 0000000..a0504ff --- /dev/null +++ b/src/rubyEnvironment.ts @@ -0,0 +1,136 @@ +import * as vscode from "vscode"; +import { RubyStatus } from "./status"; +import { RubyChangeEvent, OptionalRubyDefinition, RubyEnvironmentsApi } from "./types"; + +export class RubyEnvironmentManager implements RubyEnvironmentsApi { + private readonly context: vscode.ExtensionContext; + private readonly status: RubyStatus; + private readonly changeEmitter: vscode.EventEmitter; + private currentRubyDefinition: OptionalRubyDefinition = null; + + constructor(context: vscode.ExtensionContext) { + this.context = context; + this.changeEmitter = new vscode.EventEmitter(); + this.status = new RubyStatus(this.changeEmitter.event); + + // Register disposables + context.subscriptions.push(this.status); + context.subscriptions.push(this.changeEmitter); + + // Watch for configuration changes + const configWatcher = vscode.workspace.onDidChangeConfiguration((e) => { + if (e.affectsConfiguration("rubyEnvironments")) { + this.updateRubyDefinition(vscode.workspace.workspaceFolders?.[0]); + } + }); + context.subscriptions.push(configWatcher); + + // Register command to select Ruby installation + const selectRuby = vscode.commands.registerCommand("ruby-environments.selectRuby", async () => { + await this.selectRuby(); + }); + context.subscriptions.push(selectRuby); + } + + activate(workspace: vscode.WorkspaceFolder | undefined): Promise { + // Load Ruby definition from configuration and emit change event + this.updateRubyDefinition(workspace); + return Promise.resolve(); + } + + getRuby(): OptionalRubyDefinition { + return this.currentRubyDefinition; + } + + get onDidRubyChange(): vscode.Event { + return this.changeEmitter.event; + } + + private updateRubyDefinition(workspace: vscode.WorkspaceFolder | undefined): void { + this.currentRubyDefinition = this.getRubyDefinitionFromConfig(); + this.changeEmitter.fire({ + workspace: workspace, + ruby: this.currentRubyDefinition, + }); + } + + private getRubyDefinitionFromConfig(): OptionalRubyDefinition { + const rubyPath = this.getRubyPath(); + + if (!rubyPath) { + return null; + } + + return { + error: true, + }; + } + + private getRubyPath(): string | undefined { + // First check workspace state (set by the selectRuby command) + const workspaceRubyPath = this.context.workspaceState.get("rubyPath"); + + // Then fall back to configuration + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + const configuredRubyPath = config.get("rubyPath"); + + return workspaceRubyPath || configuredRubyPath; + } + + private async selectRuby(): Promise { + const rubyPath = this.getRubyPath(); + + // 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: ${rubyPath || "not set"}`, + }, + ); + + if (!option) { + return; + } + + let newPath: string | undefined; + + 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) { + newPath = uris[0].fsPath; + } + } else if (option.value === "manual") { + newPath = await vscode.window.showInputBox({ + prompt: "Enter Ruby executable path", + value: rubyPath, + placeHolder: "ruby", + validateInput: (value) => { + if (!value || value.trim().length === 0) { + return "Path cannot be empty"; + } + return null; + }, + }); + } + + if (newPath) { + await this.context.workspaceState.update("rubyPath", newPath); + this.updateRubyDefinition(vscode.workspace.workspaceFolders?.[0]); + vscode.window.showInformationMessage(`Ruby executable path updated to ${newPath}`); + } + } +} diff --git a/src/status.ts b/src/status.ts index ad5ba46..a8345fa 100644 --- a/src/status.ts +++ b/src/status.ts @@ -1,19 +1,29 @@ import * as vscode from "vscode"; -import { RubyDefinition } from "./types"; +import { RubyChangeEvent, OptionalRubyDefinition } from "./types"; -export class RubyStatus { +export class RubyStatus implements vscode.Disposable { public readonly item: vscode.LanguageStatusItem; + private readonly subscription: vscode.Disposable; - constructor() { + constructor(onDidRubyChange: vscode.Event) { this.item = vscode.languages.createLanguageStatusItem("ruby-environments-status", { language: "ruby", }); this.item.name = "Ruby Environment"; this.item.severity = vscode.LanguageStatusSeverity.Information; + this.item.command = { + title: "Select", + command: "ruby-environments.selectRuby", + }; + + // Subscribe to Ruby change events + this.subscription = onDidRubyChange((event) => { + this.refresh(event.ruby); + }); } - refresh(rubyDefinition: RubyDefinition | null) { + private refresh(rubyDefinition: OptionalRubyDefinition) { if (rubyDefinition === null) { this.item.text = "Ruby: Not detected"; this.item.detail = "No Ruby environment detected"; @@ -31,6 +41,7 @@ export class RubyStatus { } dispose() { + this.subscription.dispose(); this.item.dispose(); } } diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 4c10c0f..6215cca 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -1,4 +1,5 @@ import * as assert from "assert"; +import * as vscode from "vscode"; import { suite, test, beforeEach, afterEach } from "mocha"; import { activate, deactivate } from "../extension"; import { FakeContext, createContext } from "./helpers"; @@ -17,7 +18,7 @@ suite("Extension Test Suite", () => { }); test("returns an object implementing RubyEnvironmentsApi", async () => { - const api = activate(context); + const api = await activate(context); assert.strictEqual(typeof api, "object", "activate should return an object"); assert.strictEqual(typeof api.activate, "function", "API should have an activate method"); @@ -29,21 +30,26 @@ suite("Extension Test Suite", () => { await result; }); - test("returned API conforms to RubyEnvironmentsApi interface", () => { - const api = activate(context); + test("returned API conforms to RubyEnvironmentsApi interface", async () => { + const api = await activate(context); const typedApi: RubyEnvironmentsApi = api; assert.ok(typedApi, "API should conform to RubyEnvironmentsApi interface"); }); - test("getRuby returns null initially", () => { - const api = activate(context); + test("getRuby returns Ruby definition after activation", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); - assert.strictEqual(api.getRuby(), null, "getRuby should return null before activation"); + const api = await activate(context); + await api.activate(undefined); + + const ruby = api.getRuby(); + assert.ok(ruby !== null, "getRuby should return a Ruby definition after activation"); }); - test("onDidRubyChange allows subscribing to events", () => { - const api = activate(context); + test("onDidRubyChange allows subscribing to events", async () => { + const api = await activate(context); let eventFired = false; const disposable = api.onDidRubyChange(() => { @@ -57,12 +63,28 @@ suite("Extension Test Suite", () => { assert.strictEqual(eventFired, false, "event should not have fired yet"); }); - test("adds disposables to context subscriptions for disposal", () => { + test("registers emitter, status, config watcher, and command subscriptions", async () => { assert.strictEqual(context.subscriptions.length, 0, "subscriptions should be empty initially"); - activate(context); + await activate(context); + + assert.strictEqual( + context.subscriptions.length, + 4, + "Extension should register four subscriptions (emitter, status, config watcher, and command)", + ); + }); + }); + + suite("selectRuby command", () => { + test("command is registered", async () => { + const mockContext = createContext(); + await activate(mockContext); + + const commands = await vscode.commands.getCommands(true); + assert.ok(commands.includes("ruby-environments.selectRuby"), "Command should be registered"); - assert.strictEqual(context.subscriptions.length, 2, "should add emitter and status to subscriptions"); + mockContext.dispose(); }); }); diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 5ad514c..4f0f831 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -4,9 +4,20 @@ export type FakeContext = vscode.ExtensionContext & { dispose: () => void }; export function createContext(): FakeContext { const subscriptions: vscode.Disposable[] = []; + const workspaceStateStorage = new Map(); return { subscriptions, + workspaceState: { + get: (key: string): T | undefined => { + return workspaceStateStorage.get(key) as T | undefined; + }, + update: (key: string, value: unknown): Thenable => { + workspaceStateStorage.set(key, value); + return Promise.resolve(); + }, + keys: () => Array.from(workspaceStateStorage.keys()), + }, dispose: () => { subscriptions.forEach((subscription) => { subscription.dispose(); diff --git a/src/test/rubyEnvironment.test.ts b/src/test/rubyEnvironment.test.ts new file mode 100644 index 0000000..6f9030f --- /dev/null +++ b/src/test/rubyEnvironment.test.ts @@ -0,0 +1,199 @@ +import * as assert from "assert"; +import * as vscode from "vscode"; +import { suite, test, beforeEach, afterEach } from "mocha"; +import { RubyEnvironmentManager } from "../rubyEnvironment"; +import { FakeContext, createContext } from "./helpers"; +import { RubyChangeEvent } from "../types"; + +suite("RubyEnvironmentManager", () => { + let context: FakeContext; + let manager: RubyEnvironmentManager; + + beforeEach(() => { + context = createContext(); + }); + + afterEach(async () => { + context.dispose(); + // Clean up any configuration changes + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", undefined, vscode.ConfigurationTarget.Global); + }); + + suite("constructor", () => { + test("registers subscriptions", () => { + const initialSubscriptions = context.subscriptions.length; + manager = new RubyEnvironmentManager(context); + + assert.ok(context.subscriptions.length > initialSubscriptions, "Manager should register subscriptions"); + }); + + test("registers selectRuby command", async () => { + manager = new RubyEnvironmentManager(context); + + const commands = await vscode.commands.getCommands(true); + assert.ok(commands.includes("ruby-environments.selectRuby"), "Command should be registered"); + }); + }); + + suite("getRuby", () => { + test("returns null before activation", () => { + manager = new RubyEnvironmentManager(context); + + const ruby = manager.getRuby(); + assert.strictEqual(ruby, null, "Should return null before activation"); + }); + + test("returns Ruby definition after activation", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + + manager = new RubyEnvironmentManager(context); + await manager.activate(undefined); + + const ruby = manager.getRuby(); + assert.ok(ruby !== null, "Should return Ruby definition after activation"); + if (ruby !== null) { + assert.strictEqual(ruby.error, true, "Should return error for mock ruby path"); + } + }); + + test("returns Ruby definition from workspace state after activation", async () => { + await context.workspaceState.update("rubyPath", "/custom/ruby/path"); + manager = new RubyEnvironmentManager(context); + await manager.activate(undefined); + + const ruby = manager.getRuby(); + assert.ok(ruby !== null, "Should return Ruby definition"); + if (ruby !== null) { + assert.strictEqual(ruby.error, true, "Should return error state for configured path"); + } + }); + }); + + suite("activate", () => { + test("initializes Ruby definition", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + + manager = new RubyEnvironmentManager(context); + assert.strictEqual(manager.getRuby(), null, "Should be null before activation"); + + await manager.activate(undefined); + + const ruby = manager.getRuby(); + assert.ok(ruby !== null, "Should have Ruby definition after activation"); + if (ruby !== null) { + assert.strictEqual(ruby.error, true, "Should return error for mock ruby path"); + } + }); + }); + + suite("onDidRubyChange", () => { + test("is an event that can be subscribed to", () => { + manager = new RubyEnvironmentManager(context); + + let eventFired = false; + const disposable = manager.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("fires when activate is called", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + + manager = new RubyEnvironmentManager(context); + + let eventFired = false; + let receivedEvent: RubyChangeEvent | undefined; + const disposable = manager.onDidRubyChange((event) => { + eventFired = true; + receivedEvent = event; + }); + + await manager.activate(undefined); + + assert.strictEqual(eventFired, true, "Event should have fired after activation"); + assert.ok(receivedEvent, "Event data should be received"); + assert.strictEqual(receivedEvent.workspace, undefined, "Workspace should be undefined"); + assert.ok(receivedEvent.ruby !== null, "Ruby should be defined when configured"); + + disposable.dispose(); + }); + + test("fires when configuration changes after activation", async () => { + manager = new RubyEnvironmentManager(context); + await manager.activate(undefined); + + let eventCount = 0; + const disposable = manager.onDidRubyChange(() => { + eventCount++; + }); + + // Trigger configuration change + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/test/ruby/path", vscode.ConfigurationTarget.Global); + + // Wait a bit for the event to fire + await new Promise((resolve) => setTimeout(resolve, 100)); + + assert.ok(eventCount > 0, "Event should have fired when configuration changed"); + + disposable.dispose(); + }); + + test("includes workspace in event from workspaceFolders", async () => { + manager = new RubyEnvironmentManager(context); + + let receivedEvent: RubyChangeEvent | undefined; + const disposable = manager.onDidRubyChange((event) => { + receivedEvent = event; + }); + + const mockWorkspace = { + uri: vscode.Uri.file("/test/workspace"), + name: "test-workspace", + index: 0, + } as vscode.WorkspaceFolder; + + await manager.activate(mockWorkspace); + + assert.ok(receivedEvent, "Event should have been received"); + assert.strictEqual(receivedEvent.workspace, mockWorkspace, "Workspace should match"); + + disposable.dispose(); + }); + }); + + suite("configuration changes", () => { + test("updates Ruby definition when workspace state changes after activation", async () => { + const config = vscode.workspace.getConfiguration("rubyEnvironments"); + await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + + manager = new RubyEnvironmentManager(context); + await manager.activate(undefined); + + const initialRuby = manager.getRuby(); + assert.ok(initialRuby !== null, "Initial Ruby should be defined after activation"); + + // Dispose the first manager before creating a new one + context.dispose(); + context = createContext(); + + // Simulate workspace state change by creating a new manager with updated state + await context.workspaceState.update("rubyPath", "/new/ruby/path"); + const newManager = new RubyEnvironmentManager(context); + await newManager.activate(undefined); + + const updatedRuby = newManager.getRuby(); + assert.ok(updatedRuby, "Updated Ruby should be defined after activation"); + }); + }); +}); diff --git a/src/test/status.test.ts b/src/test/status.test.ts index 68aaaca..d42e8e7 100644 --- a/src/test/status.test.ts +++ b/src/test/status.test.ts @@ -1,46 +1,42 @@ import * as assert from "assert"; import * as vscode from "vscode"; -import { afterEach } from "mocha"; +import { beforeEach, afterEach } from "mocha"; import { RubyStatus } from "../status"; -import { RubyDefinition, JitType } from "../types"; +import { RubyDefinition, RubyChangeEvent, JitType } from "../types"; suite("RubyStatus", () => { let status: RubyStatus; + let changeEmitter: vscode.EventEmitter; + + beforeEach(() => { + changeEmitter = new vscode.EventEmitter(); + status = new RubyStatus(changeEmitter.event); + }); afterEach(() => { status.dispose(); + changeEmitter.dispose(); }); test("Status is initialized with the right values", () => { - status = new RubyStatus(); - assert.strictEqual(status.item.name, "Ruby Environment"); assert.strictEqual(status.item.severity, vscode.LanguageStatusSeverity.Information); + assert.strictEqual(status.item.command?.title, "Select"); + assert.strictEqual(status.item.command?.command, "ruby-environments.selectRuby"); }); - test("Refresh with null displays not detected", () => { - status = new RubyStatus(); - status.refresh(null); - - assert.strictEqual(status.item.text, "Ruby: Not detected"); - assert.strictEqual(status.item.detail, "No Ruby environment detected"); - assert.strictEqual(status.item.severity, vscode.LanguageStatusSeverity.Warning); - }); - - test("Refresh with error displays error state", () => { - status = new RubyStatus(); + test("Updates when event fires with error", () => { const rubyDefinition: RubyDefinition = { error: true, }; - status.refresh(rubyDefinition); + changeEmitter.fire({ workspace: undefined, ruby: rubyDefinition }); assert.strictEqual(status.item.text, "Ruby: Error"); assert.strictEqual(status.item.detail, "Error detecting Ruby environment"); assert.strictEqual(status.item.severity, vscode.LanguageStatusSeverity.Error); }); - test("Refresh with Ruby version displays version", () => { - status = new RubyStatus(); + test("Updates when event fires with Ruby version", () => { const rubyDefinition: RubyDefinition = { error: false, rubyVersion: "3.3.0", @@ -48,14 +44,13 @@ suite("RubyStatus", () => { env: {}, gemPath: [], }; - status.refresh(rubyDefinition); + changeEmitter.fire({ workspace: undefined, ruby: rubyDefinition }); assert.strictEqual(status.item.text, "Ruby 3.3.0"); assert.strictEqual(status.item.severity, vscode.LanguageStatusSeverity.Information); }); - test("Refresh with Ruby version and YJIT displays both", () => { - status = new RubyStatus(); + test("Updates when event fires with Ruby version and YJIT", () => { const rubyDefinition: RubyDefinition = { error: false, rubyVersion: "3.3.0", @@ -63,14 +58,13 @@ suite("RubyStatus", () => { env: {}, gemPath: [], }; - status.refresh(rubyDefinition); + changeEmitter.fire({ workspace: undefined, ruby: rubyDefinition }); assert.strictEqual(status.item.text, "Ruby 3.3.0 (YJIT)"); assert.strictEqual(status.item.severity, vscode.LanguageStatusSeverity.Information); }); - test("Refresh with multiple JITs displays all", () => { - status = new RubyStatus(); + test("Updates when event fires with multiple JITs", () => { const rubyDefinition: RubyDefinition = { error: false, rubyVersion: "3.4.0", @@ -78,14 +72,13 @@ suite("RubyStatus", () => { env: {}, gemPath: [], }; - status.refresh(rubyDefinition); + changeEmitter.fire({ workspace: undefined, ruby: rubyDefinition }); assert.strictEqual(status.item.text, "Ruby 3.4.0 (YJIT, ZJIT)"); assert.strictEqual(status.item.severity, vscode.LanguageStatusSeverity.Information); }); - test("Refresh updates existing status", () => { - status = new RubyStatus(); + test("Updates correctly when multiple events fire", () => { const rubyDefinition1: RubyDefinition = { error: false, rubyVersion: "3.3.0", @@ -93,7 +86,7 @@ suite("RubyStatus", () => { env: {}, gemPath: [], }; - status.refresh(rubyDefinition1); + changeEmitter.fire({ workspace: undefined, ruby: rubyDefinition1 }); assert.strictEqual(status.item.text, "Ruby 3.3.0 (YJIT)"); @@ -104,7 +97,7 @@ suite("RubyStatus", () => { env: {}, gemPath: [], }; - status.refresh(rubyDefinition2); + changeEmitter.fire({ workspace: undefined, ruby: rubyDefinition2 }); assert.strictEqual(status.item.text, "Ruby 3.2.0"); }); diff --git a/src/types.ts b/src/types.ts index 13a5af8..535661e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,12 +31,17 @@ export interface RubyEnvironment { */ export type RubyDefinition = RubyError | RubyEnvironment; +/** + * Represents an optional Ruby environment definition + */ +export type OptionalRubyDefinition = RubyDefinition | null; + /** * Event data emitted when the Ruby environment changes */ export interface RubyChangeEvent { workspace: vscode.WorkspaceFolder | undefined; - ruby: RubyDefinition; + ruby: OptionalRubyDefinition; } /** @@ -46,7 +51,7 @@ export interface RubyEnvironmentsApi { /** Activate the extension for a specific workspace */ activate: (workspace: vscode.WorkspaceFolder | undefined) => Promise; /** Get the current Ruby definition */ - getRuby: () => RubyDefinition | null; + getRuby: () => OptionalRubyDefinition; /** Event that fires when the Ruby environment changes */ onDidRubyChange: vscode.Event; } From efb944867cd3f82feef109130ff4b23deb00bef7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Fri, 23 Jan 2026 15:17:20 -0500 Subject: [PATCH 3/4] Activate when in a Ruby project using bundler --- package.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index 9bd7392..706da58 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,9 @@ "Programming Languages" ], "activationEvents": [ - "onLanguage:ruby" + "onLanguage:ruby", + "workspaceContains:Gemfile", + "workspaceContains:gems.rb" ], "main": "./dist/extension.js", "contributes": { From ece302ae2968119212da2e5293b31b175f30b8f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafael=20Mendon=C3=A7a=20Fran=C3=A7a?= Date: Tue, 10 Feb 2026 21:59:36 -0500 Subject: [PATCH 4/4] Activate the selected Ruby Get the correct information from the Ruby environment by running a Ruby script that outputs the necessary details in a structured format. This allows us to avoid hardcoding any assumptions about the environment and ensures compatibility across different Ruby versions and setups. --- activation.rb | 9 ++ package.json | 14 +- src/common.ts | 18 +++ src/rubyEnvironment.ts | 79 ++++++++++-- src/test/extension.test.ts | 26 ---- src/test/helpers.ts | 1 + src/test/rubyEnvironment.test.ts | 211 ++++++++++++++++++------------- yarn.lock | 62 ++++++++- 8 files changed, 288 insertions(+), 132 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..2d65011 --- /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 706da58..9622949 100644 --- a/package.json +++ b/package.json @@ -61,20 +61,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/rubyEnvironment.ts b/src/rubyEnvironment.ts index a0504ff..201b61e 100644 --- a/src/rubyEnvironment.ts +++ b/src/rubyEnvironment.ts @@ -1,6 +1,12 @@ import * as vscode from "vscode"; import { RubyStatus } from "./status"; -import { RubyChangeEvent, OptionalRubyDefinition, RubyEnvironmentsApi } from "./types"; +import { RubyChangeEvent, OptionalRubyDefinition, RubyEnvironmentsApi, RubyDefinition, JitType } from "./types"; +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"; export class RubyEnvironmentManager implements RubyEnvironmentsApi { private readonly context: vscode.ExtensionContext; @@ -18,9 +24,9 @@ export class RubyEnvironmentManager implements RubyEnvironmentsApi { context.subscriptions.push(this.changeEmitter); // Watch for configuration changes - const configWatcher = vscode.workspace.onDidChangeConfiguration((e) => { + const configWatcher = vscode.workspace.onDidChangeConfiguration(async (e) => { if (e.affectsConfiguration("rubyEnvironments")) { - this.updateRubyDefinition(vscode.workspace.workspaceFolders?.[0]); + await this.updateRubyDefinition(vscode.workspace.workspaceFolders?.[0]); } }); context.subscriptions.push(configWatcher); @@ -32,10 +38,9 @@ export class RubyEnvironmentManager implements RubyEnvironmentsApi { context.subscriptions.push(selectRuby); } - activate(workspace: vscode.WorkspaceFolder | undefined): Promise { + async activate(workspace: vscode.WorkspaceFolder | undefined): Promise { // Load Ruby definition from configuration and emit change event - this.updateRubyDefinition(workspace); - return Promise.resolve(); + await this.updateRubyDefinition(workspace); } getRuby(): OptionalRubyDefinition { @@ -46,24 +51,48 @@ export class RubyEnvironmentManager implements RubyEnvironmentsApi { return this.changeEmitter.event; } - private updateRubyDefinition(workspace: vscode.WorkspaceFolder | undefined): void { - this.currentRubyDefinition = this.getRubyDefinitionFromConfig(); + private async updateRubyDefinition(workspace: vscode.WorkspaceFolder | undefined): Promise { + this.currentRubyDefinition = await this.getRubyDefinitionFromConfig(workspace); this.changeEmitter.fire({ workspace: workspace, ruby: this.currentRubyDefinition, }); } - private getRubyDefinitionFromConfig(): OptionalRubyDefinition { + private async getRubyDefinitionFromConfig( + workspace: vscode.WorkspaceFolder | undefined, + ): Promise { const rubyPath = this.getRubyPath(); if (!rubyPath) { return null; } - return { - error: true, - }; + try { + const activationScriptUri = vscode.Uri.joinPath(this.context.extensionUri, "activation.rb"); + + const command = `${rubyPath} -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; + } + + const cwd = workspace?.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 getRubyPath(): string | undefined { @@ -77,6 +106,30 @@ export class RubyEnvironmentManager implements RubyEnvironmentsApi { return workspaceRubyPath || configuredRubyPath; } + 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: version, + gemPath: gemPath.split(","), + availableJITs: availableJITs, + env: Object.fromEntries(envEntries.map((entry: string) => entry.split(VALUE_SEPARATOR))) as NodeJS.ProcessEnv, + }; + } + private async selectRuby(): Promise { const rubyPath = this.getRubyPath(); @@ -129,7 +182,7 @@ export class RubyEnvironmentManager implements RubyEnvironmentsApi { if (newPath) { await this.context.workspaceState.update("rubyPath", newPath); - this.updateRubyDefinition(vscode.workspace.workspaceFolders?.[0]); + await this.updateRubyDefinition(vscode.workspace.workspaceFolders?.[0]); vscode.window.showInformationMessage(`Ruby executable path updated to ${newPath}`); } } diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 6215cca..81b3d6a 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -37,32 +37,6 @@ suite("Extension Test Suite", () => { assert.ok(typedApi, "API should conform to RubyEnvironmentsApi interface"); }); - test("getRuby returns Ruby definition after activation", async () => { - const config = vscode.workspace.getConfiguration("rubyEnvironments"); - await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); - - const api = await activate(context); - await api.activate(undefined); - - const ruby = api.getRuby(); - assert.ok(ruby !== null, "getRuby should return a Ruby definition after activation"); - }); - - test("onDidRubyChange allows subscribing to events", async () => { - const api = await activate(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, and command subscriptions", async () => { assert.strictEqual(context.subscriptions.length, 0, "subscriptions should be empty initially"); diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 4f0f831..ef97e2b 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -8,6 +8,7 @@ export function createContext(): FakeContext { return { subscriptions, + extensionUri: vscode.Uri.file(__dirname + "/../.."), workspaceState: { get: (key: string): T | undefined => { return workspaceStateStorage.get(key) as T | undefined; diff --git a/src/test/rubyEnvironment.test.ts b/src/test/rubyEnvironment.test.ts index 6f9030f..009dc93 100644 --- a/src/test/rubyEnvironment.test.ts +++ b/src/test/rubyEnvironment.test.ts @@ -1,16 +1,66 @@ import * as assert from "assert"; import * as vscode from "vscode"; +import sinon from "sinon"; import { suite, test, beforeEach, afterEach } from "mocha"; -import { RubyEnvironmentManager } from "../rubyEnvironment"; +import * as common from "../common"; +import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, RubyEnvironmentManager, VALUE_SEPARATOR } from "../rubyEnvironment"; import { FakeContext, createContext } from "./helpers"; -import { RubyChangeEvent } from "../types"; +import { JitType, OptionalRubyDefinition, RubyChangeEvent } from "../types"; + +// Test helpers +function createTestWorkspace(): vscode.WorkspaceFolder { + return { + uri: vscode.Uri.file("/test/workspace"), + name: "test", + index: 0, + }; +} + +function createMockRubyResponse(): string { + return [ + "3.3.0", + "/path/to/gems,/another/path", + "true", + `PATH${VALUE_SEPARATOR}/usr/bin`, + `HOME${VALUE_SEPARATOR}/home/user`, + ].join(FIELD_SEPARATOR); +} + +function stubAsyncExec(sandbox: sinon.SinonSandbox, response?: string): sinon.SinonStub { + const envStub = response || createMockRubyResponse(); + return sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); +} + +function buildExpectedCommand(context: FakeContext, rubyPath: string): string { + const activationUri = vscode.Uri.joinPath(context.extensionUri, "activation.rb"); + return `${rubyPath} -W0 -EUTF-8:UTF-8 '${activationUri.fsPath}'`; +} + +function getExpectedShell(): string | undefined { + return common.isWindows() ? undefined : vscode.env.shell; +} + +function assertRubyDefinition(ruby: OptionalRubyDefinition): void { + assert.ok(ruby !== null, "Should return Ruby definition"); + assert.strictEqual(ruby.error, false); + assert.strictEqual(ruby.rubyVersion, "3.3.0"); + assert.deepStrictEqual(ruby.availableJITs, [JitType.YJIT]); + assert.deepStrictEqual(ruby.gemPath, ["/path/to/gems", "/another/path"]); + assert.strictEqual(ruby.env?.PATH, "/usr/bin"); + assert.strictEqual(ruby.env?.HOME, "/home/user"); +} suite("RubyEnvironmentManager", () => { let context: FakeContext; let manager: RubyEnvironmentManager; + let sandbox: sinon.SinonSandbox; beforeEach(() => { context = createContext(); + sandbox = sinon.createSandbox(); }); afterEach(async () => { @@ -18,6 +68,7 @@ suite("RubyEnvironmentManager", () => { // Clean up any configuration changes const config = vscode.workspace.getConfiguration("rubyEnvironments"); await config.update("rubyPath", undefined, vscode.ConfigurationTarget.Global); + sandbox.restore(); }); suite("constructor", () => { @@ -43,49 +94,67 @@ suite("RubyEnvironmentManager", () => { const ruby = manager.getRuby(); assert.strictEqual(ruby, null, "Should return null before activation"); }); + }); + + suite("activate", () => { + test("does not set Ruby definition if not configured", async () => { + manager = new RubyEnvironmentManager(context); + await manager.activate(undefined); - test("returns Ruby definition after activation", async () => { + const ruby = manager.getRuby(); + assert.strictEqual(ruby, null, "Should return null if not configured"); + }); + + test("initializes Ruby definition from configuration", async () => { const config = vscode.workspace.getConfiguration("rubyEnvironments"); await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + const workspaceFolder = createTestWorkspace(); + const execStub = stubAsyncExec(sandbox); + manager = new RubyEnvironmentManager(context); - await manager.activate(undefined); + await manager.activate(workspaceFolder); + + const expectedCommand = buildExpectedCommand(context, "/usr/bin/ruby"); + const shell = getExpectedShell(); + + assert.ok( + execStub.calledOnceWithExactly(expectedCommand, { + cwd: workspaceFolder.uri.fsPath, + shell, + env: process.env, + }), + `Expected asyncExec to be called with correct arguments`, + ); const ruby = manager.getRuby(); - assert.ok(ruby !== null, "Should return Ruby definition after activation"); - if (ruby !== null) { - assert.strictEqual(ruby.error, true, "Should return error for mock ruby path"); - } + assertRubyDefinition(ruby); }); - test("returns Ruby definition from workspace state after activation", async () => { + test("initializes Ruby definition from workspace state", async () => { await context.workspaceState.update("rubyPath", "/custom/ruby/path"); manager = new RubyEnvironmentManager(context); - await manager.activate(undefined); - const ruby = manager.getRuby(); - assert.ok(ruby !== null, "Should return Ruby definition"); - if (ruby !== null) { - assert.strictEqual(ruby.error, true, "Should return error state for configured path"); - } - }); - }); + const workspaceFolder = createTestWorkspace(); + const execStub = stubAsyncExec(sandbox); - suite("activate", () => { - test("initializes Ruby definition", async () => { - const config = vscode.workspace.getConfiguration("rubyEnvironments"); - await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + await manager.activate(workspaceFolder); - manager = new RubyEnvironmentManager(context); - assert.strictEqual(manager.getRuby(), null, "Should be null before activation"); + const expectedCommand = buildExpectedCommand(context, "/custom/ruby/path"); + const shell = getExpectedShell(); - await manager.activate(undefined); + assert.ok( + execStub.calledOnceWithExactly(expectedCommand, { + cwd: workspaceFolder.uri.fsPath, + shell, + env: process.env, + }), + `Expected asyncExec to be called with correct arguments`, + ); const ruby = manager.getRuby(); - assert.ok(ruby !== null, "Should have Ruby definition after activation"); - if (ruby !== null) { - assert.strictEqual(ruby.error, true, "Should return error for mock ruby path"); - } + assert.ok(ruby !== null, "Should return Ruby definition"); + assert.strictEqual(ruby.error, false); }); }); @@ -109,6 +178,9 @@ suite("RubyEnvironmentManager", () => { const config = vscode.workspace.getConfiguration("rubyEnvironments"); await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); + const workspaceFolder = createTestWorkspace(); + const execStub = stubAsyncExec(sandbox); + manager = new RubyEnvironmentManager(context); let eventFired = false; @@ -118,12 +190,25 @@ suite("RubyEnvironmentManager", () => { receivedEvent = event; }); - await manager.activate(undefined); + await manager.activate(workspaceFolder); + + const expectedCommand = buildExpectedCommand(context, "/usr/bin/ruby"); + const shell = getExpectedShell(); + + assert.ok( + execStub.calledOnceWithExactly(expectedCommand, { + cwd: workspaceFolder.uri.fsPath, + shell, + env: process.env, + }), + `Expected asyncExec to be called with correct arguments`, + ); assert.strictEqual(eventFired, true, "Event should have fired after activation"); assert.ok(receivedEvent, "Event data should be received"); - assert.strictEqual(receivedEvent.workspace, undefined, "Workspace should be undefined"); - assert.ok(receivedEvent.ruby !== null, "Ruby should be defined when configured"); + assert.strictEqual(receivedEvent.workspace, workspaceFolder, "Workspace should match"); + + assertRubyDefinition(receivedEvent.ruby); disposable.dispose(); }); @@ -132,68 +217,22 @@ suite("RubyEnvironmentManager", () => { manager = new RubyEnvironmentManager(context); await manager.activate(undefined); - let eventCount = 0; - const disposable = manager.onDidRubyChange(() => { - eventCount++; + const eventPromise = new Promise((resolve) => { + manager.onDidRubyChange(() => { + resolve(); + }); }); - // Trigger configuration change - const config = vscode.workspace.getConfiguration("rubyEnvironments"); - await config.update("rubyPath", "/test/ruby/path", vscode.ConfigurationTarget.Global); - - // Wait a bit for the event to fire - await new Promise((resolve) => setTimeout(resolve, 100)); - - assert.ok(eventCount > 0, "Event should have fired when configuration changed"); - - disposable.dispose(); - }); - - test("includes workspace in event from workspaceFolders", async () => { - manager = new RubyEnvironmentManager(context); - - let receivedEvent: RubyChangeEvent | undefined; - const disposable = manager.onDidRubyChange((event) => { - receivedEvent = event; + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Event did not fire within timeout")), 100); }); - const mockWorkspace = { - uri: vscode.Uri.file("/test/workspace"), - name: "test-workspace", - index: 0, - } as vscode.WorkspaceFolder; - - await manager.activate(mockWorkspace); - - assert.ok(receivedEvent, "Event should have been received"); - assert.strictEqual(receivedEvent.workspace, mockWorkspace, "Workspace should match"); - - disposable.dispose(); - }); - }); - - suite("configuration changes", () => { - test("updates Ruby definition when workspace state changes after activation", async () => { const config = vscode.workspace.getConfiguration("rubyEnvironments"); - await config.update("rubyPath", "/usr/bin/ruby", vscode.ConfigurationTarget.Global); - - manager = new RubyEnvironmentManager(context); - await manager.activate(undefined); - - const initialRuby = manager.getRuby(); - assert.ok(initialRuby !== null, "Initial Ruby should be defined after activation"); - - // Dispose the first manager before creating a new one - context.dispose(); - context = createContext(); + await config.update("rubyPath", "/test/ruby/path", vscode.ConfigurationTarget.Global); - // Simulate workspace state change by creating a new manager with updated state - await context.workspaceState.update("rubyPath", "/new/ruby/path"); - const newManager = new RubyEnvironmentManager(context); - await newManager.activate(undefined); + await Promise.race([eventPromise, timeoutPromise]); - const updatedRuby = newManager.getRuby(); - assert.ok(updatedRuby, "Updated Ruby should be defined after activation"); + assert.ok(true, "Event fired when configuration changed"); }); }); }); 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"