diff --git a/package.json b/package.json index 3c4ac80f..1bdd4c8c 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "ruby-lsp", "displayName": "Ruby LSP", "description": "VS Code plugin for connecting with the Ruby LSP", - "version": "0.4.20", + "version": "0.5.2", "publisher": "Shopify", "repository": { "type": "git", diff --git a/src/client.ts b/src/client.ts index c8300ef6..c7a91881 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,7 +1,4 @@ import path from "path"; -import fs from "fs"; -import { promisify } from "util"; -import { exec } from "child_process"; import { performance as Perf } from "perf_hooks"; import * as vscode from "vscode"; @@ -14,310 +11,156 @@ import { Range, ExecutableOptions, ServerOptions, - DiagnosticPullOptions, MessageSignature, } from "vscode-languageclient/node"; +import { LOG_CHANNEL, LSP_NAME, ClientInterface } from "./common"; import { Telemetry, RequestEvent } from "./telemetry"; import { Ruby } from "./ruby"; -import { StatusItems, Command, ServerState, ClientInterface } from "./status"; -import { TestController } from "./testController"; -import { LOG_CHANNEL } from "./common"; - -const LSP_NAME = "Ruby LSP"; -const asyncExec = promisify(exec); interface EnabledFeatures { [key: string]: boolean; } -type SyntaxTreeResponse = { ast: string } | null; - -export default class Client implements ClientInterface { - private client: LanguageClient | undefined; - private readonly workingFolder: string; - private readonly telemetry: Telemetry; - private readonly statusItems: StatusItems; - private readonly testController: TestController; - private readonly customBundleGemfile: string = vscode.workspace +// Get the executables to start the server based on the user's configuration +function getLspExecutables( + workspaceFolder: vscode.WorkspaceFolder, + env: NodeJS.ProcessEnv, +): ServerOptions { + let run: Executable; + let debug: Executable; + const branch: string = vscode.workspace + .getConfiguration("rubyLsp") + .get("branch")!; + const customBundleGemfile: string = vscode.workspace .getConfiguration("rubyLsp") .get("bundleGemfile")!; + const executableOptions: ExecutableOptions = { + cwd: workspaceFolder.uri.fsPath, + env, + shell: true, + }; + + // If there's a user defined custom bundle, we run the LSP with `bundle exec` and just trust the user configured + // their bundle. Otherwise, we run the global install of the LSP and use our custom bundle logic in the server + if (customBundleGemfile.length > 0) { + run = { + command: "bundle", + args: ["exec", "ruby-lsp"], + options: executableOptions, + }; + + debug = { + command: "bundle", + args: ["exec", "ruby-lsp", "--debug"], + options: executableOptions, + }; + } else { + const argsWithBranch = branch.length > 0 ? ["--branch", branch] : []; + + run = { + command: "ruby-lsp", + args: argsWithBranch, + options: executableOptions, + }; + + debug = { + command: "ruby-lsp", + args: argsWithBranch.concat(["--debug"]), + options: executableOptions, + }; + } + + return { run, debug }; +} + +function collectClientOptions( + configuration: vscode.WorkspaceConfiguration, + workspaceFolder: vscode.WorkspaceFolder, +): LanguageClientOptions { + const pullOn: "change" | "save" | "both" = + configuration.get("pullDiagnosticsOn")!; + + const diagnosticPullOptions = { + onChange: pullOn === "change" || pullOn === "both", + onSave: pullOn === "save" || pullOn === "both", + }; + + const features: EnabledFeatures = configuration.get("enabledFeatures")!; + const enabledFeatures = Object.keys(features).filter((key) => features[key]); + + return { + documentSelector: [ + { language: "ruby", pattern: `${workspaceFolder.uri.fsPath}/**/*` }, + ], + workspaceFolder, + diagnosticCollectionName: LSP_NAME, + outputChannel: LOG_CHANNEL, + revealOutputChannelOn: RevealOutputChannelOn.Never, + diagnosticPullOptions, + initializationOptions: { + enabledFeatures, + experimentalFeaturesEnabled: configuration.get( + "enableExperimentalFeatures", + ), + formatter: configuration.get("formatter"), + }, + }; +} + +export default class Client extends LanguageClient implements ClientInterface { + public readonly ruby: Ruby; + public serverVersion?: string; + private readonly workingDirectory: string; + private readonly telemetry: Telemetry; + private readonly createTestItems: (response: CodeLens[]) => void; private readonly baseFolder; private requestId = 0; #context: vscode.ExtensionContext; - #ruby: Ruby; - #state: ServerState = ServerState.Starting; #formatter: string; constructor( context: vscode.ExtensionContext, telemetry: Telemetry, ruby: Ruby, - testController: TestController, - workingFolder = vscode.workspace.workspaceFolders![0].uri.fsPath, + createTestItems: (response: CodeLens[]) => void, + workspaceFolder: vscode.WorkspaceFolder, ) { - this.workingFolder = workingFolder; - this.baseFolder = path.basename(this.workingFolder); - this.telemetry = telemetry; - this.testController = testController; - this.#context = context; - this.#ruby = ruby; - this.#formatter = ""; - this.statusItems = new StatusItems(this); - this.registerCommands(); - this.registerAutoRestarts(); - } - - async start() { - if (this.ruby.error) { - this.state = ServerState.Error; - return; - } - - try { - fs.accessSync(this.workingFolder, fs.constants.W_OK); - } catch (error: any) { - this.state = ServerState.Error; - - vscode.window.showErrorMessage( - `Directory ${this.workingFolder} is not writable. The Ruby LSP server needs to be able to create a .ruby-lsp - directory to function appropriately. Consider switching to a directory for which VS Code has write permissions`, - ); - - return; - } - - this.state = ServerState.Starting; - - try { - await this.installOrUpdateServer(); - } catch (error: any) { - this.state = ServerState.Error; - - // The progress dialog can't be closed by the user, so we have to guarantee that we catch errors - vscode.window.showErrorMessage( - `Failed to setup the bundle: ${error.message}. \ - See [Troubleshooting](https://github.com/Shopify/vscode-ruby-lsp#troubleshooting) for instructions`, - ); - - return; - } - - const configuration = vscode.workspace.getConfiguration("rubyLsp"); - const clientOptions: LanguageClientOptions = { - documentSelector: [{ language: "ruby" }], - diagnosticCollectionName: LSP_NAME, - outputChannel: LOG_CHANNEL, - revealOutputChannelOn: RevealOutputChannelOn.Never, - diagnosticPullOptions: this.diagnosticPullOptions(), - initializationOptions: { - enabledFeatures: this.listOfEnabledFeatures(), - experimentalFeaturesEnabled: configuration.get( - "enableExperimentalFeatures", - ), - formatter: configuration.get("formatter"), - }, - middleware: { - provideCodeLenses: async (document, token, next) => { - if (!this.client) { - return null; - } - - const response = await next(document, token); - - if (response) { - const testLenses = response.filter( - (codeLens) => (codeLens as CodeLens).data.type === "test", - ) as CodeLens[]; - - if (testLenses.length) { - this.testController.createTestItems(testLenses); - } - } - - return response; - }, - provideOnTypeFormattingEdits: async ( - document, - position, - ch, - options, - token, - _next, - ) => { - if (this.client) { - const response: vscode.TextEdit[] | null = - await this.client.sendRequest( - "textDocument/onTypeFormatting", - { - textDocument: { uri: document.uri.toString() }, - position, - ch, - options, - }, - token, - ); - - if (!response) { - return null; - } - - // Find the $0 anchor to move the cursor - const cursorPosition = response.find( - (edit) => edit.newText === "$0", - ); - - if (!cursorPosition) { - return response; - } - - // Remove the edit including the $0 anchor - response.splice(response.indexOf(cursorPosition), 1); - - const workspaceEdit = new vscode.WorkspaceEdit(); - workspaceEdit.set(document.uri, response); - - const editor = vscode.window.activeTextEditor!; - - // This should happen before applying the edits, otherwise the cursor will be moved to the wrong position - const existingText = editor.document.lineAt( - cursorPosition.range.start.line, - ).text; - - await vscode.workspace.applyEdit(workspaceEdit); - - const indentChar = vscode.window.activeTextEditor?.options - .insertSpaces - ? " " - : "\t"; - - // If the line is not empty, we don't want to indent the cursor - let indentationLength = 0; - - // If the line is empty or only contains whitespace, we want to indent the cursor to the requested position - if (/^\s*$/.exec(existingText)) { - indentationLength = cursorPosition.range.end.character; - } - - const indentation = indentChar.repeat(indentationLength); - - await vscode.window.activeTextEditor!.insertSnippet( - new vscode.SnippetString( - `${indentation}${cursorPosition.newText}`, - ), - new vscode.Selection( - cursorPosition.range.start, - cursorPosition.range.end, - ), - ); - - return null; - } - - return undefined; - }, - sendRequest: async ( - type: string | MessageSignature, - param: TP | undefined, - token: vscode.CancellationToken, - next: ( - type: string | MessageSignature, - param?: TP, - token?: vscode.CancellationToken, - ) => Promise, - ) => { - return this.benchmarkMiddleware(type, param, () => - next(type, param, token), - ); - }, - sendNotification: async ( - type: string | MessageSignature, - next: (type: string | MessageSignature, params?: TR) => Promise, - params: TR, - ) => { - return this.benchmarkMiddleware(type, params, () => - next(type, params), - ); - }, - }, - }; - - this.client = new LanguageClient( + super( LSP_NAME, - this.executables(), - clientOptions, + getLspExecutables(workspaceFolder, ruby.env), + collectClientOptions( + vscode.workspace.getConfiguration("rubyLsp"), + workspaceFolder, + ), ); - try { - await this.client.start(); - } catch (error: any) { - this.state = ServerState.Error; - LOG_CHANNEL.error(`Error restarting the server: ${error.message}`); - return; - } - - const initializeResult = this.client.initializeResult; - this.#formatter = initializeResult?.formatter; - this.telemetry.serverVersion = initializeResult?.serverInfo?.version; - this.state = ServerState.Running; - } - - async stop(): Promise { - if (this.client) { - await this.client.stop(); - this.state = ServerState.Stopped; - } - } + // Middleware are part of client options, but because they must reference `this`, we cannot make it a part of the + // `super` call (TypeScript does not allow accessing `this` before invoking `super`) + this.registerMiddleware(); - async restart() { - // If the server is already starting/restarting we should try to do it again. One scenario where that may happen is - // when doing git pull, which may trigger a restart for two watchers: .rubocop.yml and Gemfile.lock. In those cases, - // we only want to restart once and not twice to avoid leading to a duplicate process - if (this.state === ServerState.Starting) { - return; - } - - if (this.rebaseInProgress()) { - return; - } - - try { - this.state = ServerState.Starting; - - if (this.client?.isRunning()) { - await this.stop(); - await this.start(); - } else { - await this.start(); - } - } catch (error: any) { - this.state = ServerState.Error; - LOG_CHANNEL.error(`Error restarting the server: ${error.message}`); - } - } - - dispose() { - this.client?.dispose(); - } - - get ruby(): Ruby { - return this.#ruby; + this.workingDirectory = workspaceFolder.uri.fsPath; + this.baseFolder = path.basename(this.workingDirectory); + this.telemetry = telemetry; + this.createTestItems = createTestItems; + this.#context = context; + this.ruby = ruby; + this.#formatter = ""; } - private set ruby(ruby: Ruby) { - this.#ruby = ruby; + // Perform tasks that can only happen once the custom bundle logic from the server is finalized and the client is + // already running + performAfterStart() { + this.#formatter = this.initializeResult?.formatter; + this.serverVersion = this.initializeResult?.serverInfo?.version; } get formatter(): string { return this.#formatter; } - get serverVersion(): string | undefined { - return this.telemetry.serverVersion; - } - get context(): vscode.ExtensionContext { return this.#context; } @@ -326,256 +169,16 @@ export default class Client implements ClientInterface { this.#context = context; } - get state(): ServerState { - return this.#state; - } - - private set state(state: ServerState) { - this.#state = state; - this.statusItems.refresh(); - } - - private registerCommands() { - this.context.subscriptions.push( - vscode.commands.registerCommand(Command.Start, this.start.bind(this)), - vscode.commands.registerCommand(Command.Restart, this.restart.bind(this)), - vscode.commands.registerCommand(Command.Stop, this.stop.bind(this)), - vscode.commands.registerCommand( - Command.Update, - this.installOrUpdateServer.bind(this), - ), - vscode.commands.registerCommand( - Command.OpenLink, - this.openLink.bind(this), - ), - vscode.commands.registerCommand( - Command.ShowSyntaxTree, - this.showSyntaxTree.bind(this), - ), - ); - } - - private registerAutoRestarts() { - this.createRestartWatcher("Gemfile.lock"); - this.createRestartWatcher("gems.locked"); - this.createRestartWatcher("**/.rubocop.yml"); - - // If a configuration that affects the Ruby LSP has changed, update the client options using the latest - // configuration and restart the server - vscode.workspace.onDidChangeConfiguration(async (event) => { - if (event.affectsConfiguration("rubyLsp")) { - // Re-activate Ruby if the version manager changed - if (event.affectsConfiguration("rubyLsp.rubyVersionManager")) { - await this.ruby.activateRuby(); - } - - await this.restart(); - } + async sendShowSyntaxTreeRequest( + uri: vscode.Uri, + range?: Range, + ): Promise<{ ast: string } | null> { + return this.sendRequest("rubyLsp/textDocument/showSyntaxTree", { + textDocument: { uri: uri.toString() }, + range, }); } - private createRestartWatcher(pattern: string) { - const watcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(this.workingFolder, pattern), - ); - this.context.subscriptions.push(watcher); - - watcher.onDidChange(this.restart.bind(this)); - watcher.onDidCreate(this.restart.bind(this)); - watcher.onDidDelete(this.restart.bind(this)); - } - - private listOfEnabledFeatures(): string[] { - const configuration = vscode.workspace.getConfiguration("rubyLsp"); - const features: EnabledFeatures = configuration.get("enabledFeatures")!; - - return Object.keys(features).filter((key) => features[key]); - } - - private async installOrUpdateServer(): Promise { - // If there's a user configured custom bundle to run the LSP, then we do not perform auto-updates and let the user - // manage that custom bundle themselves - if (this.hasUserDefinedCustomBundle()) { - return; - } - - const oneDayInMs = 24 * 60 * 60 * 1000; - const lastUpdatedAt: number | undefined = this.context.workspaceState.get( - "rubyLsp.lastGemUpdate", - ); - - const { stderr } = await asyncExec("gem list ruby-lsp 1>&2", { - cwd: this.workingFolder, - env: this.ruby.env, - }); - - // If the gem is not yet installed, install it - if (!stderr.includes("ruby-lsp")) { - await asyncExec("gem install ruby-lsp", { - cwd: this.workingFolder, - env: this.ruby.env, - }); - - this.context.workspaceState.update("rubyLsp.lastGemUpdate", Date.now()); - return; - } - - // If we haven't updated the gem in the last 24 hours, update it - if ( - lastUpdatedAt === undefined || - Date.now() - lastUpdatedAt > oneDayInMs - ) { - try { - await asyncExec("gem update ruby-lsp", { - cwd: this.workingFolder, - env: this.ruby.env, - }); - this.context.workspaceState.update("rubyLsp.lastGemUpdate", Date.now()); - } catch (error) { - // If we fail to update the global installation of `ruby-lsp`, we don't want to prevent the server from starting - LOG_CHANNEL.error(`Failed to update global ruby-lsp gem: ${error}`); - } - } - } - - // If the `.git` folder exists and `.git/rebase-merge` or `.git/rebase-apply` exists, then we're in the middle of a - // rebase - private rebaseInProgress() { - const gitFolder = path.join(this.workingFolder, ".git"); - - return ( - fs.existsSync(gitFolder) && - (fs.existsSync(path.join(gitFolder, "rebase-merge")) || - fs.existsSync(path.join(gitFolder, "rebase-apply"))) - ); - } - - private async openLink(link: string) { - await this.telemetry.sendCodeLensEvent("link"); - vscode.env.openExternal(vscode.Uri.parse(link)); - } - - private async showSyntaxTree() { - const activeEditor = vscode.window.activeTextEditor; - - if (this.client && activeEditor) { - const document = activeEditor.document; - - if (document.languageId !== "ruby") { - vscode.window.showErrorMessage("Show syntax tree: not a Ruby file"); - return; - } - - const selection = activeEditor.selection; - let range: Range | undefined; - - // Anchor is the first point and active is the last point in the selection. If both are the same, nothing is - // selected - if (!selection.active.isEqual(selection.anchor)) { - // If you start selecting from below and go up, then the selection is reverted - if (selection.isReversed) { - range = Range.create( - selection.active.line, - selection.active.character, - selection.anchor.line, - selection.anchor.character, - ); - } else { - range = Range.create( - selection.anchor.line, - selection.anchor.character, - selection.active.line, - selection.active.character, - ); - } - } - - const response: SyntaxTreeResponse = await this.client.sendRequest( - "rubyLsp/textDocument/showSyntaxTree", - { - textDocument: { uri: activeEditor.document.uri.toString() }, - range, - }, - ); - - if (response) { - const document = await vscode.workspace.openTextDocument( - vscode.Uri.from({ - scheme: "ruby-lsp", - path: "show-syntax-tree", - query: response.ast, - }), - ); - - await vscode.window.showTextDocument(document, { - viewColumn: vscode.ViewColumn.Beside, - preserveFocus: true, - }); - } - } - } - - private executables(): ServerOptions { - let run: Executable; - let debug: Executable; - const branch: string = vscode.workspace - .getConfiguration("rubyLsp") - .get("branch")!; - - const executableOptions: ExecutableOptions = { - cwd: this.workingFolder, - env: this.ruby.env, - shell: true, - }; - - // If there's a user defined custom bundle, we run the LSP with `bundle exec` and just trust the user configured - // their bundle. Otherwise, we run the global install of the LSP and use our custom bundle logic in the server - if (this.hasUserDefinedCustomBundle()) { - run = { - command: "bundle", - args: ["exec", "ruby-lsp"], - options: executableOptions, - }; - - debug = { - command: "bundle", - args: ["exec", "ruby-lsp", "--debug"], - options: executableOptions, - }; - } else { - const argsWithBranch = branch.length > 0 ? ["--branch", branch] : []; - - run = { - command: "ruby-lsp", - args: argsWithBranch, - options: executableOptions, - }; - - debug = { - command: "ruby-lsp", - args: argsWithBranch.concat(["--debug"]), - options: executableOptions, - }; - } - - return { run, debug }; - } - - private hasUserDefinedCustomBundle(): boolean { - return this.customBundleGemfile.length > 0; - } - - private diagnosticPullOptions(): DiagnosticPullOptions { - const configuration = vscode.workspace.getConfiguration("rubyLsp"); - const pullOn: "change" | "save" | "both" = - configuration.get("pullDiagnosticsOn")!; - - return { - onChange: pullOn === "change" || pullOn === "both", - onSave: pullOn === "save" || pullOn === "both", - }; - } - private async benchmarkMiddleware( type: string | MessageSignature, params: any, @@ -666,4 +269,115 @@ export default class Client implements ClientInterface { return result!; } + + // Register the middleware in the client options + private registerMiddleware() { + this.clientOptions.middleware = { + provideCodeLenses: async (document, token, next) => { + const response = await next(document, token); + + if (response) { + const testLenses = response.filter( + (codeLens) => (codeLens as CodeLens).data.type === "test", + ) as CodeLens[]; + + if (testLenses.length) { + this.createTestItems(testLenses); + } + } + + return response; + }, + provideOnTypeFormattingEdits: async ( + document, + position, + ch, + options, + token, + _next, + ) => { + const response: vscode.TextEdit[] | null = await this.sendRequest( + "textDocument/onTypeFormatting", + { + textDocument: { uri: document.uri.toString() }, + position, + ch, + options, + }, + token, + ); + + if (!response) { + return null; + } + + // Find the $0 anchor to move the cursor + const cursorPosition = response.find((edit) => edit.newText === "$0"); + + if (!cursorPosition) { + return response; + } + + // Remove the edit including the $0 anchor + response.splice(response.indexOf(cursorPosition), 1); + + const workspaceEdit = new vscode.WorkspaceEdit(); + workspaceEdit.set(document.uri, response); + + const editor = vscode.window.activeTextEditor!; + + // This should happen before applying the edits, otherwise the cursor will be moved to the wrong position + const existingText = editor.document.lineAt( + cursorPosition.range.start.line, + ).text; + + await vscode.workspace.applyEdit(workspaceEdit); + + const indentChar = vscode.window.activeTextEditor?.options.insertSpaces + ? " " + : "\t"; + + // If the line is not empty, we don't want to indent the cursor + let indentationLength = 0; + + // If the line is empty or only contains whitespace, we want to indent the cursor to the requested position + if (/^\s*$/.exec(existingText)) { + indentationLength = cursorPosition.range.end.character; + } + + const indentation = indentChar.repeat(indentationLength); + + await vscode.window.activeTextEditor!.insertSnippet( + new vscode.SnippetString(`${indentation}${cursorPosition.newText}`), + new vscode.Selection( + cursorPosition.range.start, + cursorPosition.range.end, + ), + ); + + return null; + }, + sendRequest: async ( + type: string | MessageSignature, + param: TP | undefined, + token: vscode.CancellationToken, + next: ( + type: string | MessageSignature, + param?: TP, + token?: vscode.CancellationToken, + ) => Promise, + ) => { + return this.benchmarkMiddleware(type, param, () => + next(type, param, token), + ); + }, + sendNotification: async ( + type: string | MessageSignature, + next: (type: string | MessageSignature, params?: TR) => Promise, + params: TR, + ) => { + return this.benchmarkMiddleware(type, params, () => next(type, params)); + }, + }; + } } diff --git a/src/common.ts b/src/common.ts index c0e4507d..eb968cb8 100644 --- a/src/common.ts +++ b/src/common.ts @@ -3,9 +3,53 @@ import { exec } from "child_process"; import { promisify } from "util"; import * as vscode from "vscode"; +import { State } from "vscode-languageclient"; + +export enum Command { + Start = "rubyLsp.start", + Stop = "rubyLsp.stop", + Restart = "rubyLsp.restart", + Update = "rubyLsp.update", + ToggleExperimentalFeatures = "rubyLsp.toggleExperimentalFeatures", + ServerOptions = "rubyLsp.serverOptions", + ToggleYjit = "rubyLsp.toggleYjit", + SelectVersionManager = "rubyLsp.selectRubyVersionManager", + ToggleFeatures = "rubyLsp.toggleFeatures", + FormatterHelp = "rubyLsp.formatterHelp", + RunTest = "rubyLsp.runTest", + RunTestInTerminal = "rubyLsp.runTestInTerminal", + DebugTest = "rubyLsp.debugTest", + OpenLink = "rubyLsp.openLink", + ShowSyntaxTree = "rubyLsp.showSyntaxTree", +} + +export interface RubyInterface { + error: boolean; + versionManager?: string; + rubyVersion?: string; + supportsYjit?: boolean; +} + +export interface ClientInterface { + state: State; + formatter: string; + serverVersion?: string; +} + +export interface WorkspaceInterface { + ruby: RubyInterface; + lspClient?: ClientInterface; + error: boolean; +} + +// Event emitter used to signal that the language status items need to be refreshed +export const STATUS_EMITTER = new vscode.EventEmitter< + WorkspaceInterface | undefined +>(); export const asyncExec = promisify(exec); -export const LOG_CHANNEL = vscode.window.createOutputChannel("Ruby LSP", { +export const LSP_NAME = "Ruby LSP"; +export const LOG_CHANNEL = vscode.window.createOutputChannel(LSP_NAME, { log: true, }); diff --git a/src/debugger.ts b/src/debugger.ts index 89c045a4..8da5b4c7 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -4,33 +4,28 @@ import { ChildProcessWithoutNullStreams, spawn } from "child_process"; import * as vscode from "vscode"; -import { Ruby } from "./ruby"; import { LOG_CHANNEL } from "./common"; +import { Workspace } from "./workspace"; export class Debugger implements vscode.DebugAdapterDescriptorFactory, vscode.DebugConfigurationProvider { - private readonly workingFolder: string; - private readonly ruby: Ruby; private debugProcess?: ChildProcessWithoutNullStreams; private readonly console = vscode.debug.activeDebugConsole; - private readonly subscriptions: vscode.Disposable[]; + private readonly currentActiveWorkspace: () => Workspace | undefined; constructor( context: vscode.ExtensionContext, - ruby: Ruby, - workingFolder = vscode.workspace.workspaceFolders![0].uri.fsPath, + currentActiveWorkspace: () => Workspace | undefined, ) { - this.ruby = ruby; - this.subscriptions = [ + this.currentActiveWorkspace = currentActiveWorkspace; + + context.subscriptions.push( vscode.debug.registerDebugConfigurationProvider("ruby_lsp", this), vscode.debug.registerDebugAdapterDescriptorFactory("ruby_lsp", this), - ]; - this.workingFolder = workingFolder; - - context.subscriptions.push(...this.subscriptions); + ); } // This is where we start the debuggee process. We currently support launching with the debugger or attaching to a @@ -89,26 +84,30 @@ export class Debugger debugConfiguration: vscode.DebugConfiguration, _token?: vscode.CancellationToken, ): vscode.ProviderResult { + const workspace = this.currentActiveWorkspace(); + + if (!workspace) { + throw new Error("Debugging requires a workspace folder to be opened"); + } + if (debugConfiguration.env) { // If the user has their own debug launch configurations, we still need to inject the Ruby environment debugConfiguration.env = Object.assign( debugConfiguration.env, - this.ruby.env, + workspace.ruby.env, ); } else { - debugConfiguration.env = this.ruby.env; + debugConfiguration.env = workspace.ruby.env; } - let customGemfilePath = path.join( - this.workingFolder, - ".ruby-lsp", - "Gemfile", - ); + const workspacePath = workspace.workspaceFolder.uri.fsPath; + + let customGemfilePath = path.join(workspacePath, ".ruby-lsp", "Gemfile"); if (fs.existsSync(customGemfilePath)) { debugConfiguration.env.BUNDLE_GEMFILE = customGemfilePath; } - customGemfilePath = path.join(this.workingFolder, ".ruby-lsp", "gems.rb"); + customGemfilePath = path.join(workspacePath, ".ruby-lsp", "gems.rb"); if (fs.existsSync(customGemfilePath)) { debugConfiguration.env.BUNDLE_GEMFILE = customGemfilePath; } @@ -116,12 +115,12 @@ export class Debugger return debugConfiguration; } + // If the extension is deactivating, we need to ensure the debug process is terminated or else it may continue running + // in the background dispose() { if (this.debugProcess) { this.debugProcess.kill("SIGTERM"); } - - this.subscriptions.forEach((subscription) => subscription.dispose()); } private attachDebuggee(): Promise { @@ -167,7 +166,15 @@ export class Debugger ): Promise { let initialMessage = ""; let initialized = false; - const sockPath = this.socketPath(); + + const workspace = this.currentActiveWorkspace(); + + if (!workspace) { + throw new Error("Debugging requires a workspace folder to be opened"); + } + + const cwd = workspace.workspaceFolder.uri.fsPath; + const sockPath = this.socketPath(cwd); const configuration = session.configuration; return new Promise((resolve, reject) => { @@ -181,14 +188,14 @@ export class Debugger configuration.program, ]; - LOG_CHANNEL.info(`Spawning debugger in directory ${this.workingFolder}`); + LOG_CHANNEL.info(`Spawning debugger in directory ${cwd}`); LOG_CHANNEL.info(` Command bundle ${args.join(" ")}`); LOG_CHANNEL.info(` Environment ${JSON.stringify(configuration.env)}`); this.debugProcess = spawn("bundle", args, { shell: true, env: configuration.env, - cwd: this.workingFolder, + cwd, }); this.debugProcess.stderr.on("data", (data) => { @@ -237,14 +244,14 @@ export class Debugger // Generate a socket path so that Ruby debug doesn't have to create one for us. This makes coordination easier since // we always know the path to the socket - private socketPath() { + private socketPath(cwd: string) { const socketsDir = path.join("/", "tmp", "ruby-lsp-debug-sockets"); if (!fs.existsSync(socketsDir)) { fs.mkdirSync(socketsDir); } let socketIndex = 0; - const prefix = `ruby-debug-${path.basename(this.workingFolder)}`; + const prefix = `ruby-debug-${path.basename(cwd)}`; const existingSockets = fs .readdirSync(socketsDir) .map((file) => file) diff --git a/src/extension.ts b/src/extension.ts index 2ff8af0d..2b737f08 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,51 +1,18 @@ import * as vscode from "vscode"; -import Client from "./client"; -import { Telemetry } from "./telemetry"; -import { Ruby } from "./ruby"; -import { Debugger } from "./debugger"; -import { TestController } from "./testController"; -import DocumentProvider from "./documentProvider"; +import { RubyLsp } from "./rubyLsp"; -let client: Client | undefined; -let debug: Debugger | undefined; -let testController: TestController | undefined; +let extension: RubyLsp; export async function activate(context: vscode.ExtensionContext) { - const ruby = new Ruby(context, vscode.workspace.workspaceFolders![0]); - await ruby.activateRuby(); - - const telemetry = new Telemetry(context); - await telemetry.sendConfigurationEvents(); - - testController = new TestController( - context, - vscode.workspace.workspaceFolders![0].uri.fsPath, - ruby, - telemetry, - ); - - client = new Client(context, telemetry, ruby, testController); - - await client.start(); - debug = new Debugger(context, ruby); + if (!vscode.workspace.workspaceFolders) { + return; + } - vscode.workspace.registerTextDocumentContentProvider( - "ruby-lsp", - new DocumentProvider(), - ); + extension = new RubyLsp(context); + await extension.activate(); } export async function deactivate(): Promise { - if (client) { - await client.stop(); - } - - if (testController) { - testController.dispose(); - } - - if (debug) { - debug.dispose(); - } + await extension.deactivate(); } diff --git a/src/ruby.ts b/src/ruby.ts index c4bd679b..0f6bdd3b 100644 --- a/src/ruby.ts +++ b/src/ruby.ts @@ -4,7 +4,7 @@ import os from "os"; import * as vscode from "vscode"; -import { asyncExec, pathExists, LOG_CHANNEL } from "./common"; +import { asyncExec, pathExists, LOG_CHANNEL, RubyInterface } from "./common"; export enum VersionManager { Asdf = "asdf", @@ -17,7 +17,7 @@ export enum VersionManager { Custom = "custom", } -export class Ruby { +export class Ruby implements RubyInterface { public rubyVersion?: string; public yjitEnabled?: boolean; public supportsYjit?: boolean; diff --git a/src/rubyLsp.ts b/src/rubyLsp.ts new file mode 100644 index 00000000..e476fc9e --- /dev/null +++ b/src/rubyLsp.ts @@ -0,0 +1,402 @@ +import path from "path"; + +import * as vscode from "vscode"; +import { Range } from "vscode-languageclient/node"; + +import { Telemetry } from "./telemetry"; +import DocumentProvider from "./documentProvider"; +import { Workspace } from "./workspace"; +import { Command, STATUS_EMITTER, pathExists } from "./common"; +import { VersionManager } from "./ruby"; +import { StatusItems } from "./status"; +import { TestController } from "./testController"; +import { Debugger } from "./debugger"; + +// 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 +// commands +export class RubyLsp { + private readonly workspaces: Map = new Map(); + private readonly telemetry: Telemetry; + private readonly context: vscode.ExtensionContext; + private readonly statusItems: StatusItems; + private readonly testController: TestController; + private readonly debug: Debugger; + + constructor(context: vscode.ExtensionContext) { + this.context = context; + this.telemetry = new Telemetry(context); + this.testController = new TestController( + context, + this.telemetry, + this.currentActiveWorkspace.bind(this), + ); + this.debug = new Debugger(context, this.currentActiveWorkspace.bind(this)); + this.registerCommands(context); + + this.statusItems = new StatusItems(); + context.subscriptions.push(this.statusItems, this.debug); + + // Switch the status items based on which workspace is currently active + vscode.window.onDidChangeActiveTextEditor((editor) => { + STATUS_EMITTER.fire(this.currentActiveWorkspace(editor)); + }); + + vscode.workspace.onDidChangeWorkspaceFolders(async (event) => { + // Stop the language server and dispose all removed workspaces + for (const workspaceFolder of event.removed) { + const workspace = this.getWorkspace(workspaceFolder.uri); + + if (workspace) { + await workspace.stop(); + await workspace.dispose(); + this.workspaces.delete(workspaceFolder.uri.toString()); + } + } + + // Create and activate new workspaces for the added folders + for (const workspaceFolder of event.added) { + await this.activateWorkspace(workspaceFolder); + } + }); + + // Lazily activate workspaces that do not contain a lockfile + vscode.workspace.onDidOpenTextDocument(async (document) => { + if (document.languageId !== "ruby") { + return; + } + + const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); + + if (!workspaceFolder) { + return; + } + + const workspace = this.getWorkspace(workspaceFolder.uri); + + // If the workspace entry doesn't exist, then we haven't activated the workspace yet + if (!workspace) { + await this.activateWorkspace(workspaceFolder); + } + }); + } + + // Activate the extension. This method should perform all actions necessary to start the extension, such as booting + // all language servers for each existing workspace + async activate() { + await this.telemetry.sendConfigurationEvents(); + + for (const workspaceFolder of vscode.workspace.workspaceFolders!) { + await this.activateWorkspace(workspaceFolder); + } + + this.context.subscriptions.push( + vscode.workspace.registerTextDocumentContentProvider( + "ruby-lsp", + new DocumentProvider(), + ), + ); + + STATUS_EMITTER.fire(this.currentActiveWorkspace()); + } + + // 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() { + for (const workspace of this.workspaces.values()) { + await workspace.stop(); + } + } + + private async activateWorkspace(workspaceFolder: vscode.WorkspaceFolder) { + const workspaceDir = workspaceFolder.uri.fsPath; + + // If one of the workspaces does not contain a lockfile, then we don't try to start a language server. If the user + // ends up opening a Ruby file inside that workspace, then we lazily activate the workspace. These need to match our + // `workspaceContains` activation events in package.json + if ( + !(await pathExists(path.join(workspaceDir, "Gemfile.lock"))) && + !(await pathExists(path.join(workspaceDir, "gems.locked"))) + ) { + return; + } + + const workspace = new Workspace( + this.context, + workspaceFolder, + this.telemetry, + this.testController.createTestItems.bind(this.testController), + ); + this.workspaces.set(workspaceFolder.uri.toString(), workspace); + + await workspace.start(); + this.context.subscriptions.push(workspace); + } + + // Registers all extension commands. Commands can only be registered once, so this happens in the constructor. For + // creating multiple instances in tests, the `RubyLsp` object should be disposed of after each test to prevent double + // command register errors + private registerCommands(context: vscode.ExtensionContext) { + context.subscriptions.push( + vscode.commands.registerCommand(Command.Update, async () => { + const workspace = await this.showWorkspacePick(); + await workspace?.installOrUpdateServer(); + }), + vscode.commands.registerCommand(Command.Start, async () => { + const workspace = await this.showWorkspacePick(); + await workspace?.start(); + }), + vscode.commands.registerCommand(Command.Restart, async () => { + const workspace = await this.showWorkspacePick(); + await workspace?.restart(); + }), + vscode.commands.registerCommand(Command.Stop, async () => { + const workspace = await this.showWorkspacePick(); + await workspace?.stop(); + }), + vscode.commands.registerCommand( + Command.OpenLink, + async (link: string) => { + vscode.env.openExternal(vscode.Uri.parse(link)); + + const workspace = this.currentActiveWorkspace(); + + if (workspace?.lspClient?.serverVersion) { + await this.telemetry.sendCodeLensEvent( + "link", + workspace.lspClient.serverVersion, + ); + } + }, + ), + vscode.commands.registerCommand( + Command.ShowSyntaxTree, + this.showSyntaxTree.bind(this), + ), + vscode.commands.registerCommand(Command.FormatterHelp, () => { + vscode.env.openExternal( + vscode.Uri.parse( + "https://github.com/Shopify/vscode-ruby-lsp#formatting", + ), + ); + }), + vscode.commands.registerCommand(Command.ToggleFeatures, async () => { + // Extract feature descriptions from our package.json + const enabledFeaturesProperties = + vscode.extensions.getExtension("Shopify.ruby-lsp")!.packageJSON + .contributes.configuration.properties["rubyLsp.enabledFeatures"] + .properties; + + const descriptions: { [key: string]: string } = {}; + Object.entries(enabledFeaturesProperties).forEach( + ([key, value]: [string, any]) => { + descriptions[key] = value.description; + }, + ); + + const configuration = vscode.workspace.getConfiguration("rubyLsp"); + const features: { [key: string]: boolean } = + configuration.get("enabledFeatures")!; + const allFeatures = Object.keys(features); + const options: vscode.QuickPickItem[] = allFeatures.map((label) => { + return { + label, + picked: features[label], + description: descriptions[label], + }; + }); + + const toggledFeatures = await vscode.window.showQuickPick(options, { + canPickMany: true, + placeHolder: "Select the features you would like to enable", + }); + + if (toggledFeatures !== undefined) { + // The `picked` property is only used to determine if the checkbox is checked initially. When we receive the + // response back from the QuickPick, we need to use inclusion to check if the feature was selected + allFeatures.forEach((feature) => { + features[feature] = toggledFeatures.some( + (selected) => selected.label === feature, + ); + }); + + await vscode.workspace + .getConfiguration("rubyLsp") + .update("enabledFeatures", features, true, true); + } + }), + vscode.commands.registerCommand(Command.ToggleYjit, () => { + const lspConfig = vscode.workspace.getConfiguration("rubyLsp"); + const yjitEnabled = lspConfig.get("yjit"); + lspConfig.update("yjit", !yjitEnabled, true, true); + + const workspace = this.currentActiveWorkspace(); + + if (workspace) { + STATUS_EMITTER.fire(workspace); + } + }), + vscode.commands.registerCommand( + Command.ToggleExperimentalFeatures, + async () => { + const lspConfig = vscode.workspace.getConfiguration("rubyLsp"); + const experimentalFeaturesEnabled = lspConfig.get( + "enableExperimentalFeatures", + ); + await lspConfig.update( + "enableExperimentalFeatures", + !experimentalFeaturesEnabled, + true, + true, + ); + + STATUS_EMITTER.fire(this.currentActiveWorkspace()); + }, + ), + vscode.commands.registerCommand( + Command.ServerOptions, + async (options: [{ label: string; description: string }]) => { + const result = await vscode.window.showQuickPick(options, { + placeHolder: "Select server action", + }); + + if (result !== undefined) + await vscode.commands.executeCommand(result.description); + }, + ), + vscode.commands.registerCommand( + Command.SelectVersionManager, + async () => { + const configuration = vscode.workspace.getConfiguration("rubyLsp"); + const options = Object.values(VersionManager); + const manager = await vscode.window.showQuickPick(options, { + placeHolder: `Current: ${configuration.get("rubyVersionManager")}`, + }); + + if (manager !== undefined) { + configuration.update("rubyVersionManager", manager, true, true); + } + }, + ), + vscode.commands.registerCommand( + Command.RunTest, + (_path, name, _command) => { + this.testController.runOnClick(name); + }, + ), + vscode.commands.registerCommand( + Command.RunTestInTerminal, + this.testController.runTestInTerminal.bind(this.testController), + ), + vscode.commands.registerCommand( + Command.DebugTest, + this.testController.debugTest.bind(this.testController), + ), + ); + } + + // Get the current active workspace based on which file is opened in the editor + private currentActiveWorkspace( + activeEditor = vscode.window.activeTextEditor, + ): Workspace | undefined { + if (!activeEditor) { + return; + } + + const document = activeEditor.document; + const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); + + if (!workspaceFolder) { + return; + } + + return this.getWorkspace(workspaceFolder.uri); + } + + private getWorkspace(uri: vscode.Uri): Workspace | undefined { + return this.workspaces.get(uri.toString()); + } + + // Displays a quick pick to select which workspace to perform an action on. For example, if multiple workspaces exist, + // then we need to know which workspace to restart the language server on + private async showWorkspacePick(): Promise { + if (this.workspaces.size === 1) { + return this.workspaces.values().next().value; + } + + const workspaceFolder = await vscode.window.showWorkspaceFolderPick(); + + if (!workspaceFolder) { + return; + } + + return this.getWorkspace(workspaceFolder.uri); + } + + // Show syntax tree command + private async showSyntaxTree() { + const activeEditor = vscode.window.activeTextEditor; + + if (activeEditor) { + const document = activeEditor.document; + + if (document.languageId !== "ruby") { + vscode.window.showErrorMessage("Show syntax tree: not a Ruby file"); + return; + } + + const workspaceFolder = vscode.workspace.getWorkspaceFolder(document.uri); + + if (!workspaceFolder) { + return; + } + + const workspace = this.getWorkspace(workspaceFolder.uri); + + const selection = activeEditor.selection; + let range: Range | undefined; + + // Anchor is the first point and active is the last point in the selection. If both are the same, nothing is + // selected + if (!selection.active.isEqual(selection.anchor)) { + // If you start selecting from below and go up, then the selection is reverted + if (selection.isReversed) { + range = Range.create( + selection.active.line, + selection.active.character, + selection.anchor.line, + selection.anchor.character, + ); + } else { + range = Range.create( + selection.anchor.line, + selection.anchor.character, + selection.active.line, + selection.active.character, + ); + } + } + + const response: { ast: string } | null | undefined = + await workspace?.lspClient?.sendShowSyntaxTreeRequest( + document.uri, + range, + ); + + if (response) { + const document = await vscode.workspace.openTextDocument( + vscode.Uri.from({ + scheme: "ruby-lsp", + path: "show-syntax-tree", + query: response.ast, + }), + ); + + await vscode.window.showTextDocument(document, { + viewColumn: vscode.ViewColumn.Beside, + preserveFocus: true, + }); + } + } + } +} diff --git a/src/status.ts b/src/status.ts index 0322a812..df144c04 100644 --- a/src/status.ts +++ b/src/status.ts @@ -1,32 +1,7 @@ import * as vscode from "vscode"; +import { State } from "vscode-languageclient"; -import { Ruby, VersionManager } from "./ruby"; - -export enum ServerState { - Starting = "Starting", - Running = "Running", - Stopped = "Stopped", - Error = "Error", -} - -// Lists every Command in the Ruby LSP -export enum Command { - Start = "rubyLsp.start", - Stop = "rubyLsp.stop", - Restart = "rubyLsp.restart", - Update = "rubyLsp.update", - ToggleExperimentalFeatures = "rubyLsp.toggleExperimentalFeatures", - ServerOptions = "rubyLsp.serverOptions", - ToggleYjit = "rubyLsp.toggleYjit", - SelectVersionManager = "rubyLsp.selectRubyVersionManager", - ToggleFeatures = "rubyLsp.toggleFeatures", - FormatterHelp = "rubyLsp.formatterHelp", - RunTest = "rubyLsp.runTest", - RunTestInTerminal = "rubyLsp.runTestInTerminal", - DebugTest = "rubyLsp.debugTest", - OpenLink = "rubyLsp.openLink", - ShowSyntaxTree = "rubyLsp.showSyntaxTree", -} +import { Command, STATUS_EMITTER, WorkspaceInterface } from "./common"; const STOPPED_SERVER_OPTIONS = [ { label: "Ruby LSP: Start", description: Command.Start }, @@ -38,31 +13,17 @@ const STARTED_SERVER_OPTIONS = [ { label: "Ruby LSP: Restart", description: Command.Restart }, ]; -export interface ClientInterface { - context: vscode.ExtensionContext; - ruby: Ruby; - state: ServerState; - formatter: string; - serverVersion: string | undefined; -} - export abstract class StatusItem { public item: vscode.LanguageStatusItem; - protected context: vscode.ExtensionContext; - protected client: ClientInterface; - constructor(id: string, client: ClientInterface) { + constructor(id: string) { this.item = vscode.languages.createLanguageStatusItem(id, { scheme: "file", language: "ruby", }); - this.context = client.context; - this.client = client; - this.registerCommand(); } - abstract refresh(): void; - abstract registerCommand(): void; + abstract refresh(workspace: WorkspaceInterface): void; dispose(): void { this.item.dispose(); @@ -70,56 +31,34 @@ export abstract class StatusItem { } export class RubyVersionStatus extends StatusItem { - constructor(client: ClientInterface) { - super("rubyVersion", client); + constructor() { + super("rubyVersion"); + this.item.name = "Ruby LSP Status"; this.item.command = { title: "Change version manager", command: Command.SelectVersionManager, }; - if (client.ruby.error) { - this.item.text = "Failed to activate Ruby"; - this.item.severity = vscode.LanguageStatusSeverity.Error; - } else { - this.item.text = `Using Ruby ${client.ruby.rubyVersion} with ${client.ruby.versionManager}`; - this.item.severity = vscode.LanguageStatusSeverity.Information; - } + this.item.text = "Activating Ruby environment"; + this.item.severity = vscode.LanguageStatusSeverity.Information; } - refresh(): void { - if (this.client.ruby.error) { + refresh(workspace: WorkspaceInterface): void { + if (workspace.ruby.error) { this.item.text = "Failed to activate Ruby"; this.item.severity = vscode.LanguageStatusSeverity.Error; } else { - this.item.text = `Using Ruby ${this.client.ruby.rubyVersion} with ${this.client.ruby.versionManager}`; + this.item.text = `Using Ruby ${workspace.ruby.rubyVersion} with ${workspace.ruby.versionManager}`; this.item.severity = vscode.LanguageStatusSeverity.Information; } } - - registerCommand(): void { - this.context.subscriptions.push( - vscode.commands.registerCommand( - Command.SelectVersionManager, - async () => { - const configuration = vscode.workspace.getConfiguration("rubyLsp"); - const options = Object.values(VersionManager); - const manager = await vscode.window.showQuickPick(options, { - placeHolder: `Current: ${configuration.get("rubyVersionManager")}`, - }); - - if (manager !== undefined) { - configuration.update("rubyVersionManager", manager, true, true); - } - }, - ), - ); - } } export class ServerStatus extends StatusItem { - constructor(client: ClientInterface) { - super("server", client); + constructor() { + super("server"); + this.item.name = "Ruby LSP Status"; this.item.text = "Ruby LSP: Starting"; this.item.severity = vscode.LanguageStatusSeverity.Information; @@ -130,57 +69,47 @@ export class ServerStatus extends StatusItem { }; } - refresh(): void { - switch (this.client.state) { - case ServerState.Running: { - this.item.text = this.client.serverVersion - ? `Ruby LSP v${this.client.serverVersion}: Running` + refresh(workspace: WorkspaceInterface): void { + if (workspace.error) { + this.item.text = "Ruby LSP: Error"; + this.item.command!.arguments = [STOPPED_SERVER_OPTIONS]; + this.item.severity = vscode.LanguageStatusSeverity.Error; + return; + } + + if (!workspace.lspClient) { + return; + } + + switch (workspace.lspClient.state) { + case State.Running: { + this.item.text = workspace.lspClient.serverVersion + ? `Ruby LSP v${workspace.lspClient.serverVersion}: Running` : "Ruby LSP: Running"; this.item.command!.arguments = [STARTED_SERVER_OPTIONS]; this.item.severity = vscode.LanguageStatusSeverity.Information; break; } - case ServerState.Starting: { + case State.Starting: { this.item.text = "Ruby LSP: Starting"; this.item.command!.arguments = [STARTED_SERVER_OPTIONS]; this.item.severity = vscode.LanguageStatusSeverity.Information; break; } - case ServerState.Stopped: { + case State.Stopped: { this.item.text = "Ruby LSP: Stopped"; this.item.command!.arguments = [STOPPED_SERVER_OPTIONS]; this.item.severity = vscode.LanguageStatusSeverity.Information; break; } - case ServerState.Error: { - this.item.text = "Ruby LSP: Error"; - this.item.command!.arguments = [STOPPED_SERVER_OPTIONS]; - this.item.severity = vscode.LanguageStatusSeverity.Error; - break; - } } } - - registerCommand(): void { - this.context.subscriptions.push( - vscode.commands.registerCommand( - Command.ServerOptions, - async (options: [{ label: string; description: string }]) => { - const result = await vscode.window.showQuickPick(options, { - placeHolder: "Select server action", - }); - - if (result !== undefined) - await vscode.commands.executeCommand(result.description); - }, - ), - ); - } } export class ExperimentalFeaturesStatus extends StatusItem { - constructor(client: ClientInterface) { - super("experimentalFeatures", client); + constructor() { + super("experimentalFeatures"); + const experimentalFeaturesEnabled = vscode.workspace .getConfiguration("rubyLsp") @@ -197,50 +126,23 @@ export class ExperimentalFeaturesStatus extends StatusItem { }; } - refresh(): void {} - - registerCommand(): void { - this.context.subscriptions.push( - vscode.commands.registerCommand( - Command.ToggleExperimentalFeatures, - async () => { - const lspConfig = vscode.workspace.getConfiguration("rubyLsp"); - const experimentalFeaturesEnabled = lspConfig.get( - "enableExperimentalFeatures", - ); - await lspConfig.update( - "enableExperimentalFeatures", - !experimentalFeaturesEnabled, - true, - true, - ); - const message = experimentalFeaturesEnabled - ? "Experimental features disabled" - : "Experimental features enabled"; - this.item.text = message; - this.item.command!.title = experimentalFeaturesEnabled - ? "Enable" - : "Disable"; - }, - ), - ); - } + refresh(_workspace: WorkspaceInterface): void {} } export class YjitStatus extends StatusItem { - constructor(client: ClientInterface) { - super("yjit", client); + constructor() { + super("yjit"); this.item.name = "YJIT"; - this.refresh(); + this.item.text = "Fetching YJIT information"; } - refresh(): void { + refresh(workspace: WorkspaceInterface): void { const useYjit: boolean | undefined = vscode.workspace .getConfiguration("rubyLsp") .get("yjit"); - if (useYjit && this.client.ruby.supportsYjit) { + if (useYjit && workspace.ruby.supportsYjit) { this.item.text = "YJIT enabled"; this.item.command = { @@ -250,7 +152,7 @@ export class YjitStatus extends StatusItem { } else { this.item.text = "YJIT disabled"; - if (this.client.ruby.supportsYjit) { + if (workspace.ruby.supportsYjit) { this.item.command = { title: "Enable", command: Command.ToggleYjit, @@ -258,46 +160,20 @@ export class YjitStatus extends StatusItem { } } } - - registerCommand(): void { - this.context.subscriptions.push( - vscode.commands.registerCommand(Command.ToggleYjit, () => { - const lspConfig = vscode.workspace.getConfiguration("rubyLsp"); - const yjitEnabled = lspConfig.get("yjit"); - lspConfig.update("yjit", !yjitEnabled, true, true); - this.item.text = yjitEnabled ? "YJIT disabled" : "YJIT enabled"; - this.item.command!.title = yjitEnabled ? "Enable" : "Disable"; - }), - ); - } } export class FeaturesStatus extends StatusItem { - private descriptions: { [key: string]: string } = {}; - - constructor(client: ClientInterface) { - super("features", client); + constructor() { + super("features"); this.item.name = "Ruby LSP Features"; this.item.command = { title: "Manage", command: Command.ToggleFeatures, }; - this.refresh(); - - // Extract feature descriptions from our package.json - const enabledFeaturesProperties = - vscode.extensions.getExtension("Shopify.ruby-lsp")!.packageJSON - .contributes.configuration.properties["rubyLsp.enabledFeatures"] - .properties; - - Object.entries(enabledFeaturesProperties).forEach( - ([key, value]: [string, any]) => { - this.descriptions[key] = value.description; - }, - ); + this.item.text = "Fetching feature information"; } - refresh(): void { + refresh(_workspace: WorkspaceInterface): void { const configuration = vscode.workspace.getConfiguration("rubyLsp"); const features: { [key: string]: boolean } = configuration.get("enabledFeatures")!; @@ -309,94 +185,53 @@ export class FeaturesStatus extends StatusItem { Object.keys(features).length } features enabled`; } - - registerCommand(): void { - this.context.subscriptions.push( - vscode.commands.registerCommand(Command.ToggleFeatures, async () => { - const configuration = vscode.workspace.getConfiguration("rubyLsp"); - const features: { [key: string]: boolean } = - configuration.get("enabledFeatures")!; - const allFeatures = Object.keys(features); - const options: vscode.QuickPickItem[] = allFeatures.map((label) => { - return { - label, - picked: features[label], - description: this.descriptions[label], - }; - }); - - const toggledFeatures = await vscode.window.showQuickPick(options, { - canPickMany: true, - placeHolder: "Select the features you would like to enable", - }); - - if (toggledFeatures !== undefined) { - // The `picked` property is only used to determine if the checkbox is checked initially. When we receive the - // response back from the QuickPick, we need to use inclusion to check if the feature was selected - allFeatures.forEach((feature) => { - features[feature] = toggledFeatures.some( - (selected) => selected.label === feature, - ); - }); - - await vscode.workspace - .getConfiguration("rubyLsp") - .update("enabledFeatures", features, true, true); - } - }), - ); - } } export class FormatterStatus extends StatusItem { - constructor(client: ClientInterface) { - super("formatter", client); + constructor() { + super("formatter"); this.item.name = "Formatter"; this.item.command = { title: "Help", command: Command.FormatterHelp, }; - this.refresh(); + this.item.text = "Fetching formatter information"; } - refresh(): void { - if (this.client.formatter) { - this.item.text = `Formatter: ${this.client.formatter}`; - } else { - this.item.text = - "Formatter: requires server to be v0.12.4 or higher to display this field"; + refresh(workspace: WorkspaceInterface): void { + if (workspace.lspClient) { + if (workspace.lspClient.formatter) { + this.item.text = `Formatter: ${workspace.lspClient.formatter}`; + } else { + this.item.text = + "Formatter: requires server to be v0.12.4 or higher to display this field"; + } } } - - registerCommand(): void { - this.context.subscriptions.push( - vscode.commands.registerCommand(Command.FormatterHelp, () => { - vscode.env.openExternal( - vscode.Uri.parse( - "https://github.com/Shopify/vscode-ruby-lsp#formatting", - ), - ); - }), - ); - } } export class StatusItems { private readonly items: StatusItem[] = []; - constructor(client: ClientInterface) { + constructor() { this.items = [ - new RubyVersionStatus(client), - new ServerStatus(client), - new ExperimentalFeaturesStatus(client), - new YjitStatus(client), - new FeaturesStatus(client), - new FormatterStatus(client), + new RubyVersionStatus(), + new ServerStatus(), + new ExperimentalFeaturesStatus(), + new YjitStatus(), + new FeaturesStatus(), + new FormatterStatus(), ]; + + STATUS_EMITTER.event((workspace) => { + if (workspace) { + this.items.forEach((item) => item.refresh(workspace)); + } + }); } - public refresh() { - this.items.forEach((item) => item.refresh()); + dispose() { + this.items.forEach((item) => item.dispose()); } } diff --git a/src/telemetry.ts b/src/telemetry.ts index f01bbc61..ffa9f58f 100644 --- a/src/telemetry.ts +++ b/src/telemetry.ts @@ -41,7 +41,6 @@ class DevelopmentApi implements TelemetryApi { } export class Telemetry { - public serverVersion?: string; private api?: TelemetryApi; private readonly context: vscode.ExtensionContext; @@ -110,8 +109,8 @@ export class Telemetry { ); } - async sendCodeLensEvent(type: CodeLensEvent["type"]) { - await this.sendEvent({ type, lspVersion: this.serverVersion! }); + async sendCodeLensEvent(type: CodeLensEvent["type"], lspVersion: string) { + await this.sendEvent({ type, lspVersion }); } private async initialize(): Promise { diff --git a/src/test/suite/client.test.ts b/src/test/suite/client.test.ts index 2ab38f85..3da37b6b 100644 --- a/src/test/suite/client.test.ts +++ b/src/test/suite/client.test.ts @@ -5,12 +5,12 @@ import * as os from "os"; import { afterEach } from "mocha"; import * as vscode from "vscode"; +import { State } from "vscode-languageclient/node"; import { Ruby, VersionManager } from "../../ruby"; import { Telemetry, TelemetryApi, TelemetryEvent } from "../../telemetry"; -import { TestController } from "../../testController"; -import { ServerState } from "../../status"; import Client from "../../client"; +import { asyncExec } from "../../common"; class FakeApi implements TelemetryApi { public sentEvents: TelemetryEvent[]; @@ -27,19 +27,20 @@ class FakeApi implements TelemetryApi { suite("Client", () => { let client: Client | undefined; - let testController: TestController | undefined; const managerConfig = vscode.workspace.getConfiguration("rubyLsp"); const currentManager = managerConfig.get("rubyVersionManager"); const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const workspaceFolder: vscode.WorkspaceFolder = { + uri: vscode.Uri.parse(`file://${tmpPath}`), + name: path.basename(tmpPath), + index: 0, + }; fs.writeFileSync(path.join(tmpPath, ".ruby-version"), "3.2.2"); afterEach(async () => { - if (client && client.state === ServerState.Running) { + if (client && client.state === State.Running) { await client.stop(); - } - - if (testController) { - testController.dispose(); + await client.dispose(); } managerConfig.update("rubyVersionManager", currentManager, true, true); @@ -66,28 +67,23 @@ suite("Client", () => { }, } as unknown as vscode.ExtensionContext; - const ruby = new Ruby(context, { - uri: { fsPath: tmpPath }, - } as vscode.WorkspaceFolder); + const ruby = new Ruby(context, workspaceFolder); await ruby.activateRuby(); - const telemetry = new Telemetry(context, new FakeApi()); - - const testController = new TestController( - context, - tmpPath, - ruby, - telemetry, - ); + await asyncExec("gem install ruby-lsp", { + cwd: workspaceFolder.uri.fsPath, + env: ruby.env, + }); + const telemetry = new Telemetry(context, new FakeApi()); const client = new Client( context, telemetry, ruby, - testController, - tmpPath, + () => {}, + workspaceFolder, ); await client.start(); - assert.strictEqual(client.state, ServerState.Running); + assert.strictEqual(client.state, State.Running); }).timeout(30000); }); diff --git a/src/test/suite/debugger.test.ts b/src/test/suite/debugger.test.ts index 626b06a7..c5df9be7 100644 --- a/src/test/suite/debugger.test.ts +++ b/src/test/suite/debugger.test.ts @@ -7,12 +7,14 @@ import * as vscode from "vscode"; import { Debugger } from "../../debugger"; import { Ruby } from "../../ruby"; +import { Workspace } from "../../workspace"; suite("Debugger", () => { test("Provide debug configurations returns the default configs", () => { const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; - const ruby = { env: {} } as Ruby; - const debug = new Debugger(context, ruby, "fake"); + const debug = new Debugger(context, () => { + return undefined; + }); const configs = debug.provideDebugConfigurations!(undefined); assert.deepEqual( [ @@ -40,39 +42,58 @@ suite("Debugger", () => { ); debug.dispose(); + context.subscriptions.forEach((subscription) => subscription.dispose()); }); test("Resolve configuration injects Ruby environment", () => { const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; const ruby = { env: { bogus: "hello!" } } as unknown as Ruby; - const debug = new Debugger(context, ruby, "fake"); - const configs: any = debug.resolveDebugConfiguration!(undefined, { - type: "ruby_lsp", - name: "Debug", - request: "launch", - // eslint-disable-next-line no-template-curly-in-string - program: "ruby ${file}", + const debug = new Debugger(context, () => { + return { + ruby, + workspaceFolder: { uri: { fsPath: "fake" } }, + } as Workspace; }); + const configs: any = debug.resolveDebugConfiguration!( + { uri: { fsPath: "fake" } } as vscode.WorkspaceFolder, + { + type: "ruby_lsp", + name: "Debug", + request: "launch", + // eslint-disable-next-line no-template-curly-in-string + program: "ruby ${file}", + }, + ); assert.strictEqual(ruby.env, configs.env); debug.dispose(); + context.subscriptions.forEach((subscription) => subscription.dispose()); }); test("Resolve configuration injects Ruby environment and allows users custom environment", () => { const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; const ruby = { env: { bogus: "hello!" } } as unknown as Ruby; - const debug = new Debugger(context, ruby, "fake"); - const configs: any = debug.resolveDebugConfiguration!(undefined, { - type: "ruby_lsp", - name: "Debug", - request: "launch", - // eslint-disable-next-line no-template-curly-in-string - program: "ruby ${file}", - env: { parallel: "1" }, + const debug = new Debugger(context, () => { + return { + ruby, + workspaceFolder: { uri: { fsPath: "fake" } }, + } as Workspace; }); + const configs: any = debug.resolveDebugConfiguration!( + { uri: { fsPath: "fake" } } as vscode.WorkspaceFolder, + { + type: "ruby_lsp", + name: "Debug", + request: "launch", + // eslint-disable-next-line no-template-curly-in-string + program: "ruby ${file}", + env: { parallel: "1" }, + }, + ); assert.deepEqual({ parallel: "1", ...ruby.env }, configs.env); debug.dispose(); + context.subscriptions.forEach((subscription) => subscription.dispose()); }); test("Resolve configuration injects BUNDLE_GEMFILE if there's a custom bundle", () => { @@ -82,15 +103,23 @@ suite("Debugger", () => { const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; const ruby = { env: { bogus: "hello!" } } as unknown as Ruby; - const debug = new Debugger(context, ruby, tmpPath); - const configs: any = debug.resolveDebugConfiguration!(undefined, { - type: "ruby_lsp", - name: "Debug", - request: "launch", - // eslint-disable-next-line no-template-curly-in-string - program: "ruby ${file}", - env: { parallel: "1" }, + const debug = new Debugger(context, () => { + return { + ruby, + workspaceFolder: { uri: { fsPath: tmpPath } }, + } as Workspace; }); + const configs: any = debug.resolveDebugConfiguration!( + { uri: { fsPath: tmpPath } } as vscode.WorkspaceFolder, + { + type: "ruby_lsp", + name: "Debug", + request: "launch", + // eslint-disable-next-line no-template-curly-in-string + program: "ruby ${file}", + env: { parallel: "1" }, + }, + ); assert.deepEqual( { @@ -102,6 +131,7 @@ suite("Debugger", () => { ); debug.dispose(); + context.subscriptions.forEach((subscription) => subscription.dispose()); fs.rmSync(tmpPath, { recursive: true, force: true }); }); }); diff --git a/src/test/suite/status.test.ts b/src/test/suite/status.test.ts index 5f596d92..de94c60f 100644 --- a/src/test/suite/status.test.ts +++ b/src/test/suite/status.test.ts @@ -2,50 +2,44 @@ import * as assert from "assert"; import * as vscode from "vscode"; import { beforeEach, afterEach } from "mocha"; +import { State } from "vscode-languageclient/node"; import { Ruby } from "../../ruby"; import { RubyVersionStatus, ServerStatus, ExperimentalFeaturesStatus, - Command, YjitStatus, StatusItem, - ServerState, - ClientInterface, FeaturesStatus, FormatterStatus, } from "../../status"; +import { Command, WorkspaceInterface } from "../../common"; suite("StatusItems", () => { let ruby: Ruby; - let context: vscode.ExtensionContext; let status: StatusItem; - let client: ClientInterface; + let workspace: WorkspaceInterface; let formatter: string; - beforeEach(() => { - context = { subscriptions: [] } as unknown as vscode.ExtensionContext; - }); - afterEach(() => { - context.subscriptions.forEach((subscription) => { - subscription.dispose(); - }); status.dispose(); }); suite("RubyVersionStatus", () => { beforeEach(() => { ruby = { rubyVersion: "3.2.0", versionManager: "shadowenv" } as Ruby; - client = { - context, + workspace = { ruby, - state: ServerState.Running, - formatter: "none", - serverVersion: "1.0.0", + lspClient: { + state: State.Running, + formatter: "none", + serverVersion: "1.0.0", + }, + error: false, }; - status = new RubyVersionStatus(client); + status = new RubyVersionStatus(); + status.refresh(workspace); }); test("Status is initialized with the right values", () => { @@ -56,14 +50,13 @@ suite("StatusItems", () => { status.item.command.command, Command.SelectVersionManager, ); - assert.strictEqual(context.subscriptions.length, 1); }); test("Refresh updates version string", () => { assert.strictEqual(status.item.text, "Using Ruby 3.2.0 with shadowenv"); - client.ruby.rubyVersion = "3.2.1"; - status.refresh(); + workspace.ruby.rubyVersion = "3.2.1"; + status.refresh(workspace); assert.strictEqual(status.item.text, "Using Ruby 3.2.1 with shadowenv"); }); }); @@ -71,31 +64,22 @@ suite("StatusItems", () => { suite("ServerStatus", () => { beforeEach(() => { ruby = {} as Ruby; - client = { - context, + workspace = { ruby, - state: ServerState.Running, - formatter: "none", - serverVersion: "1.0.0", + lspClient: { + state: State.Running, + formatter: "none", + serverVersion: "1.0.0", + }, + error: false, }; - status = new ServerStatus(client); - }); - - test("Status is initialized with the right values", () => { - assert.strictEqual(status.item.text, "Ruby LSP: Starting"); - assert.strictEqual(status.item.name, "Ruby LSP Status"); - assert.strictEqual( - status.item.severity, - vscode.LanguageStatusSeverity.Information, - ); - assert.strictEqual(status.item.command?.title, "Configure"); - assert.strictEqual(status.item.command.command, Command.ServerOptions); - assert.strictEqual(context.subscriptions.length, 1); + status = new ServerStatus(); + status.refresh(workspace); }); test("Refresh when server is starting", () => { - client.state = ServerState.Starting; - status.refresh(); + workspace.lspClient!.state = State.Starting; + status.refresh(workspace); assert.strictEqual(status.item.text, "Ruby LSP: Starting"); assert.strictEqual( status.item.severity, @@ -104,8 +88,8 @@ suite("StatusItems", () => { }); test("Refresh when server is running", () => { - client.state = ServerState.Running; - status.refresh(); + workspace.lspClient!.state = State.Running; + status.refresh(workspace); assert.strictEqual(status.item.text, "Ruby LSP v1.0.0: Running"); assert.strictEqual( status.item.severity, @@ -114,8 +98,8 @@ suite("StatusItems", () => { }); test("Refresh when server is stopping", () => { - client.state = ServerState.Stopped; - status.refresh(); + workspace.lspClient!.state = State.Stopped; + status.refresh(workspace); assert.strictEqual(status.item.text, "Ruby LSP: Stopped"); assert.strictEqual( status.item.severity, @@ -124,8 +108,8 @@ suite("StatusItems", () => { }); test("Refresh when server has errored", () => { - client.state = ServerState.Error; - status.refresh(); + workspace.error = true; + status.refresh(workspace); assert.strictEqual(status.item.text, "Ruby LSP: Error"); assert.strictEqual( status.item.severity, @@ -137,14 +121,17 @@ suite("StatusItems", () => { suite("ExperimentalFeaturesStatus", () => { beforeEach(() => { ruby = {} as Ruby; - client = { - context, + workspace = { ruby, - formatter, - state: ServerState.Running, - serverVersion: "1.0.0", + lspClient: { + state: State.Running, + formatter, + serverVersion: "1.0.0", + }, + error: false, }; - status = new ExperimentalFeaturesStatus(client); + status = new ExperimentalFeaturesStatus(); + status.refresh(workspace); }); test("Status is initialized with the right values", () => { @@ -155,21 +142,23 @@ suite("StatusItems", () => { status.item.command!.command, Command.ToggleExperimentalFeatures, ); - assert.strictEqual(context.subscriptions.length, 1); }); }); suite("YjitStatus when Ruby supports it", () => { beforeEach(() => { ruby = { supportsYjit: true } as Ruby; - client = { - context, + workspace = { ruby, - state: ServerState.Running, - formatter: "none", - serverVersion: "1.0.0", + lspClient: { + state: State.Running, + formatter: "none", + serverVersion: "1.0.0", + }, + error: false, }; - status = new YjitStatus(client); + status = new YjitStatus(); + status.refresh(workspace); }); test("Status is initialized with the right values", () => { @@ -177,14 +166,13 @@ suite("StatusItems", () => { assert.strictEqual(status.item.name, "YJIT"); assert.strictEqual(status.item.command?.title, "Disable"); assert.strictEqual(status.item.command.command, Command.ToggleYjit); - assert.strictEqual(context.subscriptions.length, 1); }); test("Refresh updates whether it's disabled or enabled", () => { assert.strictEqual(status.item.text, "YJIT enabled"); - client.ruby.supportsYjit = false; - status.refresh(); + workspace.ruby.supportsYjit = false; + status.refresh(workspace); assert.strictEqual(status.item.text, "YJIT disabled"); }); }); @@ -192,14 +180,17 @@ suite("StatusItems", () => { suite("YjitStatus when Ruby does not support it", () => { beforeEach(() => { ruby = { supportsYjit: false } as Ruby; - client = { - context, + workspace = { ruby, - state: ServerState.Running, - formatter: "none", - serverVersion: "1.0.0", + lspClient: { + state: State.Running, + formatter: "none", + serverVersion: "1.0.0", + }, + error: false, }; - status = new YjitStatus(client); + status = new YjitStatus(); + status.refresh(workspace); }); test("Refresh ignores YJIT configuration if Ruby doesn't support it", () => { @@ -208,8 +199,8 @@ suite("StatusItems", () => { const lspConfig = vscode.workspace.getConfiguration("rubyLsp"); lspConfig.update("yjit", true, true, true); - client.ruby.supportsYjit = false; - status.refresh(); + workspace.ruby.supportsYjit = false; + status.refresh(workspace); assert.strictEqual(status.item.text, "YJIT disabled"); assert.strictEqual(status.item.command, undefined); @@ -227,13 +218,17 @@ suite("StatusItems", () => { beforeEach(() => { ruby = {} as Ruby; - status = new FeaturesStatus({ - context, + workspace = { ruby, - formatter, - state: ServerState.Running, - serverVersion: "1.0.0", - }); + lspClient: { + state: State.Running, + formatter: "none", + serverVersion: "1.0.0", + }, + error: false, + }; + status = new FeaturesStatus(); + status.refresh(workspace); }); afterEach(() => { @@ -250,7 +245,6 @@ suite("StatusItems", () => { assert.strictEqual(status.item.name, "Ruby LSP Features"); assert.strictEqual(status.item.command?.title, "Manage"); assert.strictEqual(status.item.command.command, Command.ToggleFeatures); - assert.strictEqual(context.subscriptions.length, 1); }); test("Refresh updates number of features", async () => { @@ -273,7 +267,7 @@ suite("StatusItems", () => { assert.strictEqual(currentFeatures[key], expected); }); - status.refresh(); + status.refresh(workspace); assert.strictEqual( status.item.text, `${ @@ -290,14 +284,17 @@ suite("StatusItems", () => { suite("FormatterStatus", () => { beforeEach(() => { ruby = {} as Ruby; - client = { - context, + workspace = { ruby, - state: ServerState.Running, - formatter: "auto", - serverVersion: "1.0.0", + lspClient: { + state: State.Running, + formatter: "auto", + serverVersion: "1.0.0", + }, + error: false, }; - status = new FormatterStatus(client); + status = new FormatterStatus(); + status.refresh(workspace); }); test("Status is initialized with the right values", () => { @@ -305,7 +302,6 @@ suite("StatusItems", () => { assert.strictEqual(status.item.name, "Formatter"); assert.strictEqual(status.item.command?.title, "Help"); assert.strictEqual(status.item.command.command, Command.FormatterHelp); - assert.strictEqual(context.subscriptions.length, 1); }); }); }); diff --git a/src/test/suite/telemetry.test.ts b/src/test/suite/telemetry.test.ts index ec3f7fa7..658d6b93 100644 --- a/src/test/suite/telemetry.test.ts +++ b/src/test/suite/telemetry.test.ts @@ -121,8 +121,7 @@ suite("Telemetry", () => { api, ); - telemetry.serverVersion = "1.0.0"; - await telemetry.sendCodeLensEvent("test"); + await telemetry.sendCodeLensEvent("test", "1.0.0"); const codeLensEvent = api.sentEvents[0] as CodeLensEvent; assert.strictEqual(codeLensEvent.type, "test"); diff --git a/src/testController.ts b/src/testController.ts index b00c809c..34e770bb 100644 --- a/src/testController.ts +++ b/src/testController.ts @@ -4,39 +4,33 @@ import { promisify } from "util"; import * as vscode from "vscode"; import { CodeLens } from "vscode-languageclient/node"; -import { Ruby } from "./ruby"; -import { Command } from "./status"; import { Telemetry } from "./telemetry"; +import { Workspace } from "./workspace"; const asyncExec = promisify(exec); -const TERMINAL_NAME = "Ruby LSP: Run test"; - export class TestController { private readonly testController: vscode.TestController; private readonly testCommands: WeakMap; private readonly testRunProfile: vscode.TestRunProfile; private readonly testDebugProfile: vscode.TestRunProfile; private readonly debugTag: vscode.TestTag = new vscode.TestTag("debug"); - private readonly workingFolder: string; private terminal: vscode.Terminal | undefined; - private readonly ruby: Ruby; private readonly telemetry: Telemetry; // We allow the timeout to be configured in seconds, but exec expects it in milliseconds private readonly testTimeout = vscode.workspace .getConfiguration("rubyLsp") .get("testTimeout") as number; + private readonly currentWorkspace: () => Workspace | undefined; + constructor( context: vscode.ExtensionContext, - workingFolder: string, - ruby: Ruby, telemetry: Telemetry, + currentWorkspace: () => Workspace | undefined, ) { - this.workingFolder = workingFolder; - this.ruby = ruby; this.telemetry = telemetry; - + this.currentWorkspace = currentWorkspace; this.testController = vscode.tests.createTestController( "rubyTests", "Ruby Tests", @@ -70,20 +64,8 @@ export class TestController { context.subscriptions.push( this.testController, - vscode.commands.registerCommand( - Command.RunTest, - (_path, name, _command) => { - this.runOnClick(name); - }, - ), - vscode.commands.registerCommand( - Command.RunTestInTerminal, - this.runTestInTerminal.bind(this), - ), - vscode.commands.registerCommand( - Command.DebugTest, - this.debugTest.bind(this), - ), + this.testDebugProfile, + this.testRunProfile, ); } @@ -161,52 +143,86 @@ export class TestController { }); } - dispose() { - this.testRunProfile.dispose(); - this.testDebugProfile.dispose(); - this.testController.dispose(); - } - - private debugTest(_path: string, _name: string, command?: string) { + async runTestInTerminal(_path: string, _name: string, command?: string) { // eslint-disable-next-line no-param-reassign command ??= this.testCommands.get(this.findTestByActiveLine()!) || ""; - return vscode.debug.startDebugging(undefined, { - type: "ruby_lsp", - name: "Debug", - request: "launch", - program: command, - env: { ...this.ruby.env, DISABLE_SPRING: "1" }, + if (this.terminal === undefined) { + this.terminal = this.getTerminal(); + } + + this.terminal.show(); + this.terminal.sendText(command); + + const workspace = this.currentWorkspace(); + + if (workspace?.lspClient?.serverVersion) { + await this.telemetry.sendCodeLensEvent( + "test_in_terminal", + workspace.lspClient.serverVersion, + ); + } + } + + runOnClick(testId: string) { + const test = this.findTestById(testId); + + if (!test) return; + + vscode.commands.executeCommand("vscode.revealTestInExplorer", test); + let tokenSource: vscode.CancellationTokenSource | null = + new vscode.CancellationTokenSource(); + + tokenSource.token.onCancellationRequested(() => { + tokenSource?.dispose(); + tokenSource = null; + + vscode.window.showInformationMessage("Cancelled the progress"); }); + + const testRun = new vscode.TestRunRequest([test], [], this.testRunProfile); + + this.testRunProfile.runHandler(testRun, tokenSource.token); } - private async runTestInTerminal( - _path: string, - _name: string, - command?: string, - ) { + debugTest(_path: string, _name: string, command?: string) { // eslint-disable-next-line no-param-reassign command ??= this.testCommands.get(this.findTestByActiveLine()!) || ""; - await this.telemetry.sendCodeLensEvent("test_in_terminal"); + const workspace = this.currentWorkspace(); - if (this.terminal === undefined) { - this.terminal = this.getTerminal(); + if (!workspace) { + throw new Error( + "No workspace found. Debugging requires a workspace to be opened", + ); } - this.terminal.show(); - this.terminal.sendText(command); + return vscode.debug.startDebugging(undefined, { + type: "ruby_lsp", + name: "Debug", + request: "launch", + program: command, + env: { ...workspace.ruby.env, DISABLE_SPRING: "1" }, + }); } + // Get an existing terminal or create a new one. For multiple workspaces, it's important to create a new terminal for + // each workspace because they might be using different Ruby versions. If there's no workspace, we fallback to a + // generic name private getTerminal() { + const workspace = this.currentWorkspace(); + const name = workspace + ? `${workspace.workspaceFolder.name}: test` + : "Ruby LSP: test"; + const previousTerminal = vscode.window.terminals.find( - (terminal) => terminal.name === TERMINAL_NAME, + (terminal) => terminal.name === name, ); return previousTerminal ? previousTerminal : vscode.window.createTerminal({ - name: TERMINAL_NAME, + name, }); } @@ -214,7 +230,6 @@ export class TestController { request: vscode.TestRunRequest, _token: vscode.CancellationToken, ) { - await this.telemetry.sendCodeLensEvent("debug"); const run = this.testController.createTestRun(request, undefined, true); const test = request.include![0]; @@ -222,13 +237,21 @@ export class TestController { await this.debugTest("", "", this.testCommands.get(test)!); run.passed(test, Date.now() - start); run.end(); + + const workspace = this.currentWorkspace(); + + if (workspace?.lspClient?.serverVersion) { + await this.telemetry.sendCodeLensEvent( + "debug", + workspace.lspClient.serverVersion, + ); + } } private async runHandler( request: vscode.TestRunRequest, token: vscode.CancellationToken, ) { - await this.telemetry.sendCodeLensEvent("test"); const run = this.testController.createTestRun(request, undefined, true); const queue: vscode.TestItem[] = []; const enqueue = (test: vscode.TestItem) => { @@ -254,7 +277,19 @@ export class TestController { if (test.tags.find((tag) => tag.id === "example")) { const start = Date.now(); try { - const output: string = await this.assertTestPasses(test); + const workspace = this.currentWorkspace(); + + if (!workspace) { + run.errored(test, new vscode.TestMessage("No workspace found")); + continue; + } + + const output: string = await this.assertTestPasses( + test, + workspace.workspaceFolder.uri.fsPath, + workspace.ruby.env, + ); + run.appendOutput(output, undefined, test); run.passed(test, Date.now() - start); } catch (err: any) { @@ -299,13 +334,26 @@ export class TestController { // Make sure to end the run after all tests have been executed run.end(); + + const workspace = this.currentWorkspace(); + + if (workspace?.lspClient?.serverVersion) { + await this.telemetry.sendCodeLensEvent( + "test", + workspace.lspClient.serverVersion, + ); + } } - private async assertTestPasses(test: vscode.TestItem) { + private async assertTestPasses( + test: vscode.TestItem, + cwd: string, + env: NodeJS.ProcessEnv, + ) { try { const result = await asyncExec(this.testCommands.get(test)!, { - cwd: this.workingFolder, - env: this.ruby.env, + cwd, + env, timeout: this.testTimeout * 1000, }); return result.stdout; @@ -318,27 +366,6 @@ export class TestController { } } - private runOnClick(testId: string) { - const test = this.findTestById(testId); - - if (!test) return; - - vscode.commands.executeCommand("vscode.revealTestInExplorer", test); - let tokenSource: vscode.CancellationTokenSource | null = - new vscode.CancellationTokenSource(); - - tokenSource.token.onCancellationRequested(() => { - tokenSource?.dispose(); - tokenSource = null; - - vscode.window.showInformationMessage("Cancelled the progress"); - }); - - const testRun = new vscode.TestRunRequest([test], [], this.testRunProfile); - - this.testRunProfile.runHandler(testRun, tokenSource.token); - } - private findTestById( testId: string, testItems: vscode.TestItemCollection = this.testController.items, diff --git a/src/workspace.ts b/src/workspace.ts new file mode 100644 index 00000000..cf567ff1 --- /dev/null +++ b/src/workspace.ts @@ -0,0 +1,245 @@ +import fs from "fs/promises"; +import path from "path"; + +import * as vscode from "vscode"; +import { CodeLens } from "vscode-languageclient/node"; + +import { Ruby } from "./ruby"; +import { Telemetry } from "./telemetry"; +import Client from "./client"; +import { + asyncExec, + LOG_CHANNEL, + WorkspaceInterface, + STATUS_EMITTER, + pathExists, +} from "./common"; + +export class Workspace implements WorkspaceInterface { + public lspClient?: Client; + public readonly ruby: Ruby; + public readonly createTestItems: (response: CodeLens[]) => void; + public readonly workspaceFolder: vscode.WorkspaceFolder; + private readonly context: vscode.ExtensionContext; + private readonly telemetry: Telemetry; + #error = false; + + constructor( + context: vscode.ExtensionContext, + workspaceFolder: vscode.WorkspaceFolder, + telemetry: Telemetry, + createTestItems: (response: CodeLens[]) => void, + ) { + this.context = context; + this.workspaceFolder = workspaceFolder; + this.telemetry = telemetry; + this.ruby = new Ruby(context, workspaceFolder); + this.createTestItems = createTestItems; + + this.registerRestarts(context); + } + + async start() { + await this.ruby.activateRuby(); + + if (this.ruby.error) { + this.error = true; + return; + } + + try { + await fs.access(this.workspaceFolder.uri.fsPath, fs.constants.W_OK); + } catch (error: any) { + this.error = true; + + vscode.window.showErrorMessage( + `Directory ${this.workspaceFolder.uri.fsPath} is not writable. The Ruby LSP server needs to be able to create a + .ruby-lsp directory to function appropriately. Consider switching to a directory for which VS Code has write + permissions`, + ); + + return; + } + + try { + await this.installOrUpdateServer(); + } catch (error: any) { + this.error = true; + vscode.window.showErrorMessage( + `Failed to setup the bundle: ${error.message}. \ + See [Troubleshooting](https://github.com/Shopify/vscode-ruby-lsp#troubleshooting) for instructions`, + ); + + return; + } + + // The `start` method can be invoked through commands - even if there's an LSP client already running. We need to + // ensure that the existing client for this workspace has been stopped and disposed of before we create a new one + if (this.lspClient) { + await this.lspClient.stop(); + await this.lspClient.dispose(); + } + + this.lspClient = new Client( + this.context, + this.telemetry, + this.ruby, + this.createTestItems, + this.workspaceFolder, + ); + + try { + STATUS_EMITTER.fire(this); + await this.lspClient.start(); + this.lspClient.performAfterStart(); + STATUS_EMITTER.fire(this); + } catch (error: any) { + this.error = true; + LOG_CHANNEL.error(`Error starting the server: ${error.message}`); + } + } + + async stop() { + await this.lspClient?.stop(); + } + + async restart() { + try { + if (await this.rebaseInProgress()) { + return; + } + + if (this.lspClient) { + await this.stop(); + await this.lspClient.dispose(); + await this.start(); + } else { + await this.start(); + } + } catch (error: any) { + this.error = true; + LOG_CHANNEL.error(`Error restarting the server: ${error.message}`); + } + } + + async dispose() { + await this.lspClient?.dispose(); + } + + // Install or update the `ruby-lsp` gem globally with `gem install ruby-lsp` or `gem update ruby-lsp`. We only try to + // update on a daily basis, not every time the server boots + async installOrUpdateServer(): Promise { + // If there's a user configured custom bundle to run the LSP, then we do not perform auto-updates and let the user + // manage that custom bundle themselves + const customBundle: string = vscode.workspace + .getConfiguration("rubyLsp") + .get("bundleGemfile")!; + + if (customBundle.length > 0) { + return; + } + + const oneDayInMs = 24 * 60 * 60 * 1000; + const lastUpdatedAt: number | undefined = this.context.workspaceState.get( + "rubyLsp.lastGemUpdate", + ); + + const { stderr } = await asyncExec("gem list ruby-lsp 1>&2", { + cwd: this.workspaceFolder.uri.fsPath, + env: this.ruby.env, + }); + + // If the gem is not yet installed, install it + if (!stderr.includes("ruby-lsp")) { + await asyncExec("gem install ruby-lsp", { + cwd: this.workspaceFolder.uri.fsPath, + env: this.ruby.env, + }); + + this.context.workspaceState.update("rubyLsp.lastGemUpdate", Date.now()); + return; + } + + // If we haven't updated the gem in the last 24 hours, update it + if ( + lastUpdatedAt === undefined || + Date.now() - lastUpdatedAt > oneDayInMs + ) { + try { + await asyncExec("gem update ruby-lsp", { + cwd: this.workspaceFolder.uri.fsPath, + env: this.ruby.env, + }); + this.context.workspaceState.update("rubyLsp.lastGemUpdate", Date.now()); + } catch (error) { + // If we fail to update the global installation of `ruby-lsp`, we don't want to prevent the server from starting + LOG_CHANNEL.error(`Failed to update global ruby-lsp gem: ${error}`); + } + } + } + + get error() { + return this.#error; + } + + private set error(value: boolean) { + STATUS_EMITTER.fire(this); + this.#error = value; + } + + private registerRestarts(context: vscode.ExtensionContext) { + this.createRestartWatcher(context, "Gemfile.lock"); + this.createRestartWatcher(context, "gems.locked"); + this.createRestartWatcher(context, "**/.rubocop.yml"); + + // If a configuration that affects the Ruby LSP has changed, update the client options using the latest + // configuration and restart the server + vscode.workspace.onDidChangeConfiguration(async (event) => { + if (event.affectsConfiguration("rubyLsp")) { + // Re-activate Ruby if the version manager changed + if ( + event.affectsConfiguration("rubyLsp.rubyVersionManager") || + event.affectsConfiguration("rubyLsp.bundleGemfile") || + event.affectsConfiguration("rubyLsp.customRubyCommand") + ) { + await this.ruby.activateRuby(); + } + + await this.restart(); + } + }); + } + + private createRestartWatcher( + context: vscode.ExtensionContext, + pattern: string, + ) { + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(this.workspaceFolder.uri.fsPath, pattern), + ); + context.subscriptions.push(watcher); + + watcher.onDidChange(this.restart.bind(this)); + watcher.onDidCreate(this.restart.bind(this)); + watcher.onDidDelete(this.restart.bind(this)); + } + + // If the `.git` folder exists and `.git/rebase-merge` or `.git/rebase-apply` exists, then we're in the middle of a + // rebase + private async rebaseInProgress() { + const gitFolder = path.join(this.workspaceFolder.uri.fsPath, ".git"); + + if (!(await pathExists(gitFolder))) { + return false; + } + + if ( + (await pathExists(path.join(gitFolder, "rebase-merge"))) || + (await pathExists(path.join(gitFolder, "rebase-apply"))) + ) { + return true; + } + + return false; + } +}