From 946d7d274753f1f9b261b775f4225ae2b462bbe0 Mon Sep 17 00:00:00 2001 From: Damian Galarza Date: Thu, 29 May 2025 10:38:33 -0400 Subject: [PATCH] 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. --- project-words | 4 + vscode/package.json | 99 ++- .../commands/baseScratchPadCommandHandler.ts | 70 ++ vscode/src/commands/commandHandler.ts | 59 ++ .../src/commands/execInReplCommandHandler.ts | 86 +++ .../executeScratchPadLineCommandHandler.ts | 40 ++ ...xecuteScratchPadSelectionCommandHandler.ts | 37 + vscode/src/commands/index.ts | 7 + .../commands/interruptReplCommandHandler.ts | 41 ++ .../src/commands/startReplCommandHandler.ts | 50 ++ vscode/src/common.ts | 7 + vscode/src/replManager.ts | 236 +++++++ vscode/src/replScratchPad.ts | 256 +++++++ vscode/src/rubyLsp.ts | 27 + vscode/src/terminalProfileProvider.ts | 495 +++++++++++++ vscode/src/terminalRepl.ts | 251 +++++++ .../commands/execInReplCommandHandler.test.ts | 181 +++++ ...xecuteScratchPadLineCommandHandler.test.ts | 287 ++++++++ ...eScratchPadSelectionCommandHandler.test.ts | 284 ++++++++ .../interruptReplCommandHandler.test.ts | 147 ++++ .../commands/startReplCommandHandler.test.ts | 130 ++++ vscode/src/test/suite/replManager.test.ts | 179 +++++ vscode/src/test/suite/replScratchPad.test.ts | 263 +++++++ .../suite/terminalProfileProvider.test.ts | 650 ++++++++++++++++++ vscode/src/test/suite/terminalRepl.test.ts | 316 +++++++++ 25 files changed, 4201 insertions(+), 1 deletion(-) create mode 100644 vscode/src/commands/baseScratchPadCommandHandler.ts create mode 100644 vscode/src/commands/commandHandler.ts create mode 100644 vscode/src/commands/execInReplCommandHandler.ts create mode 100644 vscode/src/commands/executeScratchPadLineCommandHandler.ts create mode 100644 vscode/src/commands/executeScratchPadSelectionCommandHandler.ts create mode 100644 vscode/src/commands/index.ts create mode 100644 vscode/src/commands/interruptReplCommandHandler.ts create mode 100644 vscode/src/commands/startReplCommandHandler.ts create mode 100644 vscode/src/replManager.ts create mode 100644 vscode/src/replScratchPad.ts create mode 100644 vscode/src/terminalProfileProvider.ts create mode 100644 vscode/src/terminalRepl.ts create mode 100644 vscode/src/test/suite/commands/execInReplCommandHandler.test.ts create mode 100644 vscode/src/test/suite/commands/executeScratchPadLineCommandHandler.test.ts create mode 100644 vscode/src/test/suite/commands/executeScratchPadSelectionCommandHandler.test.ts create mode 100644 vscode/src/test/suite/commands/interruptReplCommandHandler.test.ts create mode 100644 vscode/src/test/suite/commands/startReplCommandHandler.test.ts create mode 100644 vscode/src/test/suite/replManager.test.ts create mode 100644 vscode/src/test/suite/replScratchPad.test.ts create mode 100644 vscode/src/test/suite/terminalProfileProvider.test.ts create mode 100644 vscode/src/test/suite/terminalRepl.test.ts diff --git a/project-words b/project-words index 7cda229dd..8f2314ffe 100644 --- a/project-words +++ b/project-words @@ -124,3 +124,7 @@ yarp YARP yjit YJIT +Repls +COLORTERM +truecolor +IRBRC diff --git a/vscode/package.json b/vscode/package.json index e783c9065..66f7086ce 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -52,6 +52,11 @@ "when": "resourceLangId == ruby", "command": "workbench.action.terminal.runSelectedText", "group": "9_cutcopypaste" + }, + { + "when": "resourceLangId == ruby && editorHasSelection", + "command": "rubyLsp.execInREPL", + "group": "9_cutcopypaste" } ], "editor/title": [ @@ -191,6 +196,44 @@ "command": "rubyLsp.showOutput", "title": "Show output channel", "category": "Ruby LSP" + }, + { + "command": "rubyLsp.startREPL", + "title": "Start Ruby REPL", + "category": "Ruby LSP" + }, + { + "command": "rubyLsp.execInREPL", + "title": "Execute in Ruby REPL", + "category": "Ruby LSP", + "when": "editorFocus && editorLangId == ruby" + }, + { + "command": "rubyLsp.interruptREPL", + "title": "Interrupt Ruby REPL", + "category": "Ruby LSP" + }, + { + "command": "rubyLsp.executeScratchPadLine", + "title": "Execute Current Line in Scratch Pad", + "category": "Ruby LSP", + "when": "editorTextFocus && editorLangId == ruby && resourceScheme == untitled" + }, + { + "command": "rubyLsp.executeScratchPadSelection", + "title": "Execute Selection in Scratch Pad", + "category": "Ruby LSP", + "when": "editorTextFocus && editorLangId == ruby && resourceScheme == untitled" + }, + { + "command": "rubyLsp.createIrbTerminal", + "title": "Start Ruby REPL (IRB)", + "category": "Ruby LSP" + }, + { + "command": "rubyLsp.createRailsConsoleTerminal", + "title": "Start Rails Console", + "category": "Ruby LSP" } ], "configuration": { @@ -540,6 +583,34 @@ "description": "Controls the level of opacity for inline RBS comment signatures", "type": "string", "default": "1" + }, + "rubyLsp.replSettings": { + "description": "Settings for the Ruby REPL integration", + "type": "object", + "properties": { + "showWelcomeMessage": { + "description": "Show welcome message with tips when starting REPL", + "type": "boolean", + "default": true + }, + "executionFeedbackDuration": { + "description": "How long to show execution feedback decorations (in milliseconds)", + "type": "number", + "default": 3000, + "minimum": 1000, + "maximum": 10000 + }, + "autoOpenScratchPad": { + "description": "Automatically open the scratch pad when starting REPL", + "type": "boolean", + "default": true + } + }, + "default": { + "showWelcomeMessage": true, + "executionFeedbackDuration": 3000, + "autoOpenScratchPad": true + } } } }, @@ -555,6 +626,18 @@ } ] }, + "terminal": { + "profiles": [ + { + "id": "rubyLsp.irbTerminal", + "title": "Ruby REPL (IRB)" + }, + { + "id": "rubyLsp.railsConsoleTerminal", + "title": "Rails Console" + } + ] + }, "breakpoints": [ { "language": "ruby" @@ -768,7 +851,21 @@ "editor.formatOnType": true, "editor.wordSeparators": "`~@#$%^&*()-=+[{]}\\|;:'\",.<>/" } - } + }, + "keybindings": [ + { + "command": "rubyLsp.executeScratchPadLine", + "key": "ctrl+enter", + "mac": "cmd+enter", + "when": "editorTextFocus && editorLangId == ruby && resourceScheme == untitled" + }, + { + "command": "rubyLsp.executeScratchPadSelection", + "key": "ctrl+shift+enter", + "mac": "cmd+shift+enter", + "when": "editorTextFocus && editorLangId == ruby && resourceScheme == untitled" + } + ] }, "scripts": { "vscode:prepublish": "yarn run esbuild-base --minify", diff --git a/vscode/src/commands/baseScratchPadCommandHandler.ts b/vscode/src/commands/baseScratchPadCommandHandler.ts new file mode 100644 index 000000000..3568886a8 --- /dev/null +++ b/vscode/src/commands/baseScratchPadCommandHandler.ts @@ -0,0 +1,70 @@ +import * as vscode from "vscode"; + +import { Workspace } from "../workspace"; +import { ReplScratchPad } from "../replScratchPad"; +import { TerminalRepl } from "../terminalRepl"; + +import { BaseCommandHandler } from "./commandHandler"; + +/** + * Base class for scratch pad execution commands + */ +export abstract class BaseScratchPadCommandHandler extends BaseCommandHandler { + constructor( + private currentActiveWorkspace: ( + activeEditor?: vscode.TextEditor, + ) => Workspace | undefined, + private getScratchPad: (workspaceKey: string) => ReplScratchPad | undefined, + private getTerminalRepl: (workspaceKey: string) => TerminalRepl | undefined, + ) { + super(); + } + + async execute(): Promise { + const editor = this.getActiveEditor(); + if (!editor) { + return; + } + + if (!this.isScratchPadDocument(editor.document)) { + return; + } + + const workspace = this.currentActiveWorkspace(editor); + if (!workspace) { + return; + } + + const workspaceKey = workspace.workspaceFolder.uri.toString(); + const scratchPad = this.getScratchPad(workspaceKey); + + if (!scratchPad) { + vscode.window.showWarningMessage( + "No scratch pad found for this workspace", + ); + return; + } + + const terminalRepl = this.getTerminalRepl(workspaceKey); + if (!terminalRepl) { + vscode.window.showWarningMessage("No REPL found for this workspace"); + return; + } + + await this.executeScratchPadAction(scratchPad, terminalRepl, editor); + } + + protected abstract executeScratchPadAction( + scratchPad: ReplScratchPad, + terminalRepl: TerminalRepl, + editor: vscode.TextEditor, + ): Promise; + + protected isExecutableCode(code: string): boolean { + return Boolean(code && !code.startsWith("#")); + } + + private isScratchPadDocument(document: vscode.TextDocument): boolean { + return document.isUntitled && document.languageId === "ruby"; + } +} diff --git a/vscode/src/commands/commandHandler.ts b/vscode/src/commands/commandHandler.ts new file mode 100644 index 000000000..14dc363d6 --- /dev/null +++ b/vscode/src/commands/commandHandler.ts @@ -0,0 +1,59 @@ +import * as vscode from "vscode"; + +/** + * Interface for command handlers that can be registered with VS Code + */ +export interface CommandHandler { + /** + * The command identifier that this handler responds to + */ + readonly commandId: string; + + /** + * Execute the command + */ + execute(...args: any[]): Promise | void; + + /** + * Register this command handler with VS Code + */ + register(): vscode.Disposable; +} + +/** + * Abstract base class for command handlers with common functionality + */ +export abstract class BaseCommandHandler implements CommandHandler { + abstract readonly commandId: string; + + abstract execute(...args: any[]): Promise | void; + + /** + * Register this command handler with VS Code + */ + register(): vscode.Disposable { + return vscode.commands.registerCommand(this.commandId, (...args) => + this.execute(...args), + ); + } + + /** + * Helper method to show error messages consistently + */ + protected showError(message: string, error?: Error): void { + const fullMessage = error ? `${message}: ${error.message}` : message; + vscode.window.showErrorMessage(fullMessage); + } + + /** + * Helper method to get the active editor with validation + */ + protected getActiveEditor(): vscode.TextEditor | undefined { + const editor = vscode.window.activeTextEditor; + if (!editor) { + vscode.window.showWarningMessage("No active editor found"); + return undefined; + } + return editor; + } +} diff --git a/vscode/src/commands/execInReplCommandHandler.ts b/vscode/src/commands/execInReplCommandHandler.ts new file mode 100644 index 000000000..71ad1934a --- /dev/null +++ b/vscode/src/commands/execInReplCommandHandler.ts @@ -0,0 +1,86 @@ +import * as vscode from "vscode"; + +import { Command } from "../common"; +import { Workspace } from "../workspace"; +import { TerminalRepl } from "../terminalRepl"; + +import { BaseCommandHandler } from "./commandHandler"; + +/** + * Command handler for executing code in an existing REPL + */ +export class ExecInReplCommandHandler extends BaseCommandHandler { + readonly commandId = Command.ExecInRepl; + + constructor( + private currentActiveWorkspace: ( + activeEditor?: vscode.TextEditor, + ) => Workspace | undefined, + private getTerminalRepl: (workspaceKey: string) => TerminalRepl | undefined, + ) { + super(); + } + + async execute(): Promise { + const editor = this.getActiveEditor(); + if (!editor) { + return; + } + + const workspace = this.currentActiveWorkspace(editor); + if (!workspace) { + vscode.window.showWarningMessage("No workspace found for current file"); + return; + } + + const workspaceKey = workspace.workspaceFolder.uri.toString(); + const terminalRepl = this.getTerminalRepl(workspaceKey); + + if (!terminalRepl || !terminalRepl.isRunning) { + await this.promptToStartRepl(); + return; + } + + const code = this.getCodeToExecute(editor); + if (!code.trim()) { + vscode.window.showWarningMessage("No code selected to execute"); + return; + } + + try { + await terminalRepl.execute(code); + } catch (error) { + this.showError("Failed to execute in REPL", error as Error); + } + } + + private getCodeToExecute(editor: vscode.TextEditor): string { + const selection = editor.selection; + + if (selection.isEmpty) { + return this.getCurrentLineText(editor); + } else { + return this.getSelectedText(editor); + } + } + + private getCurrentLineText(editor: vscode.TextEditor): string { + const line = editor.document.lineAt(editor.selection.active.line); + return line.text; + } + + private getSelectedText(editor: vscode.TextEditor): string { + return editor.document.getText(editor.selection); + } + + private async promptToStartRepl(): Promise { + const answer = await vscode.window.showInformationMessage( + "No REPL is running for this workspace. Would you like to start one?", + "Start REPL", + ); + + if (answer === "Start REPL") { + await vscode.commands.executeCommand(Command.StartRepl); + } + } +} diff --git a/vscode/src/commands/executeScratchPadLineCommandHandler.ts b/vscode/src/commands/executeScratchPadLineCommandHandler.ts new file mode 100644 index 000000000..067ea2983 --- /dev/null +++ b/vscode/src/commands/executeScratchPadLineCommandHandler.ts @@ -0,0 +1,40 @@ +import * as vscode from "vscode"; + +import { Command } from "../common"; +import { ReplScratchPad } from "../replScratchPad"; +import { TerminalRepl } from "../terminalRepl"; + +import { BaseScratchPadCommandHandler } from "./baseScratchPadCommandHandler"; + +/** + * Command handler for executing the current line in a scratch pad + */ +export class ExecuteScratchPadLineCommandHandler extends BaseScratchPadCommandHandler { + readonly commandId = Command.ExecuteScratchPadLine; + + protected async executeScratchPadAction( + scratchPad: ReplScratchPad, + terminalRepl: TerminalRepl, + editor: vscode.TextEditor, + ): Promise { + const code = scratchPad.getCurrentLineCode(editor); + if (!this.isExecutableCode(code)) { + scratchPad.moveCursorToNextLine(editor); + return; + } + + try { + await terminalRepl.execute(code); + scratchPad.showExecutionSuccess(editor, editor.selection.active.line); + } catch (error) { + this.showError("Failed to execute in scratch pad", error as Error); + scratchPad.showExecutionError( + editor, + editor.selection.active.line, + (error as Error).message, + ); + } + + scratchPad.moveCursorToNextLine(editor); + } +} diff --git a/vscode/src/commands/executeScratchPadSelectionCommandHandler.ts b/vscode/src/commands/executeScratchPadSelectionCommandHandler.ts new file mode 100644 index 000000000..0dcb5de69 --- /dev/null +++ b/vscode/src/commands/executeScratchPadSelectionCommandHandler.ts @@ -0,0 +1,37 @@ +import * as vscode from "vscode"; + +import { Command } from "../common"; +import { ReplScratchPad } from "../replScratchPad"; +import { TerminalRepl } from "../terminalRepl"; + +import { BaseScratchPadCommandHandler } from "./baseScratchPadCommandHandler"; + +/** + * Command handler for executing the selected text in a scratch pad + */ +export class ExecuteScratchPadSelectionCommandHandler extends BaseScratchPadCommandHandler { + readonly commandId = Command.ExecuteScratchPadSelection; + + protected async executeScratchPadAction( + scratchPad: ReplScratchPad, + terminalRepl: TerminalRepl, + editor: vscode.TextEditor, + ): Promise { + const { code, lineNumber } = scratchPad.getSelectionCode(editor); + if (!this.isExecutableCode(code)) { + return; + } + + try { + await terminalRepl.execute(code); + scratchPad.showExecutionSuccess(editor, lineNumber); + } catch (error) { + this.showError("Failed to execute in scratch pad", error as Error); + scratchPad.showExecutionError( + editor, + lineNumber, + (error as Error).message, + ); + } + } +} diff --git a/vscode/src/commands/index.ts b/vscode/src/commands/index.ts new file mode 100644 index 000000000..6346940fa --- /dev/null +++ b/vscode/src/commands/index.ts @@ -0,0 +1,7 @@ +export { CommandHandler, BaseCommandHandler } from "./commandHandler"; +export { BaseScratchPadCommandHandler } from "./baseScratchPadCommandHandler"; +export { StartReplCommandHandler } from "./startReplCommandHandler"; +export { ExecInReplCommandHandler } from "./execInReplCommandHandler"; +export { InterruptReplCommandHandler } from "./interruptReplCommandHandler"; +export { ExecuteScratchPadLineCommandHandler } from "./executeScratchPadLineCommandHandler"; +export { ExecuteScratchPadSelectionCommandHandler } from "./executeScratchPadSelectionCommandHandler"; diff --git a/vscode/src/commands/interruptReplCommandHandler.ts b/vscode/src/commands/interruptReplCommandHandler.ts new file mode 100644 index 000000000..788588850 --- /dev/null +++ b/vscode/src/commands/interruptReplCommandHandler.ts @@ -0,0 +1,41 @@ +import * as vscode from "vscode"; + +import { Command } from "../common"; +import { Workspace } from "../workspace"; +import { TerminalRepl } from "../terminalRepl"; + +import { BaseCommandHandler } from "./commandHandler"; + +/** + * Command handler for interrupting a running REPL session + */ +export class InterruptReplCommandHandler extends BaseCommandHandler { + readonly commandId = Command.InterruptRepl; + + constructor( + private showWorkspacePick: () => Promise, + private getTerminalRepl: (workspaceKey: string) => TerminalRepl | undefined, + ) { + super(); + } + + async execute(): Promise { + const workspace = await this.showWorkspacePick(); + if (!workspace) { + return; + } + + const workspaceKey = workspace.workspaceFolder.uri.toString(); + const terminalRepl = this.getTerminalRepl(workspaceKey); + + if (!terminalRepl || !terminalRepl.isRunning) { + vscode.window.showInformationMessage( + "No REPL is running for this workspace", + ); + return; + } + + terminalRepl.interrupt(); + vscode.window.showInformationMessage("REPL interrupted"); + } +} diff --git a/vscode/src/commands/startReplCommandHandler.ts b/vscode/src/commands/startReplCommandHandler.ts new file mode 100644 index 000000000..d6ebaf4e9 --- /dev/null +++ b/vscode/src/commands/startReplCommandHandler.ts @@ -0,0 +1,50 @@ +import * as vscode from "vscode"; + +import { Command } from "../common"; +import { Workspace } from "../workspace"; +import { ReplType } from "../terminalRepl"; + +import { BaseCommandHandler } from "./commandHandler"; + +/** + * Command handler for starting a new REPL session + */ +export class StartReplCommandHandler extends BaseCommandHandler { + readonly commandId = Command.StartRepl; + + constructor( + private showWorkspacePick: () => Promise, + private startRepl: ( + workspace: Workspace, + replType: ReplType, + ) => Promise, + ) { + super(); + } + + async execute(): Promise { + const workspace = await this.showWorkspacePick(); + if (!workspace) { + return; + } + + const replType = await this.selectReplType(); + if (!replType) { + return; + } + + try { + await this.startRepl(workspace, replType); + } catch (error) { + this.showError("Failed to start REPL", error as Error); + } + } + + private async selectReplType(): Promise { + const replType = await vscode.window.showQuickPick(["irb", "rails"], { + placeHolder: "Select REPL type", + }); + + return replType as ReplType | undefined; + } +} diff --git a/vscode/src/common.ts b/vscode/src/common.ts index 0ed9f4bd3..257511648 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -34,6 +34,13 @@ export enum Command { ShowOutput = "rubyLsp.showOutput", MigrateLaunchConfiguration = "rubyLsp.migrateLaunchConfiguration", GoToRelevantFile = "rubyLsp.goToRelevantFile", + StartRepl = "rubyLsp.startREPL", + ExecInRepl = "rubyLsp.execInREPL", + InterruptRepl = "rubyLsp.interruptREPL", + ExecuteScratchPadLine = "rubyLsp.executeScratchPadLine", + ExecuteScratchPadSelection = "rubyLsp.executeScratchPadSelection", + CreateIrbTerminal = "rubyLsp.createIrbTerminal", + CreateRailsConsoleTerminal = "rubyLsp.createRailsConsoleTerminal", } export interface RubyInterface { diff --git a/vscode/src/replManager.ts b/vscode/src/replManager.ts new file mode 100644 index 000000000..ba655106b --- /dev/null +++ b/vscode/src/replManager.ts @@ -0,0 +1,236 @@ +import * as vscode from "vscode"; + +import { TerminalRepl, ReplType } from "./terminalRepl"; +import { ReplScratchPad } from "./replScratchPad"; +import { RubyLspTerminalProfileProvider } from "./terminalProfileProvider"; +import { Workspace } from "./workspace"; +import { CommandHandler } from "./commands/commandHandler"; +import { StartReplCommandHandler } from "./commands/startReplCommandHandler"; +import { ExecInReplCommandHandler } from "./commands/execInReplCommandHandler"; +import { InterruptReplCommandHandler } from "./commands/interruptReplCommandHandler"; +import { ExecuteScratchPadLineCommandHandler } from "./commands/executeScratchPadLineCommandHandler"; +import { ExecuteScratchPadSelectionCommandHandler } from "./commands/executeScratchPadSelectionCommandHandler"; + +export class ReplManager implements vscode.Disposable { + private terminalRepls: Map = new Map(); + private replScratchPads: Map = new Map(); + private terminalProfileProvider: RubyLspTerminalProfileProvider; + private commandHandlers: CommandHandler[] = []; + + constructor( + private context: vscode.ExtensionContext, + private getWorkspaces: () => Workspace[], + private showWorkspacePick: () => Promise, + private currentActiveWorkspace: ( + activeEditor?: vscode.TextEditor, + ) => Workspace | undefined, + ) { + this.terminalProfileProvider = new RubyLspTerminalProfileProvider( + [], + this.registerRepl.bind(this), + this.unregisterRepl.bind(this), + this.registerScratchPad.bind(this), + ); + + this.initializeCommandHandlers(); + } + + public register(): vscode.Disposable[] { + this.terminalProfileProvider.register(this.context); + + return this.commandHandlers.map((handler) => handler.register()); + } + + public async updateWorkspaces(): Promise { + await this.terminalProfileProvider.updateWorkspaces(this.getWorkspaces()); + } + + public dispose(): void { + this.disposeAllTerminalRepls(); + this.disposeAllScratchPads(); + this.terminalProfileProvider.dispose(); + } + + private disposeAllTerminalRepls(): void { + for (const terminalRepl of this.terminalRepls.values()) { + terminalRepl.dispose(); + } + this.terminalRepls.clear(); + } + + private disposeAllScratchPads(): void { + for (const scratchPad of this.replScratchPads.values()) { + scratchPad.dispose(); + } + this.replScratchPads.clear(); + } + + private initializeCommandHandlers(): void { + this.commandHandlers = [ + new StartReplCommandHandler( + this.showWorkspacePick, + this.startRepl.bind(this), + ), + new ExecInReplCommandHandler( + this.currentActiveWorkspace, + (workspaceKey: string) => this.terminalRepls.get(workspaceKey), + ), + new InterruptReplCommandHandler( + this.showWorkspacePick, + (workspaceKey: string) => this.terminalRepls.get(workspaceKey), + ), + new ExecuteScratchPadLineCommandHandler( + this.currentActiveWorkspace, + (workspaceKey: string) => this.replScratchPads.get(workspaceKey), + (workspaceKey: string) => this.terminalRepls.get(workspaceKey), + ), + new ExecuteScratchPadSelectionCommandHandler( + this.currentActiveWorkspace, + (workspaceKey: string) => this.replScratchPads.get(workspaceKey), + (workspaceKey: string) => this.terminalRepls.get(workspaceKey), + ), + ]; + } + + private async startRepl( + workspace: Workspace, + replType: ReplType, + ): Promise { + const workspaceKey = workspace.workspaceFolder.uri.toString(); + + this.cleanupExistingReplForWorkspace(workspaceKey); + const terminalRepl = this.createTerminalRepl( + workspace, + replType, + workspaceKey, + ); + + try { + await terminalRepl.start(); + await this.handleSuccessfulReplStart(workspaceKey, replType); + } catch (error: any) { + this.handleReplStartFailure(workspaceKey, terminalRepl, error); + } + } + + private cleanupExistingReplForWorkspace(workspaceKey: string): void { + const existingRepl = this.terminalRepls.get(workspaceKey); + if (existingRepl) { + existingRepl.dispose(); + this.terminalRepls.delete(workspaceKey); + } + } + + private createTerminalRepl( + workspace: Workspace, + replType: ReplType, + workspaceKey: string, + ): TerminalRepl { + const terminalRepl = new TerminalRepl(workspace, replType); + + terminalRepl.onDidClose(() => { + this.cleanupWorkspaceResources(workspaceKey); + }); + + this.terminalRepls.set(workspaceKey, terminalRepl); + return terminalRepl; + } + + private cleanupWorkspaceResources(workspaceKey: string): void { + this.terminalRepls.delete(workspaceKey); + this.disposeScratchPadForWorkspace(workspaceKey); + } + + private async handleSuccessfulReplStart( + workspaceKey: string, + replType: ReplType, + ): Promise { + if (this.autoOpenScratchPad) { + await this.createAndShowScratchPad(workspaceKey); + this.showScratchPadWelcomeMessage(); + } else if (this.showWelcomeMessage) { + this.showReplWelcomeMessage(replType); + } + } + + private async createAndShowScratchPad(workspaceKey: string): Promise { + const scratchPad = new ReplScratchPad(); + this.replScratchPads.set(workspaceKey, scratchPad); + await scratchPad.show(); + } + + private showScratchPadWelcomeMessage(): void { + if (this.showWelcomeMessage) { + vscode.window.showInformationMessage( + "Ruby REPL started with scratch pad. Use Ctrl+Enter to execute code!", + ); + } + } + + private showReplWelcomeMessage(replType: ReplType): void { + vscode.window.showInformationMessage( + `${replType === "rails" ? "Rails Console" : "Ruby REPL"} started successfully`, + ); + } + + private handleReplStartFailure( + workspaceKey: string, + terminalRepl: TerminalRepl, + error: any, + ): void { + vscode.window.showErrorMessage(`Failed to start REPL: ${error.message}`); + terminalRepl.dispose(); + this.terminalRepls.delete(workspaceKey); + } + + private getReplSettings(): vscode.WorkspaceConfiguration { + return vscode.workspace.getConfiguration("rubyLsp.replSettings"); + } + + private getReplSetting(key: string): T { + return this.getReplSettings().get(key)!; + } + + private get autoOpenScratchPad(): boolean { + return this.getReplSetting("autoOpenScratchPad"); + } + + private get showWelcomeMessage(): boolean { + return this.getReplSetting("showWelcomeMessage"); + } + + private registerRepl(workspaceKey: string, repl: TerminalRepl): void { + this.disposeReplForWorkspace(workspaceKey); + this.terminalRepls.set(workspaceKey, repl); + this.disposeScratchPadForWorkspace(workspaceKey); + } + + private unregisterRepl(workspaceKey: string): void { + this.disposeReplForWorkspace(workspaceKey); + this.disposeScratchPadForWorkspace(workspaceKey); + } + + private disposeReplForWorkspace(workspaceKey: string): void { + const terminalRepl = this.terminalRepls.get(workspaceKey); + if (terminalRepl) { + terminalRepl.dispose(); + this.terminalRepls.delete(workspaceKey); + } + } + + private disposeScratchPadForWorkspace(workspaceKey: string): void { + const scratchPad = this.replScratchPads.get(workspaceKey); + if (scratchPad) { + scratchPad.dispose(); + this.replScratchPads.delete(workspaceKey); + } + } + + private registerScratchPad( + workspaceKey: string, + scratchPad: ReplScratchPad, + ): void { + this.disposeScratchPadForWorkspace(workspaceKey); + this.replScratchPads.set(workspaceKey, scratchPad); + } +} diff --git a/vscode/src/replScratchPad.ts b/vscode/src/replScratchPad.ts new file mode 100644 index 000000000..89cff01ca --- /dev/null +++ b/vscode/src/replScratchPad.ts @@ -0,0 +1,256 @@ +import * as vscode from "vscode"; + +export class ReplScratchPad { + private static readonly initialContent = `# Ruby REPL Scratch Pad +# Write Ruby code here with full LSP support (completions, hover, etc.) +# +# Keyboard Shortcuts: +# • Ctrl+Enter: Execute current line +# • Ctrl+Shift+Enter: Execute selection or current line +# +# Tips: +# • Full IntelliSense and hover documentation available +# • Errors are highlighted with red underlines +# • Executed lines show a temporary indicator +# • Use the terminal below for command history (up/down arrows work there) +# +# Examples: + +# Variables +name = "Ruby Developer" +age = 25 + +# String interpolation +puts "I am #{name} and I'm #{age} years old" + +# Arrays +fruits = ["apple", "banana", "orange"] +fruits.each { |fruit| puts "I like #{fruit}" } + +# Hash +person = { name: "Alice", age: 30, city: "New York" } +person[:occupation] = "Developer" + +# Methods +def greet(name) + "Hello, #{name}!" +end + +greet("World") +`; + + private document: vscode.TextDocument | undefined; + private editor: vscode.TextEditor | undefined; + private decorationType: vscode.TextEditorDecorationType; + private errorDecorationType: vscode.TextEditorDecorationType; + + constructor() { + this.decorationType = vscode.window.createTextEditorDecorationType({ + after: { + color: new vscode.ThemeColor("editorCodeLens.foreground"), + fontStyle: "italic", + }, + }); + + this.errorDecorationType = vscode.window.createTextEditorDecorationType({ + textDecoration: "wavy underline red", + after: { + color: new vscode.ThemeColor("errorForeground"), + fontStyle: "italic", + }, + }); + } + + async show(): Promise { + await this.createOrReuseScratchPadDocument(); + this.editor = await this.displayDocumentInSideEditor(); + await this.setupEditorAndTerminalLayout(); + } + + dispose(): void { + this.closeScratchPadSafely(); + this.disposeDecorationTypes(); + } + + async closeScratchPad(): Promise { + if (this.hasScratchPadDocument()) { + await this.closeScratchPadTab(); + } + this.clearDocumentReferences(); + } + + getCurrentLineCode(editor: vscode.TextEditor): string { + const line = editor.document.lineAt(editor.selection.active.line); + return line.text.trim(); + } + + getSelectionCode(editor: vscode.TextEditor): { + code: string; + lineNumber: number; + } { + if (editor.selection.isEmpty) { + const line = editor.document.lineAt(editor.selection.active.line); + return { + code: line.text.trim(), + lineNumber: line.lineNumber, + }; + } else { + return { + code: editor.document.getText(editor.selection), + lineNumber: editor.selection.end.line, + }; + } + } + + showExecutionSuccess(editor: vscode.TextEditor, lineNumber: number): void { + this.addExecutedDecoration(editor, lineNumber); + this.clearErrorDecorations(editor); + } + + showExecutionError( + editor: vscode.TextEditor, + lineNumber: number, + error: string, + ): void { + this.addErrorDecoration(editor, lineNumber, error); + } + + moveCursorToNextLine(editor: vscode.TextEditor): void { + const currentLineNumber = editor.selection.active.line; + const nextLine = Math.min( + currentLineNumber + 1, + editor.document.lineCount - 1, + ); + const newPosition = new vscode.Position(nextLine, 0); + editor.selection = new vscode.Selection(newPosition, newPosition); + } + + private async createOrReuseScratchPadDocument(): Promise { + if (!this.document || this.document.isClosed) { + this.document = await vscode.workspace.openTextDocument({ + language: "ruby", + content: ReplScratchPad.initialContent, + }); + } + } + + private async displayDocumentInSideEditor(): Promise { + return vscode.window.showTextDocument(this.document!, { + viewColumn: vscode.ViewColumn.Beside, + preserveFocus: false, + }); + } + + private async setupEditorAndTerminalLayout(): Promise { + this.clearExistingDecorations(); + await this.focusTerminalThenEditor(); + } + + private clearExistingDecorations(): void { + this.editor!.setDecorations(this.decorationType, []); + this.editor!.setDecorations(this.errorDecorationType, []); + } + + private async focusTerminalThenEditor(): Promise { + await vscode.commands.executeCommand("workbench.action.terminal.focus"); + await vscode.commands.executeCommand( + "workbench.action.focusActiveEditorGroup", + ); + } + + private clearErrorDecorations(editor: vscode.TextEditor): void { + editor.setDecorations(this.errorDecorationType, []); + } + + private closeScratchPadSafely(): void { + this.closeScratchPad().catch(() => {}); + } + + private disposeDecorationTypes(): void { + this.decorationType.dispose(); + this.errorDecorationType.dispose(); + } + + private hasScratchPadDocument(): boolean { + return Boolean(this.document && !this.document.isClosed && this.editor); + } + + private async closeScratchPadTab(): Promise { + const scratchPadTab = this.findScratchPadTab(); + if (scratchPadTab) { + await vscode.window.tabGroups.close(scratchPadTab); + } + } + + private findScratchPadTab(): vscode.Tab | undefined { + const tabs = vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .filter((tab) => tab.input instanceof vscode.TabInputText); + + return tabs.find((tab) => { + const input = tab.input as vscode.TabInputText; + return input.uri.toString() === this.document?.uri.toString(); + }); + } + + private clearDocumentReferences(): void { + this.document = undefined; + this.editor = undefined; + } + + private addExecutedDecoration(editor: vscode.TextEditor, line: number): void { + const decorations = [ + { + range: new vscode.Range( + line, + Number.MAX_SAFE_INTEGER, + line, + Number.MAX_SAFE_INTEGER, + ), + renderOptions: { + after: { + contentText: " ✓ executed", + color: new vscode.ThemeColor("testing.iconPassed"), + }, + }, + }, + ]; + + editor.setDecorations(this.decorationType, decorations); + this.scheduleDecorationsRemoval(editor, this.decorationType); + } + + private addErrorDecoration( + editor: vscode.TextEditor, + line: number, + error: string, + ): void { + const decorations = [ + { + range: new vscode.Range(line, 0, line, Number.MAX_SAFE_INTEGER), + renderOptions: { + after: { + contentText: ` ✗ ${error}`, + }, + }, + }, + ]; + + editor.setDecorations(this.errorDecorationType, decorations); + this.scheduleDecorationsRemoval(editor, this.errorDecorationType); + } + + private scheduleDecorationsRemoval( + editor: vscode.TextEditor, + decorationType: vscode.TextEditorDecorationType, + ): void { + setTimeout(() => { + editor.setDecorations(decorationType, []); + }, this.feedbackDuration); + } + + private get feedbackDuration(): number { + const config = vscode.workspace.getConfiguration("rubyLsp.replSettings"); + return config.get("executionFeedbackDuration")!; + } +} diff --git a/vscode/src/rubyLsp.ts b/vscode/src/rubyLsp.ts index ecfd99d51..357b5157a 100644 --- a/vscode/src/rubyLsp.ts +++ b/vscode/src/rubyLsp.ts @@ -20,6 +20,7 @@ import { Rails } from "./rails"; import { ChatAgent } from "./chatAgent"; import { collectRubyLspInfo } from "./infoCollector"; import { Mode } from "./streamingRunner"; +import { ReplManager } from "./replManager"; // The RubyLsp class represents an instance of the entire extension. This should only be instantiated once at the // activation event. One instance of this class controls all of the existing workspaces, telemetry and handles all @@ -32,6 +33,7 @@ export class RubyLsp { private readonly debug: Debugger; private readonly telemetry: vscode.TelemetryLogger; private readonly rails: Rails; + private readonly replManager: ReplManager; // A URI => content map of virtual documents for delegate requests private readonly virtualDocuments = new Map(); @@ -54,6 +56,12 @@ export class RubyLsp { ); this.debug = new Debugger(context, this.workspaceResolver.bind(this)); this.rails = new Rails(this.showWorkspacePick.bind(this)); + this.replManager = new ReplManager( + context, + () => Array.from(this.workspaces.values()), + this.showWorkspacePick.bind(this), + this.currentActiveWorkspace.bind(this), + ); this.statusItems = new StatusItems(); const dependenciesTree = new DependenciesTree(); @@ -78,6 +86,9 @@ export class RubyLsp { this.workspaces.delete(workspaceFolder.uri.toString()); } } + + // Update REPL manager with remaining workspaces + await this.replManager.updateWorkspaces(); }), // Lazily activate workspaces that do not contain a lockfile vscode.workspace.onDidOpenTextDocument(async (document) => { @@ -128,6 +139,9 @@ export class RubyLsp { async activate() { await vscode.commands.executeCommand("testing.clearTestResults"); + // Register REPL manager commands + this.context.subscriptions.push(...this.replManager.register()); + const firstWorkspace = vscode.workspace.workspaceFolders?.[0]; // We only activate the first workspace eagerly to avoid running into performance and memory issues. Having too many @@ -135,6 +149,9 @@ export class RubyLsp { // activated lazily once a Ruby document is opened inside of it through the `onDidOpenTextDocument` event if (firstWorkspace) { await this.activateWorkspace(firstWorkspace, true); + + // Update REPL manager after first workspace is loaded + await this.replManager.updateWorkspaces(); } // If the user has the editor already opened on a Ruby file and that file does not belong to the first workspace, @@ -167,6 +184,8 @@ export class RubyLsp { // Deactivate the extension, which should stop all language servers. Notice that this just stops anything that is // running, but doesn't dispose of existing instances async deactivate() { + this.replManager.dispose(); + for (const workspace of this.workspaces.values()) { await workspace.stop(); await workspace.dispose(); @@ -239,6 +258,9 @@ export class RubyLsp { this.workspaces.set(workspaceFolder.uri.toString(), workspace); + // Update REPL manager with current workspaces + await this.replManager.updateWorkspaces(); + // If we successfully activated a workspace, then we can start showing the dependencies tree view. This is necessary // so that we can avoid showing it on non Ruby projects await vscode.commands.executeCommand( @@ -750,6 +772,11 @@ export class RubyLsp { workspaceFolder = vscode.workspace.getWorkspaceFolder( activeEditor.document.uri, ); + + // For untitled documents, use the first workspace folder + if (!workspaceFolder && activeEditor.document.isUntitled) { + workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + } } else { // If there's no active editor, we search based on the current workspace name workspaceFolder = vscode.workspace.workspaceFolders?.find( diff --git a/vscode/src/terminalProfileProvider.ts b/vscode/src/terminalProfileProvider.ts new file mode 100644 index 000000000..245040606 --- /dev/null +++ b/vscode/src/terminalProfileProvider.ts @@ -0,0 +1,495 @@ +import * as vscode from "vscode"; + +import { TerminalRepl, ReplType } from "./terminalRepl"; +import { ReplScratchPad } from "./replScratchPad"; +import { Workspace } from "./workspace"; +import { Command } from "./common"; + +export class RubyLspTerminalProfileProvider { + private workspaces: Workspace[]; + private registered = false; + private registerReplCallback?: ( + workspaceKey: string, + repl: TerminalRepl, + ) => void; + + private unregisterReplCallback?: (workspaceKey: string) => void; + private registerScratchPadCallback?: ( + workspaceKey: string, + scratchPad: ReplScratchPad, + ) => void; + + private irbProfileDisposable?: vscode.Disposable; + private railsProfileDisposable?: vscode.Disposable; + + constructor( + workspaces: Workspace[], + registerReplCallback?: (workspaceKey: string, repl: TerminalRepl) => void, + unregisterReplCallback?: (workspaceKey: string) => void, + registerScratchPadCallback?: ( + workspaceKey: string, + scratchPad: ReplScratchPad, + ) => void, + ) { + this.workspaces = workspaces; + this.registerReplCallback = registerReplCallback; + this.unregisterReplCallback = unregisterReplCallback; + this.registerScratchPadCallback = registerScratchPadCallback; + } + + public async updateWorkspaces(workspaces: Workspace[]): Promise { + this.workspaces = workspaces; + } + + public setCallbacks( + registerReplCallback: (workspaceKey: string, repl: TerminalRepl) => void, + unregisterReplCallback: (workspaceKey: string) => void, + registerScratchPadCallback?: ( + workspaceKey: string, + scratchPad: ReplScratchPad, + ) => void, + ): void { + this.registerReplCallback = registerReplCallback; + this.unregisterReplCallback = unregisterReplCallback; + this.registerScratchPadCallback = registerScratchPadCallback; + } + + public dispose(): void { + this.disposeProfileProviders(); + } + + public register(context: vscode.ExtensionContext): void { + if (this.registered) { + return; + } + + this.registerCommands(context); + this.registerTerminalProfileProviders(context); + this.setupTerminalMonitoring(context); + + this.registered = true; + } + + private disposeProfileProviders(): void { + if (this.irbProfileDisposable) { + this.irbProfileDisposable.dispose(); + this.irbProfileDisposable = undefined; + } + + if (this.railsProfileDisposable) { + this.railsProfileDisposable.dispose(); + this.railsProfileDisposable = undefined; + } + } + + private registerCommands(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand(Command.CreateIrbTerminal, () => + this.createIrbTerminal(), + ), + vscode.commands.registerCommand(Command.CreateRailsConsoleTerminal, () => + this.createRailsConsoleTerminal(), + ), + ); + } + + private registerTerminalProfileProviders( + context: vscode.ExtensionContext, + ): void { + this.irbProfileDisposable = vscode.window.registerTerminalProfileProvider( + "rubyLsp.irbTerminal", + { + provideTerminalProfile: ( + token: vscode.CancellationToken, + ): vscode.ProviderResult => { + return this.provideIrbTerminalProfile(token); + }, + }, + ); + context.subscriptions.push(this.irbProfileDisposable); + + this.railsProfileDisposable = vscode.window.registerTerminalProfileProvider( + "rubyLsp.railsConsoleTerminal", + { + provideTerminalProfile: ( + token: vscode.CancellationToken, + ): vscode.ProviderResult => { + return this.provideRailsConsoleTerminalProfile(token); + }, + }, + ); + context.subscriptions.push(this.railsProfileDisposable); + } + + private setupTerminalMonitoring(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.window.onDidOpenTerminal((terminal) => { + this.handleTerminalOpened(terminal).catch(() => {}); + }), + ); + } + + private async handleTerminalOpened(terminal: vscode.Terminal): Promise { + if (this.isReplTerminal(terminal)) { + const replType = terminal.name === "Rails Console" ? "rails" : "irb"; + await this.wrapTerminalAsRepl(terminal, replType); + } + } + + private isReplTerminal(terminal: vscode.Terminal): boolean { + return ( + terminal.name === "Ruby REPL (IRB)" || terminal.name === "Rails Console" + ); + } + + private async wrapTerminalAsRepl( + terminal: vscode.Terminal, + replType: ReplType, + ): Promise { + const workspace = this.getActiveWorkspace(); + if (!workspace) { + return; + } + + const workspaceKey = workspace.workspaceFolder.uri.toString(); + + this.unregisterExistingRepl(workspaceKey); + + const terminalRepl = new TerminalRepl(workspace, replType); + terminalRepl.adoptTerminal(terminal); + + terminalRepl.onDidClose(() => { + this.unregisterExistingRepl(workspaceKey); + }); + + this.registerNewRepl(workspaceKey, terminalRepl); + await this.createAndShowScratchPad(workspaceKey, replType); + } + + private async createIrbTerminal(): Promise { + const workspace = this.getActiveWorkspace(); + if (!workspace) { + vscode.window.showErrorMessage("No Ruby workspace found"); + return; + } + + const workspaceKey = workspace.workspaceFolder.uri.toString(); + this.unregisterExistingRepl(workspaceKey); + + const terminalRepl = new TerminalRepl(workspace, "irb"); + terminalRepl.onDidClose(() => { + this.unregisterExistingRepl(workspaceKey); + }); + + this.registerNewRepl(workspaceKey, terminalRepl); + + try { + await terminalRepl.start(); + await this.createAndShowScratchPad(workspaceKey, "irb"); + } catch (error: any) { + this.handleReplStartFailure("IRB", error, workspaceKey, terminalRepl); + } + } + + private async createRailsConsoleTerminal(): Promise { + const workspace = this.getActiveWorkspace(); + if (!workspace) { + vscode.window.showErrorMessage("No Ruby workspace found"); + return; + } + + if (!(await this.isRailsProject(workspace))) { + this.showNonRailsProjectWarning(); + return; + } + + const workspaceKey = workspace.workspaceFolder.uri.toString(); + this.unregisterExistingRepl(workspaceKey); + + const terminalRepl = new TerminalRepl(workspace, "rails"); + terminalRepl.onDidClose(() => { + this.unregisterExistingRepl(workspaceKey); + }); + + this.registerNewRepl(workspaceKey, terminalRepl); + + try { + await terminalRepl.start(); + await this.createAndShowScratchPad(workspaceKey, "rails"); + } catch (error: any) { + this.handleReplStartFailure( + "Rails Console", + error, + workspaceKey, + terminalRepl, + ); + } + } + + private unregisterExistingRepl(workspaceKey: string): void { + if (this.unregisterReplCallback) { + this.unregisterReplCallback(workspaceKey); + } + } + + private registerNewRepl( + workspaceKey: string, + terminalRepl: TerminalRepl, + ): void { + if (this.registerReplCallback) { + this.registerReplCallback(workspaceKey, terminalRepl); + } + } + + private showNonRailsProjectWarning(): void { + vscode.window.showWarningMessage( + "Rails Console is only available in Rails projects. Use Ruby REPL instead.", + ); + } + + private handleReplStartFailure( + replName: string, + error: any, + workspaceKey: string, + terminalRepl: TerminalRepl, + ): void { + vscode.window.showErrorMessage( + `Failed to start ${replName}: ${error.message}`, + ); + terminalRepl.dispose(); + this.unregisterExistingRepl(workspaceKey); + } + + private async provideIrbTerminalProfile( + _token: vscode.CancellationToken, + ): Promise { + const workspace = this.getActiveWorkspace(); + if (!workspace) { + return undefined; + } + + const irbCommand = await this.buildIrbCommand(workspace); + + return { + options: { + name: "Ruby REPL (IRB)", + cwd: workspace.workspaceFolder.uri.fsPath, + env: this.buildIrbEnvironment(workspace), + iconPath: new vscode.ThemeIcon("ruby"), + isTransient: true, + shellPath: "/bin/sh", + shellArgs: ["-c", `${irbCommand} && exit`], + }, + }; + } + + private async buildIrbCommand(workspace: Workspace): Promise { + const useBundle = await this.shouldUseBundleExec(workspace); + const irbOptions = "--colorize --autocomplete"; + return useBundle ? `bundle exec irb ${irbOptions}` : `irb ${irbOptions}`; + } + + private buildIrbEnvironment(workspace: Workspace): Record { + return { + ...workspace.ruby.env, + TERM: "xterm-256color", + COLORTERM: "truecolor", + IRBRC: "", + IRB_USE_COLORIZE: "true", + }; + } + + private async provideRailsConsoleTerminalProfile( + _token: vscode.CancellationToken, + ): Promise { + const workspace = this.getActiveWorkspace(); + if (!workspace) { + return undefined; + } + + if (!(await this.isRailsProject(workspace))) { + return this.createNonRailsHelpProfile(workspace); + } + + const railsCommand = await this.buildRailsCommand(workspace); + + return { + options: { + name: "Rails Console", + cwd: workspace.workspaceFolder.uri.fsPath, + env: this.buildRailsEnvironment(workspace), + iconPath: new vscode.ThemeIcon("ruby"), + isTransient: true, + shellPath: "/bin/sh", + shellArgs: ["-c", `${railsCommand} && exit`], + }, + }; + } + + private createNonRailsHelpProfile( + workspace: Workspace, + ): vscode.TerminalProfile { + const helpMessage = [ + 'echo "❌ Rails Console is not available in this project."', + 'echo ""', + 'echo "This doesn\'t appear to be a Rails project."', + 'echo "Try using \\"Ruby REPL (IRB)\\" instead!"', + 'echo ""', + 'echo "Press any key to close..."', + "read -n 1", + "exit", + ].join("; "); + + return { + options: { + name: "Rails Console", + cwd: workspace.workspaceFolder.uri.fsPath, + env: workspace.ruby.env, + iconPath: new vscode.ThemeIcon("ruby"), + isTransient: true, + shellPath: "/bin/sh", + shellArgs: ["-c", helpMessage], + }, + }; + } + + private async buildRailsCommand(workspace: Workspace): Promise { + const useBundle = await this.shouldUseBundleExec(workspace); + return useBundle ? "bundle exec rails console" : "rails console"; + } + + private buildRailsEnvironment(workspace: Workspace): Record { + return { + ...workspace.ruby.env, + TERM: "xterm-256color", + COLORTERM: "truecolor", + }; + } + + private getActiveWorkspace(): Workspace | undefined { + if (this.workspaces.length === 1) { + return this.workspaces[0]; + } + + const workspaceForActiveEditor = this.findWorkspaceForActiveEditor(); + return workspaceForActiveEditor || this.workspaces[0]; + } + + private findWorkspaceForActiveEditor(): Workspace | undefined { + const activeEditor = vscode.window.activeTextEditor; + if (!activeEditor) { + return undefined; + } + + const activeWorkspaceFolder = vscode.workspace.getWorkspaceFolder( + activeEditor.document.uri, + ); + if (!activeWorkspaceFolder) { + return undefined; + } + + return this.workspaces.find( + (ws) => + ws.workspaceFolder.uri.toString() === + activeWorkspaceFolder.uri.toString(), + ); + } + + private async isRailsProject(workspace: Workspace): Promise { + if (await this.hasRailsConfigFile(workspace)) { + return true; + } + + return this.hasRailsInGemfile(workspace); + } + + private async hasRailsConfigFile(workspace: Workspace): Promise { + try { + const configFile = vscode.Uri.joinPath( + workspace.workspaceFolder.uri, + "config", + "application.rb", + ); + await vscode.workspace.fs.stat(configFile); + return true; + } catch { + return false; + } + } + + private async hasRailsInGemfile(workspace: Workspace): Promise { + try { + const gemfileUri = vscode.Uri.joinPath( + workspace.workspaceFolder.uri, + "Gemfile", + ); + const gemfileContent = await vscode.workspace.fs.readFile(gemfileUri); + const content = new TextDecoder().decode(gemfileContent); + return ( + content.includes("rails") || + content.includes("'rails'") || + content.includes('"rails"') + ); + } catch { + return false; + } + } + + private async shouldUseBundleExec(workspace: Workspace): Promise { + try { + await vscode.workspace.fs.stat( + vscode.Uri.joinPath(workspace.workspaceFolder.uri, "Gemfile"), + ); + return true; + } catch { + return false; + } + } + + private get autoOpenScratchPad(): boolean { + const config = vscode.workspace.getConfiguration("rubyLsp.replSettings"); + return config.get("autoOpenScratchPad")!; + } + + private get showWelcomeMessage(): boolean { + const config = vscode.workspace.getConfiguration("rubyLsp.replSettings"); + return config.get("showWelcomeMessage")!; + } + + private async createAndShowScratchPad( + workspaceKey: string, + replType: ReplType, + ): Promise { + if (!this.autoOpenScratchPad || !this.registerScratchPadCallback) { + this.showReplStartedMessage(replType); + return; + } + + const scratchPad = new ReplScratchPad(); + this.registerScratchPadCallback(workspaceKey, scratchPad); + await scratchPad.show(); + this.showScratchPadStartedMessage(replType); + } + + private showReplStartedMessage(replType: ReplType): void { + if (this.showWelcomeMessage) { + const replTypeName = this.getReplDisplayName(replType); + vscode.window.showInformationMessage( + `${replTypeName} started successfully`, + ); + } + } + + private showScratchPadStartedMessage(replType: ReplType): void { + if (this.showWelcomeMessage) { + const replTypeName = this.getReplDisplayName(replType); + vscode.window.showInformationMessage( + `${replTypeName} started with scratch pad. Use Ctrl+Enter to execute code!`, + ); + } + } + + private getReplDisplayName(replType: ReplType): string { + return replType === "rails" ? "Rails Console" : "Ruby REPL (IRB)"; + } +} diff --git a/vscode/src/terminalRepl.ts b/vscode/src/terminalRepl.ts new file mode 100644 index 000000000..c3b32d386 --- /dev/null +++ b/vscode/src/terminalRepl.ts @@ -0,0 +1,251 @@ +import * as vscode from "vscode"; + +import { Workspace } from "./workspace"; + +export type ReplType = "irb" | "rails"; + +export class TerminalRepl implements vscode.Disposable { + private terminal: vscode.Terminal | undefined; + private readonly workspace: Workspace; + private readonly replType: ReplType; + private terminalCloseListener: vscode.Disposable | undefined; + private onDidCloseCallback?: () => void; + private wasSuccessfullyStarted = false; + + constructor(workspace: Workspace, replType: ReplType) { + this.workspace = workspace; + this.replType = replType; + } + + async start(): Promise { + this.disposeExistingTerminal(); + this.terminal = this.createTerminalWithOptions(); + this.terminal.show(); + this.setupTerminalCloseListener(); + + const replCommand = await this.buildReplCommand(); + + this.sendReplCommandSafely(replCommand); + } + + adoptTerminal(terminal: vscode.Terminal): void { + this.disposeExistingTerminal(); + this.terminal = terminal; + this.setupTerminalCloseListener(); + } + + async execute(code: string): Promise { + this.ensureTerminalExists(); + this.verifyTerminalIsActive(); + this.sendCodeToTerminal(code); + } + + interrupt(): void { + if (!this.terminal) { + return; + } + + if (!this.isTerminalActive()) { + this.clearTerminalReference(); + return; + } + + this.sendInterruptSignal(); + } + + dispose(): void { + this.wasSuccessfullyStarted = false; + this.disposeTerminalCloseListener(); + this.disposeTerminal(); + } + + get isRunning(): boolean { + if (!this.terminal) { + return false; + } + + return vscode.window.terminals.includes(this.terminal); + } + + onDidClose(callback: () => void): void { + this.onDidCloseCallback = callback; + } + + private disposeExistingTerminal(): void { + if (this.terminal) { + this.terminal.dispose(); + } + } + + private createTerminalWithOptions(): vscode.Terminal { + const terminalName = this.terminalName; + const terminalOptions: vscode.TerminalOptions = { + name: terminalName, + cwd: this.workspace.workspaceFolder.uri.fsPath, + }; + + const terminal = vscode.window.createTerminal(terminalOptions); + + if (!terminal) { + throw new Error( + "Failed to create terminal - createTerminal returned null/undefined", + ); + } + + return terminal; + } + + private get terminalName(): string { + return this.replType === "rails" + ? "Rails Console (Direct)" + : "Ruby REPL (IRB, Direct)"; + } + + private sendReplCommandSafely(replCommand: string): void { + try { + const terminal = this.getTerminalSafely(); + terminal.sendText(replCommand); + this.wasSuccessfullyStarted = true; + } catch (error) { + this.handleReplCommandFailure(error); + } + } + + private handleReplCommandFailure(error: unknown): void { + if (this.terminal) { + this.terminal.dispose(); + this.terminal = undefined; + } + throw new Error( + `Failed to send REPL command: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + private ensureTerminalExists(): void { + if (!this.terminal) { + throw new Error("REPL is not running"); + } + } + + private verifyTerminalIsActive(): void { + const terminal = this.getTerminalSafely(); + if (!vscode.window.terminals.includes(terminal)) { + this.terminal = undefined; + throw new Error("REPL terminal was closed"); + } + } + + private sendCodeToTerminal(code: string): void { + try { + const terminal = this.getTerminalSafely(); + terminal.sendText(code); + } catch (error) { + this.terminal = undefined; + throw new Error(`Failed to execute code in REPL: ${error}`); + } + } + + private isTerminalActive(): boolean { + if (!this.terminal) { + return false; + } + return vscode.window.terminals.includes(this.terminal); + } + + private clearTerminalReference(): void { + this.terminal = undefined; + } + + private sendInterruptSignal(): void { + if (!this.terminal) { + return; + } + + try { + this.terminal.sendText("\x03", false); + } catch (error) { + this.terminal = undefined; + } + } + + private getTerminalSafely(): vscode.Terminal { + if (!this.terminal) { + throw new Error("Terminal is not available"); + } + return this.terminal; + } + + private disposeTerminalCloseListener(): void { + if (this.terminalCloseListener) { + this.terminalCloseListener.dispose(); + this.terminalCloseListener = undefined; + } + } + + private disposeTerminal(): void { + if (this.terminal) { + this.terminal.dispose(); + this.terminal = undefined; + } + } + + private setupTerminalCloseListener(): void { + this.disposeTerminalCloseListener(); + this.terminalCloseListener = vscode.window.onDidCloseTerminal( + this.handleTerminalClosed.bind(this), + ); + } + + private handleTerminalClosed(closedTerminal: vscode.Terminal): void { + if (this.isOurTerminal(closedTerminal)) { + this.cleanupAfterTerminalClosed(); + this.notifyTerminalClosed(); + this.showTerminalClosedMessageIfAppropriate(); + } + } + + private isOurTerminal(closedTerminal: vscode.Terminal): boolean { + return closedTerminal === this.terminal; + } + + private cleanupAfterTerminalClosed(): void { + this.terminal = undefined; + this.disposeTerminalCloseListener(); + } + + private notifyTerminalClosed(): void { + if (this.onDidCloseCallback) { + this.onDidCloseCallback(); + } + } + + private showTerminalClosedMessageIfAppropriate(): void { + if (this.wasSuccessfullyStarted) { + const replName = + this.replType === "rails" ? "Rails Console" : "Ruby REPL"; + vscode.window.showInformationMessage(`${replName} has been closed`); + } + } + + private async shouldUseBundleExec(): Promise { + try { + await vscode.workspace.fs.stat( + vscode.Uri.joinPath(this.workspace.workspaceFolder.uri, "Gemfile"), + ); + return true; + } catch { + return false; + } + } + + private async buildReplCommand(): Promise { + const useBundle = await this.shouldUseBundleExec(); + + if (this.replType === "rails") { + return useBundle ? "bundle exec rails console" : "rails console"; + } else { + const irbOptions = "--colorize --autocomplete"; + return useBundle ? `bundle exec irb ${irbOptions}` : `irb ${irbOptions}`; + } + } +} diff --git a/vscode/src/test/suite/commands/execInReplCommandHandler.test.ts b/vscode/src/test/suite/commands/execInReplCommandHandler.test.ts new file mode 100644 index 000000000..a572657ea --- /dev/null +++ b/vscode/src/test/suite/commands/execInReplCommandHandler.test.ts @@ -0,0 +1,181 @@ +import * as assert from "assert"; + +import * as vscode from "vscode"; +import * as sinon from "sinon"; + +import { ExecInReplCommandHandler } from "../../../commands/execInReplCommandHandler"; +import { TerminalRepl } from "../../../terminalRepl"; +import { Workspace } from "../../../workspace"; +import { Command } from "../../../common"; + +suite("ExecInReplCommandHandler", () => { + let sandbox: sinon.SinonSandbox; + let handler: ExecInReplCommandHandler; + let mockWorkspace: Workspace; + let mockTerminalRepl: TerminalRepl; + let currentActiveWorkspaceStub: sinon.SinonStub; + let getTerminalReplStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + + mockWorkspace = { + workspaceFolder: { + uri: vscode.Uri.file("/test/workspace"), + }, + } as any; + + mockTerminalRepl = { + isRunning: true, + execute: sandbox.stub().resolves(), + } as any; + + currentActiveWorkspaceStub = sandbox.stub(); + getTerminalReplStub = sandbox.stub(); + + handler = new ExecInReplCommandHandler( + currentActiveWorkspaceStub, + getTerminalReplStub, + ); + + // Mock VS Code APIs + sandbox.stub(vscode.window, "showWarningMessage"); + sandbox.stub(vscode.window, "showInformationMessage"); + sandbox.stub(vscode.window, "showErrorMessage"); + sandbox.stub(vscode.commands, "executeCommand"); + }); + + teardown(() => { + sandbox.restore(); + }); + + test("executes selected text when editor has selection", async () => { + const mockEditor = { + selection: { + isEmpty: false, + }, + document: { + getText: sandbox.stub().returns("puts 'Hello, World!'"), + }, + } as any; + + sandbox.stub(vscode.window, "activeTextEditor").value(mockEditor); + currentActiveWorkspaceStub.returns(mockWorkspace); + getTerminalReplStub.returns(mockTerminalRepl); + + await handler.execute(); + + assert.ok( + (mockTerminalRepl.execute as sinon.SinonStub).calledWith( + "puts 'Hello, World!'", + ), + ); + }); + + test("executes current line when editor has no selection", async () => { + const mockLine = { + text: "puts 'Current line'", + }; + + const mockEditor = { + selection: { + isEmpty: true, + active: { line: 5 }, + }, + document: { + lineAt: sandbox.stub().returns(mockLine), + }, + } as any; + + sandbox.stub(vscode.window, "activeTextEditor").value(mockEditor); + currentActiveWorkspaceStub.returns(mockWorkspace); + getTerminalReplStub.returns(mockTerminalRepl); + + await handler.execute(); + + assert.ok( + (mockTerminalRepl.execute as sinon.SinonStub).calledWith( + "puts 'Current line'", + ), + ); + }); + + test("prompts to start REPL when no REPL is running", async () => { + const mockEditor = { + selection: { isEmpty: true, active: { line: 0 } }, + document: { lineAt: () => ({ text: "test code" }) }, + } as any; + + sandbox.stub(vscode.window, "activeTextEditor").value(mockEditor); + currentActiveWorkspaceStub.returns(mockWorkspace); + getTerminalReplStub.returns(undefined); + + const showInfoStub = vscode.window + .showInformationMessage as sinon.SinonStub; + showInfoStub.resolves("Start REPL"); + + await handler.execute(); + + assert.ok( + showInfoStub.calledWith( + "No REPL is running for this workspace. Would you like to start one?", + "Start REPL", + ), + ); + assert.ok( + (vscode.commands.executeCommand as sinon.SinonStub).calledWith( + Command.StartRepl, + ), + ); + }); + + test("handles execution errors gracefully", async () => { + const mockEditor = { + selection: { isEmpty: true, active: { line: 0 } }, + document: { lineAt: () => ({ text: "invalid code" }) }, + } as any; + + const failingRepl = { + isRunning: true, + execute: sandbox.stub().rejects(new Error("Syntax error")), + } as any; + + sandbox.stub(vscode.window, "activeTextEditor").value(mockEditor); + currentActiveWorkspaceStub.returns(mockWorkspace); + getTerminalReplStub.returns(failingRepl); + + await handler.execute(); + + assert.ok( + (vscode.window.showErrorMessage as sinon.SinonStub).calledWith( + "Failed to execute in REPL: Syntax error", + ), + ); + }); + + test("shows warning when no active editor", async () => { + sandbox.stub(vscode.window, "activeTextEditor").value(undefined); + + await handler.execute(); + + assert.ok( + (vscode.window.showWarningMessage as sinon.SinonStub).calledWith( + "No active editor found", + ), + ); + }); + + test("shows warning when no workspace found", async () => { + const mockEditor = {} as any; + sandbox.stub(vscode.window, "activeTextEditor").value(mockEditor); + currentActiveWorkspaceStub.returns(undefined); + + await handler.execute(); + + assert.ok( + (vscode.window.showWarningMessage as sinon.SinonStub).calledWith( + "No workspace found for current file", + ), + ); + }); +}); diff --git a/vscode/src/test/suite/commands/executeScratchPadLineCommandHandler.test.ts b/vscode/src/test/suite/commands/executeScratchPadLineCommandHandler.test.ts new file mode 100644 index 000000000..f26e42eb0 --- /dev/null +++ b/vscode/src/test/suite/commands/executeScratchPadLineCommandHandler.test.ts @@ -0,0 +1,287 @@ +import * as assert from "assert"; + +import * as vscode from "vscode"; +import * as sinon from "sinon"; + +import { ExecuteScratchPadLineCommandHandler } from "../../../commands/executeScratchPadLineCommandHandler"; +import { ReplScratchPad } from "../../../replScratchPad"; +import { TerminalRepl } from "../../../terminalRepl"; +import { Workspace } from "../../../workspace"; +import { Command } from "../../../common"; + +suite("ExecuteScratchPadLineCommandHandler", () => { + let sandbox: sinon.SinonSandbox; + let handler: ExecuteScratchPadLineCommandHandler; + let mockWorkspace: Workspace; + let mockScratchPad: ReplScratchPad; + let mockEditor: vscode.TextEditor; + let mockTerminalRepl: TerminalRepl; + // Using any to allow property modification + let mockDocument: any; + let currentActiveWorkspaceStub: sinon.SinonStub; + let getScratchPadStub: sinon.SinonStub; + let getTerminalReplStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + + mockWorkspace = { + workspaceFolder: { + uri: vscode.Uri.file("/test/workspace"), + }, + } as any; + + mockTerminalRepl = { + execute: sandbox.stub().resolves(), + } as any; + + mockScratchPad = { + getCurrentLineCode: sandbox.stub().returns("puts 'hello'"), + showExecutionSuccess: sandbox.stub(), + showExecutionError: sandbox.stub(), + moveCursorToNextLine: sandbox.stub(), + } as any; + + mockDocument = { + isUntitled: true, + languageId: "ruby", + }; + + mockEditor = { + selection: { active: { line: 0 } }, + document: mockDocument, + } as any; + + currentActiveWorkspaceStub = sandbox.stub(); + getScratchPadStub = sandbox.stub(); + getTerminalReplStub = sandbox.stub().returns(mockTerminalRepl); + + handler = new ExecuteScratchPadLineCommandHandler( + currentActiveWorkspaceStub, + getScratchPadStub, + getTerminalReplStub, + ); + + // Mock VS Code APIs + sandbox.stub(vscode.window, "activeTextEditor").value(mockEditor); + sandbox.stub(vscode.window, "showWarningMessage"); + }); + + teardown(() => { + sandbox.restore(); + }); + + test("has correct command ID", () => { + assert.strictEqual(handler.commandId, Command.ExecuteScratchPadLine); + }); + + test("returns early when no active editor", async () => { + sandbox.stub(vscode.window, "activeTextEditor").value(undefined); + + await handler.execute(); + + assert.ok(currentActiveWorkspaceStub.notCalled); + assert.ok(getScratchPadStub.notCalled); + assert.ok(getTerminalReplStub.notCalled); + }); + + test("returns early when document is not a scratch pad", async () => { + mockDocument.isUntitled = false; + + await handler.execute(); + + assert.ok(currentActiveWorkspaceStub.notCalled); + assert.ok(getScratchPadStub.notCalled); + assert.ok(getTerminalReplStub.notCalled); + }); + + test("returns early when document language is not Ruby", async () => { + mockDocument.languageId = "javascript"; + + await handler.execute(); + + assert.ok(currentActiveWorkspaceStub.notCalled); + assert.ok(getScratchPadStub.notCalled); + assert.ok(getTerminalReplStub.notCalled); + }); + + test("returns early when no workspace found", async () => { + currentActiveWorkspaceStub.returns(undefined); + + await handler.execute(); + + assert.ok(currentActiveWorkspaceStub.calledOnceWith(mockEditor)); + assert.ok(getScratchPadStub.notCalled); + assert.ok(getTerminalReplStub.notCalled); + }); + + test("shows warning when no scratch pad found", async () => { + currentActiveWorkspaceStub.returns(mockWorkspace); + getScratchPadStub.returns(undefined); + + await handler.execute(); + + assert.ok(currentActiveWorkspaceStub.calledOnceWith(mockEditor)); + assert.ok( + getScratchPadStub.calledOnceWith( + mockWorkspace.workspaceFolder.uri.toString(), + ), + ); + assert.ok(getTerminalReplStub.notCalled); + assert.ok( + (vscode.window.showWarningMessage as sinon.SinonStub).calledWith( + "No scratch pad found for this workspace", + ), + ); + }); + + test("shows warning when no terminal REPL found", async () => { + currentActiveWorkspaceStub.returns(mockWorkspace); + getScratchPadStub.returns(mockScratchPad); + getTerminalReplStub.returns(undefined); + + await handler.execute(); + + assert.ok(currentActiveWorkspaceStub.calledOnceWith(mockEditor)); + assert.ok( + getScratchPadStub.calledOnceWith( + mockWorkspace.workspaceFolder.uri.toString(), + ), + ); + assert.ok( + getTerminalReplStub.calledOnceWith( + mockWorkspace.workspaceFolder.uri.toString(), + ), + ); + assert.ok( + (vscode.window.showWarningMessage as sinon.SinonStub).calledWith( + "No REPL found for this workspace", + ), + ); + }); + + test("moves cursor when code is not executable", async () => { + currentActiveWorkspaceStub.returns(mockWorkspace); + getScratchPadStub.returns(mockScratchPad); + (mockScratchPad.getCurrentLineCode as sinon.SinonStub).returns("# comment"); + + await handler.execute(); + + assert.ok( + (mockScratchPad.getCurrentLineCode as sinon.SinonStub).calledOnceWith( + mockEditor, + ), + ); + assert.ok((mockTerminalRepl.execute as sinon.SinonStub).notCalled); + assert.ok( + (mockScratchPad.showExecutionSuccess as sinon.SinonStub).notCalled, + ); + assert.ok( + (mockScratchPad.moveCursorToNextLine as sinon.SinonStub).calledOnceWith( + mockEditor, + ), + ); + }); + + test("successfully executes current line", async () => { + currentActiveWorkspaceStub.returns(mockWorkspace); + getScratchPadStub.returns(mockScratchPad); + + await handler.execute(); + + assert.ok(currentActiveWorkspaceStub.calledOnceWith(mockEditor)); + assert.ok( + getScratchPadStub.calledOnceWith( + mockWorkspace.workspaceFolder.uri.toString(), + ), + ); + assert.ok( + getTerminalReplStub.calledOnceWith( + mockWorkspace.workspaceFolder.uri.toString(), + ), + ); + assert.ok( + (mockScratchPad.getCurrentLineCode as sinon.SinonStub).calledOnceWith( + mockEditor, + ), + ); + assert.ok( + (mockTerminalRepl.execute as sinon.SinonStub).calledOnceWith( + "puts 'hello'", + ), + ); + assert.ok( + (mockScratchPad.showExecutionSuccess as sinon.SinonStub).calledOnce, + ); + assert.ok( + (mockScratchPad.moveCursorToNextLine as sinon.SinonStub).calledOnceWith( + mockEditor, + ), + ); + }); + + test("handles execution errors", async () => { + currentActiveWorkspaceStub.returns(mockWorkspace); + getScratchPadStub.returns(mockScratchPad); + (mockTerminalRepl.execute as sinon.SinonStub).rejects( + new Error("Execution failed"), + ); + + await handler.execute(); + + assert.ok( + (mockScratchPad.getCurrentLineCode as sinon.SinonStub).calledOnceWith( + mockEditor, + ), + ); + assert.ok( + (mockTerminalRepl.execute as sinon.SinonStub).calledOnceWith( + "puts 'hello'", + ), + ); + assert.ok( + (mockScratchPad.showExecutionError as sinon.SinonStub).calledOnce, + ); + assert.ok( + (mockScratchPad.moveCursorToNextLine as sinon.SinonStub).calledOnceWith( + mockEditor, + ), + ); + }); + + suite("Document validation", () => { + test("validates scratch pad document correctly - saved Ruby file", async () => { + mockDocument.isUntitled = false; + mockDocument.languageId = "ruby"; + + await handler.execute(); + + // Should return early because saved files are not scratch pads + assert.ok(currentActiveWorkspaceStub.notCalled); + }); + + test("validates scratch pad document correctly - untitled non-Ruby file", async () => { + mockDocument.isUntitled = true; + mockDocument.languageId = "typescript"; + + await handler.execute(); + + // Should return early because non-Ruby files are not scratch pads + assert.ok(currentActiveWorkspaceStub.notCalled); + }); + + test("validates scratch pad document correctly - valid scratch pad", async () => { + mockDocument.isUntitled = true; + mockDocument.languageId = "ruby"; + currentActiveWorkspaceStub.returns(mockWorkspace); + getScratchPadStub.returns(mockScratchPad); + + await handler.execute(); + + // Should proceed to execution + assert.ok(currentActiveWorkspaceStub.calledOnce); + assert.ok(getScratchPadStub.calledOnce); + assert.ok(getTerminalReplStub.calledOnce); + }); + }); +}); diff --git a/vscode/src/test/suite/commands/executeScratchPadSelectionCommandHandler.test.ts b/vscode/src/test/suite/commands/executeScratchPadSelectionCommandHandler.test.ts new file mode 100644 index 000000000..1b922bbf0 --- /dev/null +++ b/vscode/src/test/suite/commands/executeScratchPadSelectionCommandHandler.test.ts @@ -0,0 +1,284 @@ +import * as assert from "assert"; + +import * as vscode from "vscode"; +import * as sinon from "sinon"; + +import { ExecuteScratchPadSelectionCommandHandler } from "../../../commands/executeScratchPadSelectionCommandHandler"; +import { ReplScratchPad } from "../../../replScratchPad"; +import { TerminalRepl } from "../../../terminalRepl"; +import { Workspace } from "../../../workspace"; +import { Command } from "../../../common"; + +suite("ExecuteScratchPadSelectionCommandHandler", () => { + let sandbox: sinon.SinonSandbox; + let handler: ExecuteScratchPadSelectionCommandHandler; + let mockWorkspace: Workspace; + let mockScratchPad: ReplScratchPad; + let mockEditor: vscode.TextEditor; + let mockTerminalRepl: TerminalRepl; + // Using any to allow property modification + let mockDocument: any; + let currentActiveWorkspaceStub: sinon.SinonStub; + let getScratchPadStub: sinon.SinonStub; + let getTerminalReplStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + + mockWorkspace = { + workspaceFolder: { + uri: vscode.Uri.file("/test/workspace"), + }, + } as any; + + mockTerminalRepl = { + execute: sandbox.stub().resolves(), + } as any; + + mockScratchPad = { + getSelectionCode: sandbox.stub().returns({ + code: "puts 'selection'", + lineNumber: 1, + }), + showExecutionSuccess: sandbox.stub(), + showExecutionError: sandbox.stub(), + } as any; + + mockDocument = { + isUntitled: true, + languageId: "ruby", + }; + + mockEditor = { + selection: { active: { line: 0 }, isEmpty: false }, + document: mockDocument, + } as any; + + currentActiveWorkspaceStub = sandbox.stub(); + getScratchPadStub = sandbox.stub(); + getTerminalReplStub = sandbox.stub().returns(mockTerminalRepl); + + handler = new ExecuteScratchPadSelectionCommandHandler( + currentActiveWorkspaceStub, + getScratchPadStub, + getTerminalReplStub, + ); + + // Mock VS Code APIs + sandbox.stub(vscode.window, "activeTextEditor").value(mockEditor); + sandbox.stub(vscode.window, "showWarningMessage"); + }); + + teardown(() => { + sandbox.restore(); + }); + + test("has correct command ID", () => { + assert.strictEqual(handler.commandId, Command.ExecuteScratchPadSelection); + }); + + test("returns early when no active editor", async () => { + sandbox.stub(vscode.window, "activeTextEditor").value(undefined); + + await handler.execute(); + + assert.ok(currentActiveWorkspaceStub.notCalled); + assert.ok(getScratchPadStub.notCalled); + assert.ok(getTerminalReplStub.notCalled); + }); + + test("returns early when document is not a scratch pad", async () => { + mockDocument.isUntitled = false; + + await handler.execute(); + + assert.ok(currentActiveWorkspaceStub.notCalled); + assert.ok(getScratchPadStub.notCalled); + assert.ok(getTerminalReplStub.notCalled); + }); + + test("returns early when document language is not Ruby", async () => { + mockDocument.languageId = "javascript"; + + await handler.execute(); + + assert.ok(currentActiveWorkspaceStub.notCalled); + assert.ok(getScratchPadStub.notCalled); + assert.ok(getTerminalReplStub.notCalled); + }); + + test("returns early when no workspace found", async () => { + currentActiveWorkspaceStub.returns(undefined); + + await handler.execute(); + + assert.ok(currentActiveWorkspaceStub.calledOnceWith(mockEditor)); + assert.ok(getScratchPadStub.notCalled); + assert.ok(getTerminalReplStub.notCalled); + }); + + test("shows warning when no scratch pad found", async () => { + currentActiveWorkspaceStub.returns(mockWorkspace); + getScratchPadStub.returns(undefined); + + await handler.execute(); + + assert.ok(currentActiveWorkspaceStub.calledOnceWith(mockEditor)); + assert.ok( + getScratchPadStub.calledOnceWith( + mockWorkspace.workspaceFolder.uri.toString(), + ), + ); + assert.ok(getTerminalReplStub.notCalled); + assert.ok( + (vscode.window.showWarningMessage as sinon.SinonStub).calledWith( + "No scratch pad found for this workspace", + ), + ); + }); + + test("shows warning when no terminal REPL found", async () => { + currentActiveWorkspaceStub.returns(mockWorkspace); + getScratchPadStub.returns(mockScratchPad); + getTerminalReplStub.returns(undefined); + + await handler.execute(); + + assert.ok(currentActiveWorkspaceStub.calledOnceWith(mockEditor)); + assert.ok( + getScratchPadStub.calledOnceWith( + mockWorkspace.workspaceFolder.uri.toString(), + ), + ); + assert.ok( + getTerminalReplStub.calledOnceWith( + mockWorkspace.workspaceFolder.uri.toString(), + ), + ); + assert.ok( + (vscode.window.showWarningMessage as sinon.SinonStub).calledWith( + "No REPL found for this workspace", + ), + ); + }); + + test("returns early when code is not executable", async () => { + currentActiveWorkspaceStub.returns(mockWorkspace); + getScratchPadStub.returns(mockScratchPad); + (mockScratchPad.getSelectionCode as sinon.SinonStub).returns({ + code: "# comment", + lineNumber: 1, + }); + + await handler.execute(); + + assert.ok( + (mockScratchPad.getSelectionCode as sinon.SinonStub).calledOnceWith( + mockEditor, + ), + ); + assert.ok((mockTerminalRepl.execute as sinon.SinonStub).notCalled); + assert.ok( + (mockScratchPad.showExecutionSuccess as sinon.SinonStub).notCalled, + ); + }); + + test("successfully executes selection", async () => { + currentActiveWorkspaceStub.returns(mockWorkspace); + getScratchPadStub.returns(mockScratchPad); + + await handler.execute(); + + assert.ok(currentActiveWorkspaceStub.calledOnceWith(mockEditor)); + assert.ok( + getScratchPadStub.calledOnceWith( + mockWorkspace.workspaceFolder.uri.toString(), + ), + ); + assert.ok( + getTerminalReplStub.calledOnceWith( + mockWorkspace.workspaceFolder.uri.toString(), + ), + ); + assert.ok( + (mockScratchPad.getSelectionCode as sinon.SinonStub).calledOnceWith( + mockEditor, + ), + ); + assert.ok( + (mockTerminalRepl.execute as sinon.SinonStub).calledOnceWith( + "puts 'selection'", + ), + ); + assert.ok( + (mockScratchPad.showExecutionSuccess as sinon.SinonStub).calledOnceWith( + mockEditor, + 1, + ), + ); + }); + + test("handles execution errors", async () => { + currentActiveWorkspaceStub.returns(mockWorkspace); + getScratchPadStub.returns(mockScratchPad); + (mockTerminalRepl.execute as sinon.SinonStub).rejects( + new Error("Execution failed"), + ); + + await handler.execute(); + + assert.ok( + (mockScratchPad.getSelectionCode as sinon.SinonStub).calledOnceWith( + mockEditor, + ), + ); + assert.ok( + (mockTerminalRepl.execute as sinon.SinonStub).calledOnceWith( + "puts 'selection'", + ), + ); + assert.ok( + (mockScratchPad.showExecutionError as sinon.SinonStub).calledOnceWith( + mockEditor, + 1, + "Execution failed", + ), + ); + }); + + suite("Document validation", () => { + test("validates scratch pad document correctly - saved Ruby file", async () => { + mockDocument.isUntitled = false; + mockDocument.languageId = "ruby"; + + await handler.execute(); + + // Should return early because saved files are not scratch pads + assert.ok(currentActiveWorkspaceStub.notCalled); + }); + + test("validates scratch pad document correctly - untitled non-Ruby file", async () => { + mockDocument.isUntitled = true; + mockDocument.languageId = "typescript"; + + await handler.execute(); + + // Should return early because non-Ruby files are not scratch pads + assert.ok(currentActiveWorkspaceStub.notCalled); + }); + + test("validates scratch pad document correctly - valid scratch pad", async () => { + mockDocument.isUntitled = true; + mockDocument.languageId = "ruby"; + currentActiveWorkspaceStub.returns(mockWorkspace); + getScratchPadStub.returns(mockScratchPad); + + await handler.execute(); + + // Should proceed to execution + assert.ok(currentActiveWorkspaceStub.calledOnce); + assert.ok(getScratchPadStub.calledOnce); + assert.ok(getTerminalReplStub.calledOnce); + }); + }); +}); diff --git a/vscode/src/test/suite/commands/interruptReplCommandHandler.test.ts b/vscode/src/test/suite/commands/interruptReplCommandHandler.test.ts new file mode 100644 index 000000000..7f9883b23 --- /dev/null +++ b/vscode/src/test/suite/commands/interruptReplCommandHandler.test.ts @@ -0,0 +1,147 @@ +import * as assert from "assert"; + +import * as vscode from "vscode"; +import * as sinon from "sinon"; + +import { InterruptReplCommandHandler } from "../../../commands/interruptReplCommandHandler"; +import { TerminalRepl } from "../../../terminalRepl"; +import { Workspace } from "../../../workspace"; +import { Command } from "../../../common"; + +suite("InterruptReplCommandHandler", () => { + let sandbox: sinon.SinonSandbox; + let handler: InterruptReplCommandHandler; + let mockWorkspace: Workspace; + let mockTerminalRepl: TerminalRepl; + let showWorkspacePickStub: sinon.SinonStub; + let getTerminalReplStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + + mockWorkspace = { + workspaceFolder: { + uri: vscode.Uri.file("/test/workspace"), + }, + } as any; + + mockTerminalRepl = { + isRunning: true, + interrupt: sandbox.stub(), + } as any; + + showWorkspacePickStub = sandbox.stub(); + getTerminalReplStub = sandbox.stub(); + + handler = new InterruptReplCommandHandler( + showWorkspacePickStub, + getTerminalReplStub, + ); + + // Mock VS Code APIs + sandbox.stub(vscode.window, "showInformationMessage"); + }); + + teardown(() => { + sandbox.restore(); + }); + + test("has correct command ID", () => { + assert.strictEqual(handler.commandId, Command.InterruptRepl); + }); + + test("returns early when no workspace is selected", async () => { + showWorkspacePickStub.resolves(undefined); + + await handler.execute(); + + assert.ok(showWorkspacePickStub.calledOnce); + assert.ok(getTerminalReplStub.notCalled); + assert.ok( + (vscode.window.showInformationMessage as sinon.SinonStub).notCalled, + ); + }); + + test("shows message when no REPL is running", async () => { + showWorkspacePickStub.resolves(mockWorkspace); + getTerminalReplStub.returns(undefined); + + await handler.execute(); + + assert.ok(showWorkspacePickStub.calledOnce); + assert.ok( + getTerminalReplStub.calledWith( + mockWorkspace.workspaceFolder.uri.toString(), + ), + ); + assert.ok( + (vscode.window.showInformationMessage as sinon.SinonStub).calledWith( + "No REPL is running for this workspace", + ), + ); + }); + + test("shows message when REPL exists but is not running", async () => { + const notRunningRepl = { + isRunning: false, + interrupt: sandbox.stub(), + } as any; + + showWorkspacePickStub.resolves(mockWorkspace); + getTerminalReplStub.returns(notRunningRepl); + + await handler.execute(); + + assert.ok(showWorkspacePickStub.calledOnce); + assert.ok( + getTerminalReplStub.calledWith( + mockWorkspace.workspaceFolder.uri.toString(), + ), + ); + assert.ok(notRunningRepl.interrupt.notCalled); + assert.ok( + (vscode.window.showInformationMessage as sinon.SinonStub).calledWith( + "No REPL is running for this workspace", + ), + ); + }); + + test("successfully interrupts running REPL", async () => { + showWorkspacePickStub.resolves(mockWorkspace); + getTerminalReplStub.returns(mockTerminalRepl); + + await handler.execute(); + + assert.ok(showWorkspacePickStub.calledOnce); + assert.ok( + getTerminalReplStub.calledWith( + mockWorkspace.workspaceFolder.uri.toString(), + ), + ); + assert.ok((mockTerminalRepl.interrupt as sinon.SinonStub).calledOnce); + assert.ok( + (vscode.window.showInformationMessage as sinon.SinonStub).calledWith( + "REPL interrupted", + ), + ); + }); + + test("calls getTerminalRepl with correct workspace key", async () => { + const workspaceWithDifferentUri = { + workspaceFolder: { + uri: vscode.Uri.file("/different/workspace"), + }, + } as any; + + showWorkspacePickStub.resolves(workspaceWithDifferentUri); + getTerminalReplStub.returns(mockTerminalRepl); + + await handler.execute(); + + assert.ok( + getTerminalReplStub.calledWith( + workspaceWithDifferentUri.workspaceFolder.uri.toString(), + ), + ); + }); +}); diff --git a/vscode/src/test/suite/commands/startReplCommandHandler.test.ts b/vscode/src/test/suite/commands/startReplCommandHandler.test.ts new file mode 100644 index 000000000..493c0d104 --- /dev/null +++ b/vscode/src/test/suite/commands/startReplCommandHandler.test.ts @@ -0,0 +1,130 @@ +import * as assert from "assert"; + +import * as vscode from "vscode"; +import * as sinon from "sinon"; + +import { StartReplCommandHandler } from "../../../commands/startReplCommandHandler"; +import { Workspace } from "../../../workspace"; +import { Command } from "../../../common"; + +suite("StartReplCommandHandler", () => { + let sandbox: sinon.SinonSandbox; + let handler: StartReplCommandHandler; + let mockWorkspace: Workspace; + let showWorkspacePickStub: sinon.SinonStub; + let startReplStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + + mockWorkspace = { + workspaceFolder: { + uri: vscode.Uri.file("/test/workspace"), + }, + } as any; + + showWorkspacePickStub = sandbox.stub(); + startReplStub = sandbox.stub().resolves(); + + handler = new StartReplCommandHandler(showWorkspacePickStub, startReplStub); + + // Mock VS Code APIs + sandbox.stub(vscode.window, "showQuickPick"); + sandbox.stub(vscode.window, "showErrorMessage"); + }); + + teardown(() => { + sandbox.restore(); + }); + + test("has correct command ID", () => { + assert.strictEqual(handler.commandId, Command.StartRepl); + }); + + test("returns early when no workspace is selected", async () => { + showWorkspacePickStub.resolves(undefined); + + await handler.execute(); + + assert.ok(showWorkspacePickStub.calledOnce); + assert.ok(startReplStub.notCalled); + assert.ok((vscode.window.showQuickPick as sinon.SinonStub).notCalled); + }); + + test("returns early when no REPL type is selected", async () => { + showWorkspacePickStub.resolves(mockWorkspace); + (vscode.window.showQuickPick as sinon.SinonStub).resolves(undefined); + + await handler.execute(); + + assert.ok(showWorkspacePickStub.calledOnce); + assert.ok((vscode.window.showQuickPick as sinon.SinonStub).calledOnce); + assert.ok(startReplStub.notCalled); + }); + + test("successfully starts IRB REPL", async () => { + showWorkspacePickStub.resolves(mockWorkspace); + (vscode.window.showQuickPick as sinon.SinonStub).resolves("irb"); + + await handler.execute(); + + assert.ok(showWorkspacePickStub.calledOnce); + assert.ok((vscode.window.showQuickPick as sinon.SinonStub).calledOnce); + assert.ok(startReplStub.calledOnceWith(mockWorkspace, "irb")); + }); + + test("successfully starts Rails REPL", async () => { + showWorkspacePickStub.resolves(mockWorkspace); + (vscode.window.showQuickPick as sinon.SinonStub).resolves("rails"); + + await handler.execute(); + + assert.ok(showWorkspacePickStub.calledOnce); + assert.ok((vscode.window.showQuickPick as sinon.SinonStub).calledOnce); + assert.ok(startReplStub.calledOnceWith(mockWorkspace, "rails")); + }); + + test("shows correct quick pick options", async () => { + showWorkspacePickStub.resolves(mockWorkspace); + (vscode.window.showQuickPick as sinon.SinonStub).resolves("irb"); + + await handler.execute(); + + assert.ok( + (vscode.window.showQuickPick as sinon.SinonStub).calledWith( + ["irb", "rails"], + { placeHolder: "Select REPL type" }, + ), + ); + }); + + test("handles startRepl errors gracefully", async () => { + showWorkspacePickStub.resolves(mockWorkspace); + (vscode.window.showQuickPick as sinon.SinonStub).resolves("irb"); + startReplStub.rejects(new Error("Failed to start terminal")); + + await handler.execute(); + + assert.ok(startReplStub.calledOnce); + assert.ok( + (vscode.window.showErrorMessage as sinon.SinonStub).calledWith( + "Failed to start REPL: Failed to start terminal", + ), + ); + }); + + test("handles startRepl errors without error details", async () => { + showWorkspacePickStub.resolves(mockWorkspace); + (vscode.window.showQuickPick as sinon.SinonStub).resolves("rails"); + startReplStub.rejects(new Error("Generic error")); + + await handler.execute(); + + assert.ok(startReplStub.calledOnce); + assert.ok( + (vscode.window.showErrorMessage as sinon.SinonStub).calledWith( + "Failed to start REPL: Generic error", + ), + ); + }); +}); diff --git a/vscode/src/test/suite/replManager.test.ts b/vscode/src/test/suite/replManager.test.ts new file mode 100644 index 000000000..ff1c7c82c --- /dev/null +++ b/vscode/src/test/suite/replManager.test.ts @@ -0,0 +1,179 @@ +import * as assert from "assert"; + +import * as vscode from "vscode"; +import * as sinon from "sinon"; + +import { ReplManager } from "../../replManager"; +import { RubyLspTerminalProfileProvider } from "../../terminalProfileProvider"; +import { Workspace } from "../../workspace"; +import { Command } from "../../common"; + +suite("ReplManager", () => { + let sandbox: sinon.SinonSandbox; + let replManager: ReplManager; + let mockContext: vscode.ExtensionContext; + let mockWorkspace: Workspace; + let getWorkspacesStub: sinon.SinonStub; + let showWorkspacePickStub: sinon.SinonStub; + let currentActiveWorkspaceStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + + mockContext = { + subscriptions: [], + } as any; + + mockWorkspace = { + workspaceFolder: { + uri: vscode.Uri.file("/test/workspace"), + }, + } as any; + + getWorkspacesStub = sandbox.stub().returns([mockWorkspace]); + showWorkspacePickStub = sandbox.stub().resolves(mockWorkspace); + currentActiveWorkspaceStub = sandbox.stub().returns(mockWorkspace); + + sandbox.stub(RubyLspTerminalProfileProvider.prototype, "register"); + sandbox.stub(RubyLspTerminalProfileProvider.prototype, "updateWorkspaces"); + sandbox.stub(RubyLspTerminalProfileProvider.prototype, "dispose"); + + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: () => true, + } as any); + + sandbox.stub(vscode.commands, "registerCommand").returns({ + dispose: sandbox.stub(), + } as any); + + replManager = new ReplManager( + mockContext, + getWorkspacesStub, + showWorkspacePickStub, + currentActiveWorkspaceStub, + ); + }); + + teardown(() => { + sandbox.restore(); + if (replManager) { + replManager.dispose(); + } + }); + + test("register returns command disposables", () => { + const disposables = replManager.register(); + + assert.ok(Array.isArray(disposables)); + assert.ok(disposables.length > 0); + }); + + test("register calls terminal profile provider register", () => { + replManager.register(); + + assert.ok( + ( + RubyLspTerminalProfileProvider.prototype.register as sinon.SinonStub + ).calledWith(mockContext), + ); + }); + + test("updateWorkspaces delegates to terminal profile provider", async () => { + await replManager.updateWorkspaces(); + + assert.ok( + ( + RubyLspTerminalProfileProvider.prototype + .updateWorkspaces as sinon.SinonStub + ).calledWith([mockWorkspace]), + ); + }); + + test("dispose cleans up terminal profile provider", () => { + replManager.dispose(); + + assert.ok( + (RubyLspTerminalProfileProvider.prototype.dispose as sinon.SinonStub) + .calledOnce, + ); + }); + + test("registerRepl manages REPL lifecycle", () => { + const terminalRepls = (replManager as any).terminalRepls; + const mockRepl = { dispose: sandbox.stub() }; + + // Add an existing REPL + terminalRepls.set("workspace1", mockRepl); + + // Register a new REPL for the same workspace + const newRepl = { dispose: sandbox.stub() }; + (replManager as any).registerRepl("workspace1", newRepl); + + // Should dispose old REPL and register new one + assert.ok(mockRepl.dispose.calledOnce); + assert.strictEqual(terminalRepls.get("workspace1"), newRepl); + }); + + test("unregisterRepl cleans up resources", () => { + const terminalRepls = (replManager as any).terminalRepls; + const replScratchPads = (replManager as any).replScratchPads; + + const mockRepl = { dispose: sandbox.stub() }; + const mockScratchPad = { dispose: sandbox.stub() }; + + terminalRepls.set("workspace1", mockRepl); + replScratchPads.set("workspace1", mockScratchPad); + + (replManager as any).unregisterRepl("workspace1"); + + assert.ok(mockRepl.dispose.calledOnce); + assert.ok(mockScratchPad.dispose.calledOnce); + assert.ok(!terminalRepls.has("workspace1")); + assert.ok(!replScratchPads.has("workspace1")); + }); + + test("registerScratchPad manages scratch pad lifecycle", () => { + const replScratchPads = (replManager as any).replScratchPads; + const existingScratchPad = { dispose: sandbox.stub() }; + replScratchPads.set("workspace1", existingScratchPad); + + const newScratchPad = { dispose: sandbox.stub() }; + (replManager as any).registerScratchPad("workspace1", newScratchPad); + + assert.ok(existingScratchPad.dispose.calledOnce); + assert.strictEqual(replScratchPads.get("workspace1"), newScratchPad); + }); + + suite("Command registration", () => { + test("registers all expected commands", () => { + replManager.register(); + + const registerCommandStub = vscode.commands + .registerCommand as sinon.SinonStub; + const commandNames = registerCommandStub + .getCalls() + .map((call) => call.args[0]); + + assert.ok(commandNames.includes(Command.StartRepl)); + assert.ok(commandNames.includes(Command.ExecInRepl)); + assert.ok(commandNames.includes(Command.InterruptRepl)); + assert.ok(commandNames.includes(Command.ExecuteScratchPadLine)); + assert.ok(commandNames.includes(Command.ExecuteScratchPadSelection)); + }); + + test("each command has a callback function", () => { + replManager.register(); + + const registerCommandStub = vscode.commands + .registerCommand as sinon.SinonStub; + const calls = registerCommandStub.getCalls(); + + calls.forEach((call) => { + assert.ok( + typeof call.args[1] === "function", + `Command ${call.args[0]} should have a callback function`, + ); + }); + }); + }); +}); diff --git a/vscode/src/test/suite/replScratchPad.test.ts b/vscode/src/test/suite/replScratchPad.test.ts new file mode 100644 index 000000000..a0e18e9f2 --- /dev/null +++ b/vscode/src/test/suite/replScratchPad.test.ts @@ -0,0 +1,263 @@ +import * as assert from "assert"; + +import * as vscode from "vscode"; +import * as sinon from "sinon"; + +import { ReplScratchPad } from "../../replScratchPad"; + +suite("ReplScratchPad", () => { + let sandbox: sinon.SinonSandbox; + let scratchPad: ReplScratchPad; + let mockDocument: vscode.TextDocument; + let mockEditor: vscode.TextEditor; + let openTextDocumentStub: sinon.SinonStub; + let showTextDocumentStub: sinon.SinonStub; + let executeCommandStub: sinon.SinonStub; + let createTextEditorDecorationTypeStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + + mockDocument = { + lineAt: sandbox.stub(), + getText: sandbox.stub(), + lineCount: 10, + uri: vscode.Uri.file("/test/scratch.rb"), + isClosed: false, + } as any; + + mockEditor = { + document: mockDocument, + selection: new vscode.Selection(0, 0, 0, 0), + setDecorations: sandbox.stub(), + } as any; + + openTextDocumentStub = sandbox + .stub(vscode.workspace, "openTextDocument") + .resolves(mockDocument); + showTextDocumentStub = sandbox + .stub(vscode.window, "showTextDocument") + .resolves(mockEditor); + executeCommandStub = sandbox + .stub(vscode.commands, "executeCommand") + .resolves(); + createTextEditorDecorationTypeStub = sandbox + .stub(vscode.window, "createTextEditorDecorationType") + .returns({ + dispose: sandbox.stub(), + } as any); + + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: (key: string) => { + if (key === "executionFeedbackDuration") return 3000; + return undefined; + }, + } as any); + + const mockTab = { + input: { + uri: mockDocument.uri, + } as vscode.TabInputText, + }; + sandbox.stub(vscode.window, "tabGroups").value({ + all: [ + { + tabs: [mockTab], + }, + ], + close: sandbox.stub().resolves(), + }); + }); + + teardown(() => { + sandbox.restore(); + if (scratchPad) { + scratchPad.dispose(); + } + }); + + test("constructor initializes decoration types", () => { + scratchPad = new ReplScratchPad(); + + assert.ok(createTextEditorDecorationTypeStub.calledTwice); + }); + + test("show creates new document with initial content", async () => { + scratchPad = new ReplScratchPad(); + + await scratchPad.show(); + + assert.ok(openTextDocumentStub.calledOnce); + const openDocumentArgs = openTextDocumentStub.firstCall.args[0]; + assert.strictEqual(openDocumentArgs.language, "ruby"); + assert.ok(openDocumentArgs.content.includes("# Ruby REPL Scratch Pad")); + assert.ok(openDocumentArgs.content.includes("# Keyboard Shortcuts:")); + }); + + test("show displays document in side editor", async () => { + scratchPad = new ReplScratchPad(); + + await scratchPad.show(); + + assert.ok(showTextDocumentStub.calledOnce); + const showDocumentArgs = showTextDocumentStub.firstCall.args[1]; + assert.strictEqual(showDocumentArgs.viewColumn, vscode.ViewColumn.Beside); + assert.strictEqual(showDocumentArgs.preserveFocus, false); + }); + + test("show executes terminal focus commands", async () => { + scratchPad = new ReplScratchPad(); + + await scratchPad.show(); + + assert.ok(executeCommandStub.calledWith("workbench.action.terminal.focus")); + assert.ok( + executeCommandStub.calledWith("workbench.action.focusActiveEditorGroup"), + ); + }); + + test("getCurrentLineCode returns trimmed line text", () => { + scratchPad = new ReplScratchPad(); + + const mockLine = { + text: " puts 'hello' ", + lineNumber: 0, + }; + (mockDocument.lineAt as sinon.SinonStub).returns(mockLine); + mockEditor.selection = new vscode.Selection(0, 0, 0, 0); + + const code = scratchPad.getCurrentLineCode(mockEditor); + + assert.strictEqual(code, "puts 'hello'"); + assert.ok((mockDocument.lineAt as sinon.SinonStub).calledWith(0)); + }); + + test("getSelectionCode returns selected text when selection exists", () => { + scratchPad = new ReplScratchPad(); + + const selection = new vscode.Selection(0, 0, 1, 10); + mockEditor.selection = selection; + (mockDocument.getText as sinon.SinonStub).returns("puts 'selected'"); + + const result = scratchPad.getSelectionCode(mockEditor); + + assert.strictEqual(result.code, "puts 'selected'"); + assert.strictEqual(result.lineNumber, 1); + assert.ok((mockDocument.getText as sinon.SinonStub).calledWith(selection)); + }); + + test("getSelectionCode returns current line when no selection", () => { + scratchPad = new ReplScratchPad(); + + const mockLine = { + text: " puts 'current' ", + lineNumber: 2, + }; + (mockDocument.lineAt as sinon.SinonStub).returns(mockLine); + // Empty selection + mockEditor.selection = new vscode.Selection(2, 5, 2, 5); + + const result = scratchPad.getSelectionCode(mockEditor); + + assert.strictEqual(result.code, "puts 'current'"); + assert.strictEqual(result.lineNumber, 2); + }); + + test("showExecutionSuccess creates success decoration", () => { + scratchPad = new ReplScratchPad(); + + scratchPad.showExecutionSuccess(mockEditor, 0); + + // Verify success decoration was set + const setDecorationsCall = (mockEditor.setDecorations as sinon.SinonStub) + .getCalls() + .find( + (call) => + call.args[1].length > 0 && + call.args[1][0].renderOptions?.after?.contentText?.includes( + "✓ executed", + ), + ); + + assert.ok(setDecorationsCall, "Should set success decoration"); + }); + + test("showExecutionError creates error decoration", () => { + scratchPad = new ReplScratchPad(); + + scratchPad.showExecutionError(mockEditor, 0, "Test error"); + + // Verify error decoration was set + const setDecorationsCall = (mockEditor.setDecorations as sinon.SinonStub) + .getCalls() + .find( + (call) => + call.args[1].length > 0 && + call.args[1][0].renderOptions?.after?.contentText?.includes( + "✗ Test error", + ), + ); + + assert.ok(setDecorationsCall, "Should set error decoration"); + }); + + test("moveCursorToNextLine advances cursor position", () => { + scratchPad = new ReplScratchPad(); + mockEditor.selection = new vscode.Selection(2, 5, 2, 5); + + scratchPad.moveCursorToNextLine(mockEditor); + + assert.strictEqual(mockEditor.selection.active.line, 3); + assert.strictEqual(mockEditor.selection.active.character, 0); + assert.strictEqual(mockEditor.selection.anchor.line, 3); + assert.strictEqual(mockEditor.selection.anchor.character, 0); + }); + + test("moveCursorToNextLine stops at last line", () => { + scratchPad = new ReplScratchPad(); + // Last line (0-indexed) + mockEditor.selection = new vscode.Selection(9, 5, 9, 5); + + scratchPad.moveCursorToNextLine(mockEditor); + + assert.strictEqual(mockEditor.selection.active.line, 9); + assert.strictEqual(mockEditor.selection.active.character, 0); + }); + + test("closeScratchPad closes the document tab", async () => { + scratchPad = new ReplScratchPad(); + (scratchPad as any).document = mockDocument; + (scratchPad as any).editor = mockEditor; + + await scratchPad.closeScratchPad(); + + assert.ok(vscode.window.tabGroups.close); + }); + + test("closeScratchPad clears document and editor references", async () => { + scratchPad = new ReplScratchPad(); + (scratchPad as any).document = mockDocument; + (scratchPad as any).editor = mockEditor; + + await scratchPad.closeScratchPad(); + + assert.strictEqual((scratchPad as any).document, undefined); + assert.strictEqual((scratchPad as any).editor, undefined); + }); + + test("dispose calls closeScratchPad and disposes decorations", async () => { + scratchPad = new ReplScratchPad(); + + const mockDecorationType = { dispose: sandbox.stub() }; + (scratchPad as any).decorationType = mockDecorationType; + (scratchPad as any).errorDecorationType = mockDecorationType; + + // Spy on closeScratchPad + const closeScratchPadSpy = sandbox.spy(scratchPad, "closeScratchPad"); + + scratchPad.dispose(); + + assert.ok(closeScratchPadSpy.called); + assert.ok(mockDecorationType.dispose.calledTwice); + }); +}); diff --git a/vscode/src/test/suite/terminalProfileProvider.test.ts b/vscode/src/test/suite/terminalProfileProvider.test.ts new file mode 100644 index 000000000..9349424cc --- /dev/null +++ b/vscode/src/test/suite/terminalProfileProvider.test.ts @@ -0,0 +1,650 @@ +import * as assert from "assert"; +import path from "path"; + +import * as vscode from "vscode"; +import * as sinon from "sinon"; + +import { RubyLspTerminalProfileProvider } from "../../terminalProfileProvider"; +import { TerminalRepl } from "../../terminalRepl"; +import { ReplScratchPad } from "../../replScratchPad"; +import { Workspace } from "../../workspace"; + +suite("RubyLspTerminalProfileProvider", () => { + let sandbox: sinon.SinonSandbox; + let provider: RubyLspTerminalProfileProvider; + let mockWorkspace: Workspace; + let mockContext: vscode.ExtensionContext; + let registerReplCallback: sinon.SinonStub; + let unregisterReplCallback: sinon.SinonStub; + let registerScratchPadCallback: sinon.SinonStub; + let registerCommandStub: sinon.SinonStub; + let registerTerminalProfileProviderStub: sinon.SinonStub; + let onDidOpenTerminalStub: sinon.SinonStub; + let getConfigurationStub: sinon.SinonStub; + let getWorkspaceFolderStub: sinon.SinonStub; + + setup(() => { + sandbox = sinon.createSandbox(); + + mockWorkspace = { + workspaceFolder: { + uri: vscode.Uri.file(path.join("test", "workspace")), + }, + ruby: { + env: { TEST_ENV: "test" }, + }, + } as any; + + mockContext = { + subscriptions: [], + } as any; + + registerReplCallback = sandbox.stub(); + unregisterReplCallback = sandbox.stub(); + registerScratchPadCallback = sandbox.stub(); + + registerCommandStub = sandbox + .stub(vscode.commands, "registerCommand") + .returns({ + dispose: sandbox.stub(), + } as any); + + registerTerminalProfileProviderStub = sandbox + .stub(vscode.window, "registerTerminalProfileProvider") + .returns({ + dispose: sandbox.stub(), + } as any); + + onDidOpenTerminalStub = sandbox + .stub(vscode.window, "onDidOpenTerminal") + .returns({ + dispose: sandbox.stub(), + } as any); + + getConfigurationStub = sandbox + .stub(vscode.workspace, "getConfiguration") + .returns({ + get: (key: string) => { + if (key === "autoOpenScratchPad") return true; + if (key === "showWelcomeMessage") return true; + return undefined; + }, + } as any); + + sandbox.stub(vscode.window, "showErrorMessage"); + sandbox.stub(vscode.window, "showWarningMessage"); + sandbox.stub(vscode.window, "showInformationMessage"); + sandbox.stub(vscode.window, "activeTextEditor").value(undefined); + + getWorkspaceFolderStub = sandbox + .stub(vscode.workspace, "getWorkspaceFolder") + .returns(undefined); + }); + + teardown(() => { + sandbox.restore(); + if (provider) { + provider.dispose(); + } + }); + + test("constructor initializes with workspaces and callbacks", () => { + provider = new RubyLspTerminalProfileProvider( + [mockWorkspace], + registerReplCallback, + unregisterReplCallback, + registerScratchPadCallback, + ); + + assert.strictEqual((provider as any).workspaces.length, 1); + assert.strictEqual( + (provider as any).registerReplCallback, + registerReplCallback, + ); + assert.strictEqual( + (provider as any).unregisterReplCallback, + unregisterReplCallback, + ); + assert.strictEqual( + (provider as any).registerScratchPadCallback, + registerScratchPadCallback, + ); + }); + + test("constructor initializes without callbacks", () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + assert.strictEqual((provider as any).workspaces.length, 1); + assert.strictEqual((provider as any).registerReplCallback, undefined); + }); + + test("updateWorkspaces updates the workspaces array", async () => { + provider = new RubyLspTerminalProfileProvider([]); + assert.strictEqual((provider as any).workspaces.length, 0); + + await provider.updateWorkspaces([mockWorkspace]); + assert.strictEqual((provider as any).workspaces.length, 1); + }); + + test("setCallbacks updates the callback functions", () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + provider.setCallbacks( + registerReplCallback, + unregisterReplCallback, + registerScratchPadCallback, + ); + + assert.strictEqual( + (provider as any).registerReplCallback, + registerReplCallback, + ); + assert.strictEqual( + (provider as any).unregisterReplCallback, + unregisterReplCallback, + ); + assert.strictEqual( + (provider as any).registerScratchPadCallback, + registerScratchPadCallback, + ); + }); + + test("register registers commands and terminal profile providers", () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + provider.register(mockContext); + + // Verify commands are registered + assert.ok(registerCommandStub.calledWith("rubyLsp.createIrbTerminal")); + assert.ok( + registerCommandStub.calledWith("rubyLsp.createRailsConsoleTerminal"), + ); + + // Verify terminal profile providers are registered + assert.ok( + registerTerminalProfileProviderStub.calledWith("rubyLsp.irbTerminal"), + ); + assert.ok( + registerTerminalProfileProviderStub.calledWith( + "rubyLsp.railsConsoleTerminal", + ), + ); + + // Verify terminal open event listener is registered + assert.ok(onDidOpenTerminalStub.called); + }); + + test("register only registers once", () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + provider.register(mockContext); + registerCommandStub.resetHistory(); + registerTerminalProfileProviderStub.resetHistory(); + + provider.register(mockContext); + + assert.ok(registerCommandStub.notCalled); + assert.ok(registerTerminalProfileProviderStub.notCalled); + }); + + test("dispose cleans up profile providers", () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + const mockDisposable = { dispose: sandbox.stub() }; + (provider as any).irbProfileDisposable = mockDisposable; + (provider as any).railsProfileDisposable = mockDisposable; + + provider.dispose(); + + assert.ok(mockDisposable.dispose.calledTwice); + assert.strictEqual((provider as any).irbProfileDisposable, undefined); + assert.strictEqual((provider as any).railsProfileDisposable, undefined); + }); + + test("createIrbTerminal creates IRB terminal with workspace", async () => { + provider = new RubyLspTerminalProfileProvider( + [mockWorkspace], + registerReplCallback, + unregisterReplCallback, + registerScratchPadCallback, + ); + + sandbox.stub(TerminalRepl.prototype, "start").resolves(); + sandbox.stub(TerminalRepl.prototype, "onDidClose"); + + sandbox.stub(ReplScratchPad.prototype, "show").resolves(); + + await (provider as any).createIrbTerminal(); + + assert.ok(registerReplCallback.called); + }); + + test("createIrbTerminal shows error when no workspace found", async () => { + provider = new RubyLspTerminalProfileProvider([]); + + await (provider as any).createIrbTerminal(); + + assert.ok( + (vscode.window.showErrorMessage as sinon.SinonStub).calledWith( + "No Ruby workspace found", + ), + ); + }); + + test("createRailsConsoleTerminal creates Rails console in Rails project", async () => { + provider = new RubyLspTerminalProfileProvider( + [mockWorkspace], + registerReplCallback, + unregisterReplCallback, + registerScratchPadCallback, + ); + + // Mock Rails project detection + sandbox.stub(provider as any, "isRailsProject").resolves(true); + + // Mock TerminalRepl + sandbox.stub(TerminalRepl.prototype, "start").resolves(); + sandbox.stub(TerminalRepl.prototype, "onDidClose"); + + // Mock ReplScratchPad + sandbox.stub(ReplScratchPad.prototype, "show").resolves(); + + await (provider as any).createRailsConsoleTerminal(); + + assert.ok(registerReplCallback.called); + }); + + test("createRailsConsoleTerminal shows warning in non-Rails project", async () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + // Mock non-Rails project + sandbox.stub(provider as any, "isRailsProject").resolves(false); + + await (provider as any).createRailsConsoleTerminal(); + + assert.ok( + (vscode.window.showWarningMessage as sinon.SinonStub).calledWith( + "Rails Console is only available in Rails projects. Use Ruby REPL instead.", + ), + ); + }); + + test("provideIrbTerminalProfile returns IRB profile", async () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + // Mock bundle exec detection + sandbox.stub(provider as any, "shouldUseBundleExec").resolves(false); + + const token = {} as vscode.CancellationToken; + const profile = await (provider as any).provideIrbTerminalProfile(token); + + assert.ok(profile); + assert.strictEqual(profile.options.name, "Ruby REPL (IRB)"); + assert.strictEqual( + profile.options.cwd, + mockWorkspace.workspaceFolder.uri.fsPath, + ); + assert.ok( + profile.options.shellArgs[1].includes("irb --colorize --autocomplete"), + ); + }); + + test("provideIrbTerminalProfile uses bundle exec when Gemfile exists", async () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + // Mock bundle exec detection + sandbox.stub(provider as any, "shouldUseBundleExec").resolves(true); + + const token = {} as vscode.CancellationToken; + const profile = await (provider as any).provideIrbTerminalProfile(token); + + assert.ok( + profile.options.shellArgs[1].includes( + "bundle exec irb --colorize --autocomplete", + ), + ); + }); + + test("provideRailsConsoleTerminalProfile returns Rails profile for Rails project", async () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + // Mock Rails project detection + sandbox.stub(provider as any, "isRailsProject").resolves(true); + sandbox.stub(provider as any, "shouldUseBundleExec").resolves(true); + + const token = {} as vscode.CancellationToken; + const profile = await (provider as any).provideRailsConsoleTerminalProfile( + token, + ); + + assert.ok(profile); + assert.strictEqual(profile.options.name, "Rails Console"); + assert.ok( + profile.options.shellArgs[1].includes("bundle exec rails console"), + ); + }); + + test("provideRailsConsoleTerminalProfile returns help message for non-Rails project", async () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + // Mock non-Rails project + sandbox.stub(provider as any, "isRailsProject").resolves(false); + + const token = {} as vscode.CancellationToken; + const profile = await (provider as any).provideRailsConsoleTerminalProfile( + token, + ); + + assert.ok(profile); + assert.strictEqual(profile.options.name, "Rails Console"); + assert.ok( + profile.options.shellArgs[1].includes("Rails Console is not available"), + ); + }); + + test("handleTerminalOpened wraps Ruby REPL terminal", async () => { + provider = new RubyLspTerminalProfileProvider( + [mockWorkspace], + registerReplCallback, + unregisterReplCallback, + ); + + const mockTerminal = { name: "Ruby REPL (IRB)" } as vscode.Terminal; + + // Mock wrapTerminalAsRepl + const wrapSpy = sandbox + .stub(provider as any, "wrapTerminalAsRepl") + .resolves(); + + await (provider as any).handleTerminalOpened(mockTerminal); + + assert.ok(wrapSpy.calledWith(mockTerminal, "irb")); + }); + + test("handleTerminalOpened wraps Rails Console terminal", async () => { + provider = new RubyLspTerminalProfileProvider( + [mockWorkspace], + registerReplCallback, + unregisterReplCallback, + ); + + const mockTerminal = { name: "Rails Console" } as vscode.Terminal; + + // Mock wrapTerminalAsRepl + const wrapSpy = sandbox + .stub(provider as any, "wrapTerminalAsRepl") + .resolves(); + + await (provider as any).handleTerminalOpened(mockTerminal); + + assert.ok(wrapSpy.calledWith(mockTerminal, "rails")); + }); + + test("handleTerminalOpened ignores other terminals", async () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + const mockTerminal = { name: "Regular Terminal" } as vscode.Terminal; + + // Mock wrapTerminalAsRepl + const wrapSpy = sandbox + .stub(provider as any, "wrapTerminalAsRepl") + .resolves(); + + await (provider as any).handleTerminalOpened(mockTerminal); + + assert.ok(wrapSpy.notCalled); + }); + + test("getActiveWorkspace returns single workspace", () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + const activeWorkspace = (provider as any).getActiveWorkspace(); + + assert.strictEqual(activeWorkspace, mockWorkspace); + }); + + test("getActiveWorkspace returns workspace for active editor", () => { + const secondWorkspace = { + workspaceFolder: { + uri: vscode.Uri.file("/test/workspace2"), + }, + } as any; + + provider = new RubyLspTerminalProfileProvider([ + mockWorkspace, + secondWorkspace, + ]); + + // Mock active editor + const mockEditor = { + document: { + uri: vscode.Uri.file("/test/workspace2/file.rb"), + }, + }; + sandbox.stub(vscode.window, "activeTextEditor").value(mockEditor); + getWorkspaceFolderStub.returns(secondWorkspace.workspaceFolder); + + const activeWorkspace = (provider as any).getActiveWorkspace(); + + assert.strictEqual(activeWorkspace, secondWorkspace); + }); + + test("getActiveWorkspace falls back to first workspace", () => { + const secondWorkspace = { + workspaceFolder: { + uri: vscode.Uri.file("/test/workspace2"), + }, + } as any; + + provider = new RubyLspTerminalProfileProvider([ + mockWorkspace, + secondWorkspace, + ]); + + const activeWorkspace = (provider as any).getActiveWorkspace(); + + assert.strictEqual(activeWorkspace, mockWorkspace); + }); + + test("isRailsProject returns true for Rails project with config/application.rb", async () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + // Mock config/application.rb exists using sandbox.replaceGetter + const originalFs = vscode.workspace.fs; + const mockFs = { + ...originalFs, + stat: sinon.stub().onFirstCall().resolves(), + }; + sandbox.replaceGetter(vscode.workspace, "fs", () => mockFs as any); + + const isRails = await (provider as any).isRailsProject(mockWorkspace); + + assert.strictEqual(isRails, true); + }); + + test("isRailsProject returns true for project with rails in Gemfile", async () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + // Mock config/application.rb doesn't exist, but Gemfile has rails + const originalFs = vscode.workspace.fs; + const statStub = sinon.stub(); + const readFileStub = sinon.stub(); + + // config/application.rb doesn't exist + statStub.onFirstCall().rejects(); + // config/environment.rb doesn't exist + statStub.onSecondCall().rejects(); + // Gemfile exists + statStub.onThirdCall().resolves(); + + readFileStub.resolves(new TextEncoder().encode('gem "rails"')); + + const mockFs = { + ...originalFs, + stat: statStub, + readFile: readFileStub, + }; + sandbox.replaceGetter(vscode.workspace, "fs", () => mockFs as any); + + const isRails = await (provider as any).isRailsProject(mockWorkspace); + + assert.strictEqual(isRails, true); + }); + + test("isRailsProject returns false for non-Rails project", async () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + // Mock no Rails indicators found + const originalFs = vscode.workspace.fs; + const mockFs = { + ...originalFs, + stat: sinon.stub().rejects(), + }; + sandbox.replaceGetter(vscode.workspace, "fs", () => mockFs as any); + + const isRails = await (provider as any).isRailsProject(mockWorkspace); + + assert.strictEqual(isRails, false); + }); + + test("shouldUseBundleExec returns true when Gemfile exists", async () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + // Mock Gemfile exists + const originalFs = vscode.workspace.fs; + const mockFs = { + ...originalFs, + stat: sinon.stub().resolves(), + }; + sandbox.replaceGetter(vscode.workspace, "fs", () => mockFs as any); + + const shouldUse = await (provider as any).shouldUseBundleExec( + mockWorkspace, + ); + + assert.strictEqual(shouldUse, true); + }); + + test("shouldUseBundleExec returns false when Gemfile doesn't exist", async () => { + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + // Mock Gemfile doesn't exist + const originalFs = vscode.workspace.fs; + const mockFs = { + ...originalFs, + stat: sinon.stub().rejects(), + }; + sandbox.replaceGetter(vscode.workspace, "fs", () => mockFs as any); + + const shouldUse = await (provider as any).shouldUseBundleExec( + mockWorkspace, + ); + + assert.strictEqual(shouldUse, false); + }); + + test("autoOpenScratchPad reads configuration", () => { + getConfigurationStub.returns({ + get: (key: string) => { + if (key === "autoOpenScratchPad") return false; + return undefined; + }, + } as any); + + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + const autoOpen = (provider as any).autoOpenScratchPad; + + assert.strictEqual(autoOpen, false); + assert.ok(getConfigurationStub.calledWith("rubyLsp.replSettings")); + }); + + test("showWelcomeMessage reads configuration", () => { + getConfigurationStub.returns({ + get: (key: string) => { + if (key === "showWelcomeMessage") return false; + return undefined; + }, + } as any); + + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + const showMessage = (provider as any).showWelcomeMessage; + + assert.strictEqual(showMessage, false); + assert.ok(getConfigurationStub.calledWith("rubyLsp.replSettings")); + }); + + test("createAndShowScratchPad creates scratch pad when autoOpenScratchPad is true", async () => { + provider = new RubyLspTerminalProfileProvider( + [mockWorkspace], + registerReplCallback, + unregisterReplCallback, + registerScratchPadCallback, + ); + + // Create mock terminal REPL instance + const mockTerminalRepl = {} as TerminalRepl; + + // Mock ReplScratchPad show method + const showSpy = sandbox.stub(ReplScratchPad.prototype, "show").resolves(); + + // Call createAndShowScratchPad method + await (provider as any).createAndShowScratchPad( + mockTerminalRepl, + "workspace1", + "irb", + ); + + // Verify scratch pad was shown and callbacks were called + assert.ok(showSpy.called); + assert.ok(registerScratchPadCallback.called); + + // Verify welcome message was shown + assert.ok( + (vscode.window.showInformationMessage as sinon.SinonStub).calledWith( + "Ruby REPL (IRB) started with scratch pad. Use Ctrl+Enter to execute code!", + ), + ); + }); + + test("createAndShowScratchPad shows message when autoOpenScratchPad is false", async () => { + // Mock autoOpenScratchPad to be false by restoring and recreating sandbox + sandbox.restore(); + + // Create new sandbox for this test + sandbox = sinon.createSandbox(); + + // Configure mocks for autoOpenScratchPad disabled + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: (key: string) => { + if (key === "autoOpenScratchPad") return false; + if (key === "showWelcomeMessage") return true; + return undefined; + }, + } as any); + + // Mock showInformationMessage + sandbox.stub(vscode.window, "showInformationMessage"); + + // Create provider instance with mocked configuration + provider = new RubyLspTerminalProfileProvider([mockWorkspace]); + + // Create mock terminal REPL instance + const mockTerminalRepl = {} as TerminalRepl; + + // Call createAndShowScratchPad method + await (provider as any).createAndShowScratchPad( + mockTerminalRepl, + "workspace1", + "irb", + ); + + // Verify success message is shown + assert.ok( + (vscode.window.showInformationMessage as sinon.SinonStub).calledWith( + "Ruby REPL (IRB) started successfully", + ), + ); + }); +}); diff --git a/vscode/src/test/suite/terminalRepl.test.ts b/vscode/src/test/suite/terminalRepl.test.ts new file mode 100644 index 000000000..59f241981 --- /dev/null +++ b/vscode/src/test/suite/terminalRepl.test.ts @@ -0,0 +1,316 @@ +import * as assert from "assert"; + +import * as vscode from "vscode"; +import * as sinon from "sinon"; + +import { TerminalRepl } from "../../terminalRepl"; +import { Workspace } from "../../workspace"; + +suite("TerminalRepl", () => { + let sandbox: sinon.SinonSandbox; + let workspace: Workspace; + let terminalRepl: TerminalRepl; + let createTerminalStub: sinon.SinonStub; + let mockTerminal: vscode.Terminal; + + setup(() => { + sandbox = sinon.createSandbox(); + + // Mock workspace + workspace = { + workspaceFolder: { + uri: vscode.Uri.file("/test/workspace"), + }, + ruby: { + env: { TEST_ENV: "test" }, + }, + } as any; + + // Mock terminal + mockTerminal = { + show: sandbox.stub(), + sendText: sandbox.stub(), + dispose: sandbox.stub(), + } as any; + + // Stub vscode.window.createTerminal + createTerminalStub = sandbox + .stub(vscode.window, "createTerminal") + .returns(mockTerminal); + + // Stub vscode.window.terminals + sandbox.stub(vscode.window, "terminals").value([mockTerminal]); + + // Stub configuration for REPL settings + sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: (key: string) => { + if (key === "showWelcomeMessage") return true; + if (key === "executionFeedbackDuration") return 3000; + if (key === "autoOpenScratchPad") return true; + return undefined; + }, + } as any); + }); + + teardown(() => { + sandbox.restore(); + if (terminalRepl) { + terminalRepl.dispose(); + } + }); + + test("constructor initializes with IRB type", () => { + terminalRepl = new TerminalRepl(workspace, "irb"); + assert.strictEqual((terminalRepl as any).replType, "irb"); + assert.strictEqual((terminalRepl as any).workspace, workspace); + }); + + test("constructor initializes with Rails type", () => { + terminalRepl = new TerminalRepl(workspace, "rails"); + assert.strictEqual((terminalRepl as any).replType, "rails"); + assert.strictEqual((terminalRepl as any).workspace, workspace); + }); + + test("start creates terminal with correct options for IRB", async () => { + terminalRepl = new TerminalRepl(workspace, "irb"); + + // Mock shouldUseBundleExec to return false + sandbox.stub(terminalRepl as any, "shouldUseBundleExec").resolves(false); + + await terminalRepl.start(); + + assert.ok(createTerminalStub.calledOnce); + const terminalOptions = createTerminalStub.firstCall.args[0]; + assert.strictEqual(terminalOptions.name, "Ruby REPL (IRB, Direct)"); + assert.strictEqual( + terminalOptions.cwd, + workspace.workspaceFolder.uri.fsPath, + ); + assert.ok(mockTerminal.show); + + // Check that the enhanced IRB command with options is sent + const sendTextCalls = (mockTerminal.sendText as sinon.SinonStub).getCalls(); + const irbCommand = sendTextCalls.find((call) => + call.args[0].includes("irb --colorize --autocomplete"), + ); + assert.ok( + irbCommand, + "Should send IRB command with colorize and autocomplete options", + ); + }); + + test("start creates terminal with correct options for Rails", async () => { + terminalRepl = new TerminalRepl(workspace, "rails"); + + // Mock shouldUseBundleExec to return true + sandbox.stub(terminalRepl as any, "shouldUseBundleExec").resolves(true); + + await terminalRepl.start(); + + assert.ok(createTerminalStub.calledOnce); + const terminalOptions = createTerminalStub.firstCall.args[0]; + assert.strictEqual(terminalOptions.name, "Rails Console (Direct)"); + assert.ok(mockTerminal.show); + + // Check that the Rails console command is sent + const sendTextCalls = (mockTerminal.sendText as sinon.SinonStub).getCalls(); + const railsCommand = sendTextCalls.find((call) => + call.args[0].includes("bundle exec rails console"), + ); + assert.ok(railsCommand, "Should send Rails console command"); + }); + + test("execute sends code to terminal", async () => { + terminalRepl = new TerminalRepl(workspace, "irb"); + sandbox.stub(terminalRepl as any, "shouldUseBundleExec").resolves(false); + + await terminalRepl.start(); + await terminalRepl.execute("puts 'hello'"); + + assert.ok( + (mockTerminal.sendText as sinon.SinonStub).calledWith("puts 'hello'"), + ); + }); + + test("execute throws error when REPL not running", async () => { + terminalRepl = new TerminalRepl(workspace, "irb"); + + await assert.rejects( + async () => terminalRepl.execute("puts 'hello'"), + /REPL is not running/, + ); + }); + + test("interrupt sends Ctrl+C to terminal", async () => { + terminalRepl = new TerminalRepl(workspace, "irb"); + sandbox.stub(terminalRepl as any, "shouldUseBundleExec").resolves(false); + + await terminalRepl.start(); + terminalRepl.interrupt(); + + assert.ok( + (mockTerminal.sendText as sinon.SinonStub).calledWith("\x03", false), + ); + }); + + test("isRunning returns true when terminal exists", async () => { + terminalRepl = new TerminalRepl(workspace, "irb"); + sandbox.stub(terminalRepl as any, "shouldUseBundleExec").resolves(false); + + await terminalRepl.start(); + assert.strictEqual(terminalRepl.isRunning, true); + }); + + test("isRunning returns false when terminal doesn't exist", () => { + terminalRepl = new TerminalRepl(workspace, "irb"); + assert.strictEqual(terminalRepl.isRunning, false); + }); + + test("dispose cleans up terminal", async () => { + terminalRepl = new TerminalRepl(workspace, "irb"); + sandbox.stub(terminalRepl as any, "shouldUseBundleExec").resolves(false); + + await terminalRepl.start(); + terminalRepl.dispose(); + + assert.ok((mockTerminal.dispose as sinon.SinonStub).calledOnce); + assert.strictEqual((terminalRepl as any).terminal, undefined); + }); + + test("uses bundle exec when Gemfile exists", async () => { + terminalRepl = new TerminalRepl(workspace, "irb"); + + // Mock shouldUseBundleExec to return true + sandbox.stub(terminalRepl as any, "shouldUseBundleExec").resolves(true); + + await terminalRepl.start(); + + // Check that bundle exec is used with the enhanced options + const sendTextCalls = (mockTerminal.sendText as sinon.SinonStub).getCalls(); + const bundleExecCommand = sendTextCalls.find((call) => + call.args[0].includes("bundle exec irb --colorize --autocomplete"), + ); + assert.ok(bundleExecCommand, "Should use bundle exec with IRB options"); + }); + + test("doesn't use bundle exec when Gemfile doesn't exist", async () => { + terminalRepl = new TerminalRepl(workspace, "irb"); + + // Mock shouldUseBundleExec to return false + sandbox.stub(terminalRepl as any, "shouldUseBundleExec").resolves(false); + + await terminalRepl.start(); + + // Check that IRB is called without bundle exec but with options + const sendTextCalls = (mockTerminal.sendText as sinon.SinonStub).getCalls(); + const irbCommand = sendTextCalls.find( + (call) => call.args[0] === "irb --colorize --autocomplete", + ); + assert.ok(irbCommand, "Should call IRB directly with options"); + }); + + test("onDidClose callback is called when terminal is closed", async () => { + terminalRepl = new TerminalRepl(workspace, "irb"); + sandbox.stub(terminalRepl as any, "shouldUseBundleExec").resolves(false); + + // Set up callback + const closeCallback = sandbox.stub(); + terminalRepl.onDidClose(closeCallback); + + await terminalRepl.start(); + + // Get the onDidCloseTerminal listener that was registered + const onDidCloseTerminalStub = sandbox.stub( + vscode.window, + "onDidCloseTerminal", + ); + + // Manually trigger the setupTerminalCloseListener to register our stub + (terminalRepl as any).setupTerminalCloseListener(); + + // Get the callback that was registered + const registeredCallback = onDidCloseTerminalStub.firstCall.args[0]; + + // Simulate terminal close by calling the registered callback + registeredCallback(mockTerminal); + + // Verify callback was called + assert.ok(closeCallback.calledOnce); + + // Verify terminal reference was cleared + assert.strictEqual((terminalRepl as any).terminal, undefined); + }); + + test("terminal close listener is disposed when terminalRepl is disposed", async () => { + terminalRepl = new TerminalRepl(workspace, "irb"); + sandbox.stub(terminalRepl as any, "shouldUseBundleExec").resolves(false); + + await terminalRepl.start(); + + // Get reference to the listener + const listener = (terminalRepl as any).terminalCloseListener; + assert.ok(listener); + + // Dispose the terminalRepl + terminalRepl.dispose(); + + // Verify listener was disposed + assert.strictEqual((terminalRepl as any).terminalCloseListener, undefined); + }); + + test("shows information message when terminal is closed", async () => { + terminalRepl = new TerminalRepl(workspace, "irb"); + sandbox.stub(terminalRepl as any, "shouldUseBundleExec").resolves(false); + + // Stub showInformationMessage + const showInfoStub = sandbox.stub(vscode.window, "showInformationMessage"); + + await terminalRepl.start(); + + // Get the onDidCloseTerminal listener that was registered + const onDidCloseTerminalStub = sandbox.stub( + vscode.window, + "onDidCloseTerminal", + ); + + // Manually trigger the setupTerminalCloseListener to register our stub + (terminalRepl as any).setupTerminalCloseListener(); + + // Get the callback that was registered + const registeredCallback = onDidCloseTerminalStub.firstCall.args[0]; + + // Simulate terminal close + registeredCallback(mockTerminal); + + // Verify information message was shown + assert.ok(showInfoStub.calledWith("Ruby REPL has been closed")); + }); + + test("shows correct message for Rails console", async () => { + terminalRepl = new TerminalRepl(workspace, "rails"); + sandbox.stub(terminalRepl as any, "shouldUseBundleExec").resolves(true); + + // Stub showInformationMessage + const showInfoStub = sandbox.stub(vscode.window, "showInformationMessage"); + + await terminalRepl.start(); + + // Get the onDidCloseTerminal listener that was registered + const onDidCloseTerminalStub = sandbox.stub( + vscode.window, + "onDidCloseTerminal", + ); + + // Manually trigger the setupTerminalCloseListener to register our stub + (terminalRepl as any).setupTerminalCloseListener(); + + // Get the callback that was registered + const registeredCallback = onDidCloseTerminalStub.firstCall.args[0]; + + // Simulate terminal close + registeredCallback(mockTerminal); + + // Verify correct message for Rails + assert.ok(showInfoStub.calledWith("Rails Console has been closed")); + }); +});