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" } diff --git a/README.md b/README.md index cfbee6f..bc945e5 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 -## API +### For End Users + +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,44 @@ 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) diff --git a/activation.rb b/activation.rb new file mode 100644 index 0000000..5191cc1 --- /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), !!defined?(RubyVM::ZJIT)) + +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..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": [ @@ -32,10 +28,22 @@ "configuration": { "title": "Ruby Environments", "properties": { - "rubyEnvironments.rubyVersion": { + "rubyEnvironments.versionManager": { + "type": "string", + "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": null, - "description": "The Ruby version to use (e.g., '3.3.0'). If not set, will attempt to detect automatically.", + "default": "ruby", + "description": "Path to the Ruby executable. Can be an absolute path or a command in PATH.", "scope": "window" } } @@ -61,20 +69,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 new file mode 100644 index 0000000..3960ef7 --- /dev/null +++ b/src/configuredRuby.ts @@ -0,0 +1,132 @@ +import * as vscode from "vscode"; +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 + */ +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; + private readonly context: vscode.ExtensionContext; + private readonly workspaceFolder: vscode.WorkspaceFolder | undefined; + private readonly logger: vscode.LogOutputChannel; + + constructor( + workspace: WorkspaceInterface, + context: vscode.ExtensionContext, + logger: vscode.LogOutputChannel, + workspaceFolder?: vscode.WorkspaceFolder, + ) { + this.workspace = workspace; + this.context = context; + this.logger = logger; + this.workspaceFolder = workspaceFolder; + } + + async activate(): Promise { + 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, + shell, + 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) { + 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, + }; + } + } + + private parseActivationResult(stderr: string): RubyDefinition { + 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, 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 === "true") availableJITs.push(JitType.YJIT); + if (zjit === "true") availableJITs.push(JitType.ZJIT); + + return { + error: false, + rubyVersion: version, + gemPath: gemPath.split(","), + 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 4abccb8..9663e69 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,80 +1,13 @@ import * as vscode from "vscode"; -import { RubyStatus } from "./status"; -import { RubyChangeEvent, RubyDefinition, RubyEnvironmentsApi } from "./types"; - -// 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, - }; -} +import { RubyEnvironmentsApi } from "./types"; +import { RubyEnvironment } from "./rubyEnvironment"; 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); - - // Load Ruby definition from configuration - let currentRubyDefinition = getRubyDefinitionFromConfig(); - status.refresh(currentRubyDefinition); - - // Watch for configuration changes - const configWatcher = vscode.workspace.onDidChangeConfiguration((e) => { - if (e.affectsConfiguration("rubyEnvironments")) { - currentRubyDefinition = getRubyDefinitionFromConfig(); - status.refresh(currentRubyDefinition); - } - }); - 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", - }); - - 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}`); - } - }); - - context.subscriptions.push(selectRubyVersion); + const outputChannel = vscode.window.createOutputChannel("Ruby Environments", { log: true }); + context.subscriptions.push(outputChannel); - return { - activate: async (_workspace: vscode.WorkspaceFolder | undefined) => {}, - getRuby: () => null, - onDidRubyChange: rubyChangeEmitter.event, - }; + const rubyEnvironment = new RubyEnvironment(context, outputChannel); + return rubyEnvironment; } export function deactivate() { diff --git a/src/rubyEnvironment.ts b/src/rubyEnvironment.ts new file mode 100644 index 0000000..0eaea07 --- /dev/null +++ b/src/rubyEnvironment.ts @@ -0,0 +1,175 @@ +import * as vscode from "vscode"; +import { RubyStatus } from "./status"; +import { RubyChangeEvent, RubyDefinition, RubyEnvironmentsApi } from "./types"; +import { ConfiguredRuby } from "./configuredRuby"; +import { VersionManager } from "./versionManager"; + +/** + * Main class that manages the Ruby environment state and lifecycle + */ +export class RubyEnvironment implements RubyEnvironmentsApi { + 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; + // 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..."); + 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 = this.createVersionManager(); + this.logger.info(`Using version manager: ${this.versionManager.name}`); + + // Create the status item + this.status = new RubyStatus(); + this.context.subscriptions.push(this.status); + + // Setup watchers and commands + this.setupConfigWatcher(); + this.registerCommands(); + + 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(`Available JITs: ${this.currentRubyDefinition.availableJITs.join(", ")}`); + } + this.logger.debug(`Gem paths: ${this.currentRubyDefinition.gemPath.join(", ")}`); + } + + this.status.refresh(this.currentRubyDefinition); + } + + getRuby(): RubyDefinition | null { + 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; + } + + private setupConfigWatcher(): void { + 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 + if (e.affectsConfiguration("rubyEnvironments.versionManager")) { + this.versionManager = this.createVersionManager(); + this.logger.info(`Switched to version manager: ${this.versionManager.name}`); + } + await this.activate(); + } + }); + 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/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; diff --git a/src/test/configuredRuby.test.ts b/src/test/configuredRuby.test.ts new file mode 100644 index 0000000..b13df9e --- /dev/null +++ b/src/test/configuredRuby.test.ts @@ -0,0 +1,165 @@ +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; +}; + +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, + }; +} + +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(() => { + sandbox = sinon.createSandbox(); + // Create a minimal mock context + mockContext = { + extensionUri: vscode.Uri.file(__dirname + "/../.."), + } as vscode.ExtensionContext; + mockLogger = createMockLogger(); + }); + + afterEach(() => { + sandbox.restore(); + }); + + test("has correct identifier and name", () => { + const mockWorkspace = createMockWorkspace({}); + versionManager = new ConfiguredRuby(mockWorkspace, mockContext, mockLogger); + + assert.strictEqual(versionManager.identifier, "configured"); + assert.strictEqual(versionManager.name, "Configured Ruby"); + }); + + 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, mockLogger, workspaceFolder); + + const envStub = [ + "3.3.0", + "/path/to/gems,/another/path", + "true", + "false", + `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; + + assert.ok( + execStub.calledOnceWithExactly(expectedCommand, { + cwd: workspaceFolder.uri.fsPath, + shell, + env: process.env, + }), + `Expected asyncExec to be called with correct arguments`, + ); + + 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("activate returns error when Ruby executable fails", async () => { + const mockWorkspace = createMockWorkspace({ + rubyExecutablePath: "/nonexistent/ruby", + }); + versionManager = new ConfiguredRuby(mockWorkspace, mockContext, mockLogger); + + sandbox.stub(common, "asyncExec").rejects(new Error("Command failed")); + + const result = await versionManager.activate(); + + assert.strictEqual(result.error, true); + }); + + test("activate uses custom Ruby executable path", async () => { + const mockWorkspace = createMockWorkspace({ + rubyExecutablePath: "/custom/path/to/ruby", + }); + versionManager = new ConfiguredRuby(mockWorkspace, mockContext, mockLogger); + + 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}`, + }); + + 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("activate returns error when activation output is malformed", async () => { + const mockWorkspace = createMockWorkspace({ + rubyExecutablePath: "ruby", + }); + versionManager = new ConfiguredRuby(mockWorkspace, mockContext, mockLogger); + + sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: "Invalid output without separators", + }); + + const result = await versionManager.activate(); + + assert.strictEqual(result.error, true); + }); +}); diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 5931dd0..667da56 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -1,5 +1,4 @@ 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,70 +16,22 @@ suite("Extension Test Suite", () => { context.dispose(); }); - test("returns an object implementing RubyEnvironmentsApi", async () => { + 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"); - 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 = 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"); }); - - test("getRuby returns null initially", () => { - const api = activate(context); - - assert.strictEqual(api.getRuby(), null, "getRuby should return null before activation"); - }); - - test("onDidRubyChange allows subscribing to events", () => { - const api = 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", () => { - assert.strictEqual(context.subscriptions.length, 0, "subscriptions should be empty initially"); - - activate(context); - - assert.strictEqual( - context.subscriptions.length, - 4, - "Extension should register four subscriptions (emitter, status, config watcher, and command)", - ); - }); - }); - - suite("selectRubyVersion command", () => { - test("command is registered", async () => { - const mockContext = createContext(); - activate(mockContext); - - const commands = await vscode.commands.getCommands(true); - assert.ok(commands.includes("ruby-environments.selectRubyVersion"), "Command should be registered"); - - mockContext.dispose(); - }); }); suite("deactivate", () => { diff --git a/src/test/rubyEnvironment.test.ts b/src/test/rubyEnvironment.test.ts new file mode 100644 index 0000000..ef84c4b --- /dev/null +++ b/src/test/rubyEnvironment.test.ts @@ -0,0 +1,71 @@ +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", () => { + 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"); + }); + + 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)", + ); + }); + + 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", () => { + 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"); + }); + }); +}); 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); + }); }); diff --git a/src/versionManager.ts b/src/versionManager.ts new file mode 100644 index 0000000..bc29d1d --- /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; + + /** + * Activate the Ruby environment and return the Ruby definition + */ + 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"