Skip to content

Commit 1c306bd

Browse files
committed
Introduce VSCode native Ruby REPL
This expands the VSCode extension to provide a first party Ruby REPL. Features: - Custom terminal profiles to allow you to open an IRB or Rails console directly from the terminal interface - Ability to launch a REPL from the command palette, search `Ruby LSP: Start Ruby REPL` - Send code to execute in the REPL via the command palette with `Ruby LSP: Execute in Ruby REPL` - Send code to execute in the REPL via context menu, select code, right click and select `Execute in Ruby REPL` - Open a scratchpad by default when opening a REPL. This allows you to write code, utilize built in VSCode code completion / ruby-lsp and more. Send code to the REPL via cmd + enter for a single line or cmd + shift + enter for multi-lines.
1 parent 7d4b8a7 commit 1c306bd

25 files changed

+4201
-1
lines changed

project-words

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,7 @@ yarp
124124
YARP
125125
yjit
126126
YJIT
127+
Repls
128+
COLORTERM
129+
truecolor
130+
IRBRC

vscode/package.json

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@
5252
"when": "resourceLangId == ruby",
5353
"command": "workbench.action.terminal.runSelectedText",
5454
"group": "9_cutcopypaste"
55+
},
56+
{
57+
"when": "resourceLangId == ruby && editorHasSelection",
58+
"command": "rubyLsp.execInREPL",
59+
"group": "9_cutcopypaste"
5560
}
5661
],
5762
"editor/title": [
@@ -191,6 +196,44 @@
191196
"command": "rubyLsp.showOutput",
192197
"title": "Show output channel",
193198
"category": "Ruby LSP"
199+
},
200+
{
201+
"command": "rubyLsp.startREPL",
202+
"title": "Start Ruby REPL",
203+
"category": "Ruby LSP"
204+
},
205+
{
206+
"command": "rubyLsp.execInREPL",
207+
"title": "Execute in Ruby REPL",
208+
"category": "Ruby LSP",
209+
"when": "editorFocus && editorLangId == ruby"
210+
},
211+
{
212+
"command": "rubyLsp.interruptREPL",
213+
"title": "Interrupt Ruby REPL",
214+
"category": "Ruby LSP"
215+
},
216+
{
217+
"command": "rubyLsp.executeScratchPadLine",
218+
"title": "Execute Current Line in Scratch Pad",
219+
"category": "Ruby LSP",
220+
"when": "editorTextFocus && editorLangId == ruby && resourceScheme == untitled"
221+
},
222+
{
223+
"command": "rubyLsp.executeScratchPadSelection",
224+
"title": "Execute Selection in Scratch Pad",
225+
"category": "Ruby LSP",
226+
"when": "editorTextFocus && editorLangId == ruby && resourceScheme == untitled"
227+
},
228+
{
229+
"command": "rubyLsp.createIrbTerminal",
230+
"title": "Start Ruby REPL (IRB)",
231+
"category": "Ruby LSP"
232+
},
233+
{
234+
"command": "rubyLsp.createRailsConsoleTerminal",
235+
"title": "Start Rails Console",
236+
"category": "Ruby LSP"
194237
}
195238
],
196239
"configuration": {
@@ -540,6 +583,34 @@
540583
"description": "Controls the level of opacity for inline RBS comment signatures",
541584
"type": "string",
542585
"default": "1"
586+
},
587+
"rubyLsp.replSettings": {
588+
"description": "Settings for the Ruby REPL integration",
589+
"type": "object",
590+
"properties": {
591+
"showWelcomeMessage": {
592+
"description": "Show welcome message with tips when starting REPL",
593+
"type": "boolean",
594+
"default": true
595+
},
596+
"executionFeedbackDuration": {
597+
"description": "How long to show execution feedback decorations (in milliseconds)",
598+
"type": "number",
599+
"default": 3000,
600+
"minimum": 1000,
601+
"maximum": 10000
602+
},
603+
"autoOpenScratchPad": {
604+
"description": "Automatically open the scratch pad when starting REPL",
605+
"type": "boolean",
606+
"default": true
607+
}
608+
},
609+
"default": {
610+
"showWelcomeMessage": true,
611+
"executionFeedbackDuration": 3000,
612+
"autoOpenScratchPad": true
613+
}
543614
}
544615
}
545616
},
@@ -555,6 +626,18 @@
555626
}
556627
]
557628
},
629+
"terminal": {
630+
"profiles": [
631+
{
632+
"id": "rubyLsp.irbTerminal",
633+
"title": "Ruby REPL (IRB)"
634+
},
635+
{
636+
"id": "rubyLsp.railsConsoleTerminal",
637+
"title": "Rails Console"
638+
}
639+
]
640+
},
558641
"breakpoints": [
559642
{
560643
"language": "ruby"
@@ -768,7 +851,21 @@
768851
"editor.formatOnType": true,
769852
"editor.wordSeparators": "`~@#$%^&*()-=+[{]}\\|;:'\",.<>/"
770853
}
771-
}
854+
},
855+
"keybindings": [
856+
{
857+
"command": "rubyLsp.executeScratchPadLine",
858+
"key": "ctrl+enter",
859+
"mac": "cmd+enter",
860+
"when": "editorTextFocus && editorLangId == ruby && resourceScheme == untitled"
861+
},
862+
{
863+
"command": "rubyLsp.executeScratchPadSelection",
864+
"key": "ctrl+shift+enter",
865+
"mac": "cmd+shift+enter",
866+
"when": "editorTextFocus && editorLangId == ruby && resourceScheme == untitled"
867+
}
868+
]
772869
},
773870
"scripts": {
774871
"vscode:prepublish": "yarn run esbuild-base --minify",
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import * as vscode from "vscode";
2+
3+
import { Workspace } from "../workspace";
4+
import { ReplScratchPad } from "../replScratchPad";
5+
import { TerminalRepl } from "../terminalRepl";
6+
7+
import { BaseCommandHandler } from "./commandHandler";
8+
9+
/**
10+
* Base class for scratch pad execution commands
11+
*/
12+
export abstract class BaseScratchPadCommandHandler extends BaseCommandHandler {
13+
constructor(
14+
private currentActiveWorkspace: (
15+
activeEditor?: vscode.TextEditor,
16+
) => Workspace | undefined,
17+
private getScratchPad: (workspaceKey: string) => ReplScratchPad | undefined,
18+
private getTerminalRepl: (workspaceKey: string) => TerminalRepl | undefined,
19+
) {
20+
super();
21+
}
22+
23+
async execute(): Promise<void> {
24+
const editor = this.getActiveEditor();
25+
if (!editor) {
26+
return;
27+
}
28+
29+
if (!this.isScratchPadDocument(editor.document)) {
30+
return;
31+
}
32+
33+
const workspace = this.currentActiveWorkspace(editor);
34+
if (!workspace) {
35+
return;
36+
}
37+
38+
const workspaceKey = workspace.workspaceFolder.uri.toString();
39+
const scratchPad = this.getScratchPad(workspaceKey);
40+
41+
if (!scratchPad) {
42+
vscode.window.showWarningMessage(
43+
"No scratch pad found for this workspace",
44+
);
45+
return;
46+
}
47+
48+
const terminalRepl = this.getTerminalRepl(workspaceKey);
49+
if (!terminalRepl) {
50+
vscode.window.showWarningMessage("No REPL found for this workspace");
51+
return;
52+
}
53+
54+
await this.executeScratchPadAction(scratchPad, terminalRepl, editor);
55+
}
56+
57+
protected abstract executeScratchPadAction(
58+
scratchPad: ReplScratchPad,
59+
terminalRepl: TerminalRepl,
60+
editor: vscode.TextEditor,
61+
): Promise<void>;
62+
63+
protected isExecutableCode(code: string): boolean {
64+
return Boolean(code && !code.startsWith("#"));
65+
}
66+
67+
private isScratchPadDocument(document: vscode.TextDocument): boolean {
68+
return document.isUntitled && document.languageId === "ruby";
69+
}
70+
}

vscode/src/commands/commandHandler.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as vscode from "vscode";
2+
3+
/**
4+
* Interface for command handlers that can be registered with VS Code
5+
*/
6+
export interface CommandHandler {
7+
/**
8+
* The command identifier that this handler responds to
9+
*/
10+
readonly commandId: string;
11+
12+
/**
13+
* Execute the command
14+
*/
15+
execute(...args: any[]): Promise<void> | void;
16+
17+
/**
18+
* Register this command handler with VS Code
19+
*/
20+
register(): vscode.Disposable;
21+
}
22+
23+
/**
24+
* Abstract base class for command handlers with common functionality
25+
*/
26+
export abstract class BaseCommandHandler implements CommandHandler {
27+
abstract readonly commandId: string;
28+
29+
abstract execute(...args: any[]): Promise<void> | void;
30+
31+
/**
32+
* Register this command handler with VS Code
33+
*/
34+
register(): vscode.Disposable {
35+
return vscode.commands.registerCommand(this.commandId, (...args) =>
36+
this.execute(...args),
37+
);
38+
}
39+
40+
/**
41+
* Helper method to show error messages consistently
42+
*/
43+
protected showError(message: string, error?: Error): void {
44+
const fullMessage = error ? `${message}: ${error.message}` : message;
45+
vscode.window.showErrorMessage(fullMessage);
46+
}
47+
48+
/**
49+
* Helper method to get the active editor with validation
50+
*/
51+
protected getActiveEditor(): vscode.TextEditor | undefined {
52+
const editor = vscode.window.activeTextEditor;
53+
if (!editor) {
54+
vscode.window.showWarningMessage("No active editor found");
55+
return undefined;
56+
}
57+
return editor;
58+
}
59+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import * as vscode from "vscode";
2+
3+
import { Command } from "../common";
4+
import { Workspace } from "../workspace";
5+
import { TerminalRepl } from "../terminalRepl";
6+
7+
import { BaseCommandHandler } from "./commandHandler";
8+
9+
/**
10+
* Command handler for executing code in an existing REPL
11+
*/
12+
export class ExecInReplCommandHandler extends BaseCommandHandler {
13+
readonly commandId = Command.ExecInRepl;
14+
15+
constructor(
16+
private currentActiveWorkspace: (
17+
activeEditor?: vscode.TextEditor,
18+
) => Workspace | undefined,
19+
private getTerminalRepl: (workspaceKey: string) => TerminalRepl | undefined,
20+
) {
21+
super();
22+
}
23+
24+
async execute(): Promise<void> {
25+
const editor = this.getActiveEditor();
26+
if (!editor) {
27+
return;
28+
}
29+
30+
const workspace = this.currentActiveWorkspace(editor);
31+
if (!workspace) {
32+
vscode.window.showWarningMessage("No workspace found for current file");
33+
return;
34+
}
35+
36+
const workspaceKey = workspace.workspaceFolder.uri.toString();
37+
const terminalRepl = this.getTerminalRepl(workspaceKey);
38+
39+
if (!terminalRepl || !terminalRepl.isRunning) {
40+
await this.promptToStartRepl();
41+
return;
42+
}
43+
44+
const code = this.getCodeToExecute(editor);
45+
if (!code.trim()) {
46+
vscode.window.showWarningMessage("No code selected to execute");
47+
return;
48+
}
49+
50+
try {
51+
await terminalRepl.execute(code);
52+
} catch (error) {
53+
this.showError("Failed to execute in REPL", error as Error);
54+
}
55+
}
56+
57+
private getCodeToExecute(editor: vscode.TextEditor): string {
58+
const selection = editor.selection;
59+
60+
if (selection.isEmpty) {
61+
return this.getCurrentLineText(editor);
62+
} else {
63+
return this.getSelectedText(editor);
64+
}
65+
}
66+
67+
private getCurrentLineText(editor: vscode.TextEditor): string {
68+
const line = editor.document.lineAt(editor.selection.active.line);
69+
return line.text;
70+
}
71+
72+
private getSelectedText(editor: vscode.TextEditor): string {
73+
return editor.document.getText(editor.selection);
74+
}
75+
76+
private async promptToStartRepl(): Promise<void> {
77+
const answer = await vscode.window.showInformationMessage(
78+
"No REPL is running for this workspace. Would you like to start one?",
79+
"Start REPL",
80+
);
81+
82+
if (answer === "Start REPL") {
83+
await vscode.commands.executeCommand(Command.StartRepl);
84+
}
85+
}
86+
}

0 commit comments

Comments
 (0)