Skip to content

Commit 3948da2

Browse files
authored
Merge pull request #9 from Shopify/rmf-initial-implementation
Initial API proposal for Ruby Environments extension
2 parents 7195e44 + 4e4d276 commit 3948da2

File tree

11 files changed

+303
-12
lines changed

11 files changed

+303
-12
lines changed

.github/workflows/publish.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ jobs:
1616
with:
1717
node-version: "22.17.0"
1818
cache: "yarn"
19+
registry-url: "https://registry.npmjs.org"
1920

2021
- name: 📦 Install dependencies
2122
run: yarn --frozen-lockfile
@@ -33,6 +34,14 @@ jobs:
3334
env:
3435
VSCE_PAT: ${{ secrets.VSCE_PAT }}
3536

37+
- name: Publish types to npm
38+
if: "!github.event.release.prerelease"
39+
run: |
40+
cd npm
41+
npm publish --access public
42+
env:
43+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
44+
3645
# Prereleases
3746
- name: Package and publish prerelease extension in the marketplace
3847
if: "github.event.release.prerelease"
@@ -41,3 +50,11 @@ jobs:
4150
node_modules/.bin/vsce publish --pre-release --packagePath ruby-environments.vsix
4251
env:
4352
VSCE_PAT: ${{ secrets.VSCE_PAT }}
53+
54+
- name: Publish prerelease types to npm
55+
if: "github.event.release.prerelease"
56+
run: |
57+
cd npm
58+
npm publish --access public --tag next
59+
env:
60+
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ dist
33
node_modules
44
.vscode-test/
55
*.vsix
6+
npm/*.d.ts

.vscodeignore

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,19 @@
33
out/**
44
node_modules/**
55
src/**
6+
npm/**
7+
.github/**
8+
.dev/**
9+
.shadowenv.d/**
610
.gitignore
711
.yarnrc
12+
.prettierignore
13+
.prettierrc.json
814
esbuild.js
15+
dev.yml
16+
CLAUDE.md
917
vsc-extension-quickstart.md
18+
yarn.lock
1019
**/tsconfig.json
1120
**/eslint.config.mjs
1221
**/*.map

README.md

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,104 @@ need to activate the Ruby environment in order to launch Ruby executables, such
77

88
## Features
99

10-
TODO: this extension is getting extracted from the Ruby LSP.
10+
- **Automatic Ruby detection**: Discovers the Ruby interpreter installed on your machine
11+
- **Version manager integrations**: Supports popular version managers including:
12+
- [chruby](https://github.com/postmodern/chruby)
13+
- [rbenv](https://github.com/rbenv/rbenv)
14+
- [rvm](https://rvm.io/)
15+
- [asdf](https://asdf-vm.com/) (with ruby plugin)
16+
- [mise](https://mise.jdx.dev/)
17+
- **Environment activation**: Composes the correct environment variables (`PATH`, `GEM_HOME`, `GEM_PATH`, etc.) to match your shell configuration
18+
- **JIT detection**: Identifies available JIT compilers (YJIT, ZJIT) for the activated Ruby version
19+
- **Shell support**: Works with various shells including bash, zsh, fish, and PowerShell
20+
- **Extension API**: Provides a programmatic API for other extensions to access the activated Ruby environment
1121

1222
## Extension Settings
1323

1424
TODO
1525

1626
## API
1727

18-
TODO
28+
This extension exposes an API that other extensions can use to access the activated Ruby environment.
29+
30+
### TypeScript Types
31+
32+
Type definitions are available as a separate npm package:
33+
34+
```bash
35+
npm install --save-dev @shopify/ruby-environments-types
36+
```
37+
38+
### Getting the API
39+
40+
```typescript
41+
import type { RubyEnvironmentsApi } from "@shopify/ruby-environments-types";
42+
43+
const rubyEnvExtension = vscode.extensions.getExtension<RubyEnvironmentsApi>("Shopify.ruby-environments");
44+
45+
if (rubyEnvExtension) {
46+
if (!rubyEnvExtension.isActive) {
47+
await rubyEnvExtension.activate();
48+
}
49+
50+
const api: RubyEnvironmentsApi = rubyEnvExtension.exports;
51+
// Use the API...
52+
}
53+
```
54+
55+
### Activating the Ruby Environment
56+
57+
Request the extension to activate Ruby for a specific workspace:
58+
59+
```typescript
60+
await api.activate(vscode.workspace.workspaceFolders?.[0]);
61+
```
62+
63+
### Getting the Current Ruby Definition
64+
65+
Retrieve the currently activated Ruby environment:
66+
67+
```typescript
68+
import type { RubyDefinition } from "@shopify/ruby-environments-types";
69+
70+
const ruby: RubyDefinition | null = api.getRuby();
71+
72+
if (ruby === null) {
73+
console.log("Ruby environment not yet activated");
74+
} else if (ruby.error) {
75+
console.log("Ruby activation failed");
76+
} else {
77+
console.log(`Ruby version: ${ruby.rubyVersion}`);
78+
console.log(`Available JITs: ${ruby.availableJITs.join(", ")}`);
79+
console.log(`GEM_PATH: ${ruby.gemPath.join(":")}`);
80+
}
81+
```
82+
83+
### Subscribing to Ruby Environment Changes
84+
85+
Listen for changes to the Ruby environment (e.g., when the user switches Ruby versions):
86+
87+
```typescript
88+
import type { RubyChangeEvent } from "@shopify/ruby-environments-types";
89+
90+
const disposable = api.onDidRubyChange((event: RubyChangeEvent) => {
91+
console.log(`Ruby changed in workspace: ${event.workspace?.name}`);
92+
93+
if (!event.ruby.error) {
94+
console.log(`New Ruby version: ${event.ruby.rubyVersion}`);
95+
}
96+
});
97+
98+
// Add to your extension's subscriptions for automatic cleanup
99+
context.subscriptions.push(disposable);
100+
```
101+
102+
### Extension Dependency
103+
104+
To ensure your extension loads after Ruby Environments, add it as a dependency in your `package.json`:
105+
106+
```json
107+
{
108+
"extensionDependencies": ["Shopify.ruby-environments"]
109+
}
110+
```

dev.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
name: ruby-environments
22

3+
nix: true
4+
35
up:
46
- node:
57
yarn: true

npm/package.json

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"name": "@shopify/ruby-environments-types",
3+
"description": "Type definitions for the Ruby Environments VS Code extension",
4+
"version": "0.0.1",
5+
"repository": {
6+
"type": "git",
7+
"url": "https://github.com/Shopify/ruby-environments.git"
8+
},
9+
"license": "MIT",
10+
"types": "./extension.d.ts",
11+
"files": [
12+
"extension.d.ts"
13+
],
14+
"scripts": {
15+
"build": "tsc ../src/extension.ts --emitDeclarationOnly --declaration --declarationDir . --moduleResolution node --skipLibCheck",
16+
"prepublishOnly": "npm run build"
17+
},
18+
"keywords": [
19+
"ruby",
20+
"vscode",
21+
"types",
22+
"typescript"
23+
]
24+
}

package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,15 @@
2020
"contributes": {},
2121
"scripts": {
2222
"vscode:prepublish": "yarn run package",
23-
"compile": "yarn run check-types && yarn run lint && node esbuild.js",
23+
"compile": "yarn run check-types && yarn run lint && node esbuild.js && yarn run build:types",
2424
"watch": "npm-run-all -p watch:*",
2525
"watch:esbuild": "node esbuild.js --watch",
2626
"watch:tsc": "tsc --noEmit --watch --project tsconfig.json",
27-
"package": "yarn run check-types && yarn run lint && node esbuild.js --production",
27+
"build:types": "tsc --emitDeclarationOnly --declaration --declarationDir dist",
28+
"package": "yarn run check-types && yarn run lint && node esbuild.js --production && yarn run build:types",
2829
"package_release": "vsce package --out ruby-environments.vsix",
2930
"package_prerelease": "vsce package --pre-release --out ruby-environments.vsix",
31+
"npm:publish": "cd npm && npm publish --access public",
3032
"compile-tests": "tsc -p . --outDir out",
3133
"watch-tests": "tsc -p . -w --outDir out",
3234
"pretest": "yarn run compile-tests && yarn run compile && yarn run lint",

src/extension.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,68 @@
11
import * as vscode from "vscode";
22

3-
// The public API that gets exposed to other extensions that depend on Ruby environments
4-
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
5-
interface RubyEnvironmentsApi {}
3+
/**
4+
* Represents a Ruby environment that failed to activate
5+
*/
6+
export interface RubyError {
7+
error: true;
8+
}
9+
10+
/**
11+
* JIT compiler types supported by Ruby
12+
*/
13+
export enum JitType {
14+
YJIT = "YJIT",
15+
ZJIT = "ZJIT",
16+
}
17+
18+
/**
19+
* Represents a successfully activated Ruby environment
20+
*/
21+
export interface RubyEnvironment {
22+
error: false;
23+
rubyVersion: string;
24+
availableJITs: JitType[];
25+
env: NodeJS.ProcessEnv;
26+
gemPath: string[];
27+
}
28+
29+
/**
30+
* Represents a Ruby environment definition - either an error or a successful environment
31+
*/
32+
export type RubyDefinition = RubyError | RubyEnvironment;
33+
34+
/**
35+
* Event data emitted when the Ruby environment changes
36+
*/
37+
export interface RubyChangeEvent {
38+
workspace: vscode.WorkspaceFolder | undefined;
39+
ruby: RubyDefinition;
40+
}
41+
42+
/**
43+
* The public API that gets exposed to other extensions that depend on Ruby environments
44+
*/
45+
export interface RubyEnvironmentsApi {
46+
/** Activate the extension for a specific workspace */
47+
activate: (workspace: vscode.WorkspaceFolder | undefined) => Promise<void>;
48+
/** Get the current Ruby definition */
49+
getRuby: () => RubyDefinition | null;
50+
/** Event that fires when the Ruby environment changes */
51+
onDidRubyChange: vscode.Event<RubyChangeEvent>;
52+
}
53+
54+
// Event emitter for Ruby environment changes
55+
const rubyChangeEmitter = new vscode.EventEmitter<RubyChangeEvent>();
56+
57+
export function activate(context: vscode.ExtensionContext): RubyEnvironmentsApi {
58+
// Ensure the event emitter is disposed when the extension is deactivated
59+
context.subscriptions.push(rubyChangeEmitter);
660

7-
export function activate(_context: vscode.ExtensionContext): RubyEnvironmentsApi {
8-
return {};
61+
return {
62+
activate: async (_workspace: vscode.WorkspaceFolder | undefined) => {},
63+
getRuby: () => null,
64+
onDidRubyChange: rubyChangeEmitter.event,
65+
};
966
}
1067

1168
export function deactivate() {}

src/test/extension.test.ts

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,75 @@
11
import * as assert from "assert";
2+
import { suite, test, beforeEach, afterEach } from "mocha";
3+
import { activate, deactivate, RubyEnvironmentsApi } from "../extension";
4+
import { FakeContext, createContext } from "./helpers";
25

36
suite("Extension Test Suite", () => {
4-
test("Sample test", () => {
5-
assert.strictEqual(1 + 1, 2);
7+
suite("activate", () => {
8+
let context: FakeContext;
9+
10+
beforeEach(() => {
11+
context = createContext();
12+
});
13+
14+
afterEach(() => {
15+
context.dispose();
16+
});
17+
18+
test("returns an object implementing RubyEnvironmentsApi", async () => {
19+
const api = activate(context);
20+
21+
assert.strictEqual(typeof api, "object", "activate should return an object");
22+
assert.strictEqual(typeof api.activate, "function", "API should have an activate method");
23+
assert.strictEqual(typeof api.getRuby, "function", "API should have a getRuby method");
24+
assert.strictEqual(typeof api.onDidRubyChange, "function", "API should have an onDidRubyChange event");
25+
26+
const result = api.activate(undefined);
27+
assert.ok(result instanceof Promise, "activate should return a Promise");
28+
await result;
29+
});
30+
31+
test("returned API conforms to RubyEnvironmentsApi interface", () => {
32+
const api = activate(context);
33+
34+
const typedApi: RubyEnvironmentsApi = api;
35+
assert.ok(typedApi, "API should conform to RubyEnvironmentsApi interface");
36+
});
37+
38+
test("getRuby returns null initially", () => {
39+
const api = activate(context);
40+
41+
assert.strictEqual(api.getRuby(), null, "getRuby should return null before activation");
42+
});
43+
44+
test("onDidRubyChange allows subscribing to events", () => {
45+
const api = activate(context);
46+
47+
let eventFired = false;
48+
const disposable = api.onDidRubyChange(() => {
49+
eventFired = true;
50+
});
51+
52+
assert.ok(disposable, "onDidRubyChange should return a disposable");
53+
assert.strictEqual(typeof disposable.dispose, "function", "disposable should have a dispose method");
54+
55+
disposable.dispose();
56+
assert.strictEqual(eventFired, false, "event should not have fired yet");
57+
});
58+
59+
test("adds event emitter to context subscriptions for disposal", () => {
60+
assert.strictEqual(context.subscriptions.length, 0, "subscriptions should be empty initially");
61+
62+
activate(context);
63+
64+
assert.strictEqual(context.subscriptions.length, 1, "should add emitter to subscriptions");
65+
});
66+
});
67+
68+
suite("deactivate", () => {
69+
test("can be called without errors", () => {
70+
assert.doesNotThrow(() => {
71+
deactivate();
72+
}, "deactivate should not throw errors");
73+
});
674
});
775
});

src/test/helpers.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import * as vscode from "vscode";
2+
3+
export type FakeContext = vscode.ExtensionContext & { dispose: () => void };
4+
5+
export function createContext(): FakeContext {
6+
const subscriptions: vscode.Disposable[] = [];
7+
8+
return {
9+
subscriptions,
10+
dispose: () => {
11+
subscriptions.forEach((subscription) => {
12+
subscription.dispose();
13+
});
14+
},
15+
} as unknown as FakeContext;
16+
}

0 commit comments

Comments
 (0)