Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
3 changes: 2 additions & 1 deletion .prettierrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@
"printWidth": 120,
"semi": true,
"singleQuote": false,
"bracketSpacing": true
"bracketSpacing": true,
"endOfLine": "lf"
}
100 changes: 93 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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.

Expand Down Expand Up @@ -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)
9 changes: 9 additions & 0 deletions activation.rb
Original file line number Diff line number Diff line change
@@ -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")
38 changes: 24 additions & 14 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,7 @@
"categories": [
"Programming Languages"
],
"activationEvents": [
"onLanguage:ruby",
"workspaceContains:Gemfile",
"workspaceContains:gems.rb"
],
"activationEvents": [],
"main": "./dist/extension.js",
"contributes": {
"commands": [
Expand All @@ -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"
}
}
Expand All @@ -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"
}
}
18 changes: 18 additions & 0 deletions src/common.ts
Original file line number Diff line number Diff line change
@@ -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<ExecResult> {
return execAsync(command, options);
}

export function isWindows(): boolean {
return os.platform() === "win32";
}
132 changes: 132 additions & 0 deletions src/configuredRuby.ts
Original file line number Diff line number Diff line change
@@ -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<T>(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<RubyDefinition> {
const config = this.workspace.getConfiguration("rubyEnvironments");
const rubyExecutable = config.get<string>("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,
};
}
}
Loading
Loading