From 272316f1433352b9cb0f00e8e64bc17d977e98ec Mon Sep 17 00:00:00 2001 From: Dmitry Lebed <209805+d-lebed@users.noreply.github.com> Date: Wed, 27 Nov 2024 00:10:55 +0100 Subject: [PATCH] Add docker compose as a version manager --- vscode/package.json | 9 + vscode/src/client.ts | 165 ++++++++++++++++- vscode/src/common.ts | 11 +- vscode/src/docker.ts | 171 ++++++++++++++++++ vscode/src/ruby.ts | 38 +++- vscode/src/ruby/compose.ts | 180 +++++++++++++++++++ vscode/src/ruby/rubyInstaller.ts | 25 +-- vscode/src/ruby/versionManager.ts | 153 ++++++++++++++-- vscode/src/rubyLsp.ts | 2 +- vscode/src/test/suite/ruby.test.ts | 7 +- vscode/src/test/suite/ruby/asdf.test.ts | 50 ++++-- vscode/src/test/suite/ruby/compose.test.ts | 126 +++++++++++++ vscode/src/test/suite/ruby/custom.test.ts | 21 ++- vscode/src/test/suite/ruby/mise.test.ts | 35 ++-- vscode/src/test/suite/ruby/none.test.ts | 32 ++-- vscode/src/test/suite/ruby/rbenv.test.ts | 47 +++-- vscode/src/test/suite/ruby/rvm.test.ts | 21 ++- vscode/src/test/suite/ruby/shadowenv.test.ts | 14 +- vscode/src/test/suite/testHelpers.ts | 59 ++++++ vscode/src/workspace.ts | 18 +- 20 files changed, 1058 insertions(+), 126 deletions(-) create mode 100644 vscode/src/docker.ts create mode 100644 vscode/src/ruby/compose.ts create mode 100644 vscode/src/test/suite/ruby/compose.test.ts create mode 100644 vscode/src/test/suite/testHelpers.ts diff --git a/vscode/package.json b/vscode/package.json index 6932d93444..61422d2f56 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -328,6 +328,7 @@ "rvm", "shadowenv", "mise", + "compose", "custom" ], "default": "auto" @@ -347,6 +348,14 @@ "chrubyRubies": { "description": "An array of extra directories to search for Ruby installations when using chruby. Equivalent to the RUBIES environment variable", "type": "array" + }, + "composeService": { + "description": "The name of the service in the compose file to use to start the Ruby LSP server", + "type": "string" + }, + "composeCustomCommand": { + "description": "A shell command to start the ruby LSP server using compose. This overrides the composeService setting", + "type": "string" } }, "default": { diff --git a/vscode/src/client.ts b/vscode/src/client.ts index 6a9cab5140..a9cbf92aa7 100644 --- a/vscode/src/client.ts +++ b/vscode/src/client.ts @@ -37,6 +37,7 @@ import { SUPPORTED_LANGUAGE_IDS, FEATURE_FLAGS, featureEnabled, + PathConverterInterface, } from "./common"; import { Ruby } from "./ruby"; import { WorkspaceChannel } from "./workspaceChannel"; @@ -60,7 +61,7 @@ function enabledFeatureFlags(): Record { // Get the executables to start the server based on the user's configuration function getLspExecutables( workspaceFolder: vscode.WorkspaceFolder, - env: NodeJS.ProcessEnv, + ruby: Ruby, ): ServerOptions { let run: Executable; let debug: Executable; @@ -74,8 +75,8 @@ function getLspExecutables( const executableOptions: ExecutableOptions = { cwd: workspaceFolder.uri.fsPath, env: bypassTypechecker - ? { ...env, RUBY_LSP_BYPASS_TYPECHECKER: "true" } - : env, + ? { ...ruby.env, RUBY_LSP_BYPASS_TYPECHECKER: "true" } + : ruby.env, shell: true, }; @@ -129,6 +130,9 @@ function getLspExecutables( }; } + run = ruby.activateExecutable(run); + debug = ruby.activateExecutable(debug); + return { run, debug }; } @@ -166,6 +170,32 @@ function collectClientOptions( }, ); + const pathConverter = ruby.pathConverter; + + const pushAlternativePaths = ( + path: string, + schemes: string[] = supportedSchemes, + ) => { + schemes.forEach((scheme) => { + [ + pathConverter.toLocalPath(path), + pathConverter.toRemotePath(path), + ].forEach((convertedPath) => { + if (convertedPath !== path) { + SUPPORTED_LANGUAGE_IDS.forEach((language) => { + documentSelector.push({ + scheme, + language, + pattern: `${convertedPath}/**/*`, + }); + }); + } + }); + }); + }; + + pushAlternativePaths(fsPath); + // Only the first language server we spawn should handle unsaved files, otherwise requests will be duplicated across // all workspaces if (isMainWorkspace) { @@ -185,6 +215,8 @@ function collectClientOptions( pattern: `${gemPath}/**/*`, }); + pushAlternativePaths(gemPath, [scheme]); + // Because of how default gems are installed, the gemPath location is actually not exactly where the files are // located. With the regex, we are correcting the default gem path from this (where the files are not located) // /opt/rubies/3.3.1/lib/ruby/gems/3.3.0 @@ -195,15 +227,50 @@ function collectClientOptions( // Notice that we still need to add the regular path to the selector because some version managers will install // gems under the non-corrected path if (/lib\/ruby\/gems\/(?=\d)/.test(gemPath)) { + const correctedPath = gemPath.replace( + /lib\/ruby\/gems\/(?=\d)/, + "lib/ruby/", + ); + documentSelector.push({ scheme, language: "ruby", - pattern: `${gemPath.replace(/lib\/ruby\/gems\/(?=\d)/, "lib/ruby/")}/**/*`, + pattern: `${correctedPath}/**/*`, }); + + pushAlternativePaths(correctedPath, [scheme]); } }); }); + // Add other mapped paths to the document selector + pathConverter.pathMapping.forEach(([local, remote]) => { + if ( + (documentSelector as { pattern: string }[]).some( + (selector) => + selector.pattern?.startsWith(local) || + selector.pattern?.startsWith(remote), + ) + ) { + return; + } + + supportedSchemes.forEach((scheme) => { + SUPPORTED_LANGUAGE_IDS.forEach((language) => { + documentSelector.push({ + language, + pattern: `${local}/**/*`, + }); + + documentSelector.push({ + scheme, + language, + pattern: `${remote}/**/*`, + }); + }); + }); + }); + // This is a temporary solution as an escape hatch for users who cannot upgrade the `ruby-lsp` gem to a version that // supports ERB if (!configuration.get("erbSupport")) { @@ -212,9 +279,29 @@ function collectClientOptions( }); } + outputChannel.info( + `Document Selector Paths: ${JSON.stringify(documentSelector)}`, + ); + + // Map using pathMapping + const code2Protocol = (uri: vscode.Uri) => { + const remotePath = pathConverter.toRemotePath(uri.fsPath); + return vscode.Uri.file(remotePath).toString(); + }; + + const protocol2Code = (uri: string) => { + const remoteUri = vscode.Uri.parse(uri); + const localPath = pathConverter.toLocalPath(remoteUri.fsPath); + return vscode.Uri.file(localPath); + }; + return { documentSelector, workspaceFolder, + uriConverters: { + code2Protocol, + protocol2Code, + }, diagnosticCollectionName: LSP_NAME, outputChannel, revealOutputChannelOn: RevealOutputChannelOn.Never, @@ -317,6 +404,7 @@ export default class Client extends LanguageClient implements ClientInterface { private readonly baseFolder; private readonly workspaceOutputChannel: WorkspaceChannel; private readonly virtualDocuments = new Map(); + private readonly pathConverter: PathConverterInterface; #context: vscode.ExtensionContext; #formatter: string; @@ -334,7 +422,7 @@ export default class Client extends LanguageClient implements ClientInterface { ) { super( LSP_NAME, - getLspExecutables(workspaceFolder, ruby.env), + getLspExecutables(workspaceFolder, ruby), collectClientOptions( vscode.workspace.getConfiguration("rubyLsp"), workspaceFolder, @@ -349,6 +437,7 @@ export default class Client extends LanguageClient implements ClientInterface { this.registerFeature(new ExperimentalCapabilities()); this.workspaceOutputChannel = outputChannel; this.virtualDocuments = virtualDocuments; + this.pathConverter = ruby.pathConverter; // 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`) @@ -429,7 +518,9 @@ export default class Client extends LanguageClient implements ClientInterface { range?: Range, ): Promise<{ ast: string } | null> { return this.sendRequest("rubyLsp/textDocument/showSyntaxTree", { - textDocument: { uri: uri.toString() }, + textDocument: { + uri: this.pathConverter.toRemoteUri(uri).toString(), + }, range, }); } @@ -625,10 +716,12 @@ export default class Client extends LanguageClient implements ClientInterface { token, _next, ) => { + const remoteUri = this.pathConverter.toRemoteUri(document.uri); + const response: vscode.TextEdit[] | null = await this.sendRequest( "textDocument/onTypeFormatting", { - textDocument: { uri: document.uri.toString() }, + textDocument: { uri: remoteUri.toString() }, position, ch, options, @@ -696,9 +789,65 @@ export default class Client extends LanguageClient implements ClientInterface { token?: vscode.CancellationToken, ) => Promise, ) => { - return this.benchmarkMiddleware(type, param, () => + this.workspaceOutputChannel.trace( + `Sending request: ${JSON.stringify(type)} with params: ${JSON.stringify(param)}`, + ); + + const result = (await this.benchmarkMiddleware(type, param, () => next(type, param, token), + )) as any; + + this.workspaceOutputChannel.trace( + `Received response for ${JSON.stringify(type)}: ${JSON.stringify(result)}`, ); + + const request = typeof type === "string" ? type : type.method; + + try { + switch (request) { + case "rubyLsp/workspace/dependencies": + return result.map((dep: { path: string }) => { + return { + ...dep, + path: this.pathConverter.toLocalPath(dep.path), + }; + }); + + case "textDocument/codeAction": + return result.map((action: { uri: string }) => { + const remotePath = vscode.Uri.parse(action.uri).fsPath; + const localPath = this.pathConverter.toLocalPath(remotePath); + + return { + ...action, + uri: vscode.Uri.file(localPath).toString(), + }; + }); + + case "textDocument/hover": + if ( + result?.contents?.kind === "markdown" && + result.contents.value + ) { + result.contents.value = result.contents.value.replace( + /\((file:\/\/.+?)#/gim, + (_match: string, path: string) => { + const remotePath = vscode.Uri.parse(path).fsPath; + const localPath = + this.pathConverter.toLocalPath(remotePath); + return `(${vscode.Uri.file(localPath).toString()}#`; + }, + ); + } + break; + } + } catch (error) { + this.workspaceOutputChannel.error( + `Error while processing response for ${request}: ${error}`, + ); + } + + return result; }, sendNotification: async ( type: string | MessageSignature, diff --git a/vscode/src/common.ts b/vscode/src/common.ts index 61787bb06a..02173c7ef9 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -1,4 +1,4 @@ -import { exec } from "child_process"; +import { exec, spawn as originalSpawn } from "child_process"; import { createHash } from "crypto"; import { promisify } from "util"; @@ -63,11 +63,20 @@ export interface WorkspaceInterface { error: boolean; } +export interface PathConverterInterface { + pathMapping: [string, string][]; + toRemotePath: (localPath: string) => string; + toLocalPath: (remotePath: string) => string; + toRemoteUri: (localUri: vscode.Uri) => vscode.Uri; +} + // 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 spawn = originalSpawn; + export const asyncExec = promisify(exec); export const LSP_NAME = "Ruby LSP"; export const LOG_CHANNEL = vscode.window.createOutputChannel(LSP_NAME, { diff --git a/vscode/src/docker.ts b/vscode/src/docker.ts new file mode 100644 index 0000000000..3b959ac8d5 --- /dev/null +++ b/vscode/src/docker.ts @@ -0,0 +1,171 @@ +import path from "path"; + +import * as vscode from "vscode"; + +import { PathConverterInterface } from "./common"; +import { WorkspaceChannel } from "./workspaceChannel"; + +export interface ComposeConfig { + services: Record; + ["x-mutagen"]?: { sync: Record } | undefined; +} + +interface ComposeService { + volumes: ComposeVolume[]; +} + +interface ComposeVolume { + type: string; + source: string; + target: string; +} + +interface MutagenShare { + alpha: string; + beta: string; +} + +interface MutagenMount { + volume: string; + source: string; + target: string; +} + +type MutagenMountMapping = Record< + string, + { + source: string; + target: string; + } +>; + +export function fetchPathMapping( + config: ComposeConfig, + service: string, +): Record { + const mutagenMounts = fetchMutagenMounts(config["x-mutagen"]?.sync || {}); + + const bindings = fetchComposeBindings( + config.services[service]?.volumes || [], + mutagenMounts, + ); + + return bindings; +} + +export class ContainerPathConverter implements PathConverterInterface { + readonly pathMapping: [string, string][]; + private readonly outputChannel: WorkspaceChannel; + + constructor( + pathMapping: Record, + outputChannel: WorkspaceChannel, + ) { + this.pathMapping = Object.entries(pathMapping); + this.outputChannel = outputChannel; + } + + toRemotePath(path: string) { + for (const [local, remote] of this.pathMapping) { + if (path.startsWith(local)) { + const remotePath = path.replace(local, remote); + + this.outputChannel.debug( + `Converted toRemotePath ${path} to ${remotePath}`, + ); + + return path.replace(local, remote); + } + } + + return path; + } + + toLocalPath(path: string) { + for (const [local, remote] of this.pathMapping) { + if (path.startsWith(remote)) { + const localPath = path.replace(remote, local); + + this.outputChannel.debug( + `Converted toLocalPath ${path} to ${localPath}`, + ); + + return localPath; + } + } + + return path; + } + + toRemoteUri(localUri: vscode.Uri) { + const remotePath = this.toRemotePath(localUri.fsPath); + return vscode.Uri.file(remotePath); + } +} + +function fetchComposeBindings( + volumes: ComposeVolume[], + mutagenMounts: MutagenMountMapping, +): Record { + return volumes.reduce( + (acc: Record, volume: ComposeVolume) => { + if (volume.type === "bind") { + acc[volume.source] = volume.target; + } else if (volume.type === "volume") { + Object.entries(mutagenMounts).forEach( + ([ + mutagenVolume, + { source: mutagenSource, target: mutagenTarget }, + ]) => { + if (mutagenVolume.startsWith(`volume://${volume.source}/`)) { + const remotePath = path.resolve(volume.target, mutagenSource); + acc[mutagenTarget] = remotePath; + } + }, + ); + } + + return acc; + }, + {}, + ); +} + +function transformMutagenMount(alpha: string, beta: string): MutagenMount { + const [, ...path] = alpha.replace("volume://", "").split("/"); + const [volume, source] = + path.length > 0 ? [alpha, `./${path.join("/")}`] : [`${alpha}/`, "."]; + + return { volume, source, target: beta }; +} + +function fetchMutagenMounts( + sync: Record = {}, +): MutagenMountMapping { + return Object.entries(sync).reduce( + ( + acc: Record, + [name, { alpha, beta }]: [string, MutagenShare], + ) => { + if (name === "defaults") return acc; + + let mount: MutagenMount | null = null; + + if (alpha.startsWith("volume://")) { + mount = transformMutagenMount(alpha, beta); + } else if (beta.startsWith("volume://")) { + mount = transformMutagenMount(beta, alpha); + } + + if (mount) { + acc[mount.volume] = { + source: mount.source, + target: mount.target, + }; + } + + return acc; + }, + {}, + ); +} diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index a323187ce3..592bbc17f0 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -1,19 +1,22 @@ /* eslint-disable no-process-env */ import path from "path"; import os from "os"; +import { ExecOptions } from "child_process"; import * as vscode from "vscode"; +import { Executable } from "vscode-languageclient/node"; -import { asyncExec, RubyInterface } from "./common"; +import { asyncExec, PathConverterInterface, RubyInterface } from "./common"; import { WorkspaceChannel } from "./workspaceChannel"; import { Shadowenv } from "./ruby/shadowenv"; import { Chruby } from "./ruby/chruby"; -import { VersionManager } from "./ruby/versionManager"; +import { PathConverter, VersionManager } from "./ruby/versionManager"; import { Mise } from "./ruby/mise"; import { RubyInstaller } from "./ruby/rubyInstaller"; import { Rbenv } from "./ruby/rbenv"; import { Rvm } from "./ruby/rvm"; import { None } from "./ruby/none"; +import { Compose } from "./ruby/compose"; import { Custom } from "./ruby/custom"; import { Asdf } from "./ruby/asdf"; @@ -26,6 +29,7 @@ export enum ManagerIdentifier { Shadowenv = "shadowenv", Mise = "mise", RubyInstaller = "rubyInstaller", + Compose = "compose", None = "none", Custom = "custom", } @@ -47,6 +51,8 @@ export class Ruby implements RubyInterface { private readonly shell = process.env.SHELL?.replace(/(\s+)/g, "\\$1"); private _env: NodeJS.ProcessEnv = {}; + private _manager?: VersionManager; + private _pathConverter: PathConverterInterface = new PathConverter(); private _error = false; private readonly context: vscode.ExtensionContext; private readonly customBundleGemfile?: string; @@ -91,6 +97,14 @@ export class Ruby implements RubyInterface { } } + get pathConverter() { + return this._pathConverter; + } + + set pathConverter(pathConverter: PathConverterInterface) { + this._pathConverter = pathConverter; + } + get env() { return this._env; } @@ -171,6 +185,14 @@ export class Ruby implements RubyInterface { } } + runActivatedScript(command: string, options: ExecOptions = {}) { + return this._manager!.runActivatedScript(command, options); + } + + activateExecutable(executable: Executable) { + return this._manager!.activateExecutable(executable); + } + async manuallySelectRuby() { const manualSelection = await vscode.window.showInformationMessage( "Configure global or workspace specific fallback for the Ruby LSP?", @@ -228,9 +250,12 @@ export class Ruby implements RubyInterface { this.sanitizeEnvironment(env); + this.pathConverter = await manager.buildPathConverter(this.workspaceFolder); + // We need to set the process environment too to make other extensions such as Sorbet find the right Ruby paths process.env = env; this._env = env; + this._manager = manager; this.rubyVersion = version; this.yjitEnabled = (yjit && major > 3) || (major === 3 && minor >= 2); this.gemPath.push(...gemPath); @@ -332,6 +357,15 @@ export class Ruby implements RubyInterface { ), ); break; + case ManagerIdentifier.Compose: + await this.runActivation( + new Compose( + this.workspaceFolder, + this.outputChannel, + this.manuallySelectRuby.bind(this), + ), + ); + break; case ManagerIdentifier.Custom: await this.runActivation( new Custom( diff --git a/vscode/src/ruby/compose.ts b/vscode/src/ruby/compose.ts new file mode 100644 index 0000000000..f03fe95e7f --- /dev/null +++ b/vscode/src/ruby/compose.ts @@ -0,0 +1,180 @@ +/* eslint-disable no-process-env */ +import { ExecOptions } from "child_process"; +import path from "path"; + +import * as vscode from "vscode"; +import { Executable } from "vscode-languageclient/node"; + +import { + ComposeConfig, + ContainerPathConverter, + fetchPathMapping, +} from "../docker"; + +import { VersionManager, ActivationResult } from "./versionManager"; + +// Compose +// +// Compose Ruby environment activation can be used for all cases where an existing version manager does not suffice. +// Users are allowed to define a shell script that runs before calling ruby, giving them the chance to modify the PATH, +// GEM_HOME and GEM_PATH as needed to find the correct Ruby runtime. +export class Compose extends VersionManager { + protected composeConfig: ComposeConfig = { services: {} } as ComposeConfig; + + async activate(): Promise { + await this.ensureConfigured(); + + const parsedResult = await this.runEnvActivationScript( + `${this.composeRunCommand()} ${this.composeServiceName()} ruby`, + ); + + return { + env: { ...process.env }, + yjit: parsedResult.yjit, + version: parsedResult.version, + gemPath: parsedResult.gemPath, + }; + } + + runActivatedScript(command: string, options: ExecOptions = {}) { + return this.runScript( + `${this.composeRunCommand()} ${this.composeServiceName()} ${command}`, + options, + ); + } + + activateExecutable(executable: Executable) { + const composeCommand = this.parseCommand( + `${this.composeRunCommand()} ${this.composeServiceName()}`, + ); + + return { + command: composeCommand.command, + args: [ + ...composeCommand.args, + executable.command, + ...(executable.args || []), + ], + options: { + ...executable.options, + env: { ...(executable.options?.env || {}), ...composeCommand.env }, + }, + }; + } + + async buildPathConverter(workspaceFolder: vscode.WorkspaceFolder) { + const pathMapping = fetchPathMapping( + this.composeConfig, + this.composeServiceName(), + ); + + const stats = Object.entries(pathMapping).map(([local, remote]) => { + const absolute = path.resolve(workspaceFolder.uri.fsPath, local); + return vscode.workspace.fs.stat(vscode.Uri.file(absolute)).then( + (stat) => ({ stat, local, remote, absolute }), + () => ({ stat: undefined, local, remote, absolute }), + ); + }); + + const filteredMapping = (await Promise.all(stats)).reduce( + (acc, { stat, local, remote, absolute }) => { + if (stat?.type === vscode.FileType.Directory) { + this.outputChannel.info(`Path ${absolute} mapped to ${remote}`); + acc[absolute] = remote; + } else { + this.outputChannel.debug( + `Skipping path ${local} because it does not exist`, + ); + } + + return acc; + }, + {} as Record, + ); + + return new ContainerPathConverter(filteredMapping, this.outputChannel); + } + + protected composeRunCommand(): string { + return `${this.composeCommand()} run --rm -i`; + } + + protected composeServiceName(): string { + const service: string | undefined = vscode.workspace + .getConfiguration("rubyLsp.rubyVersionManager") + .get("composeService"); + + if (service === undefined) { + throw new Error( + "The composeService configuration must be set when 'compose' is selected as the version manager. \ + See the [README](https://shopify.github.io/ruby-lsp/version-managers.html) for instructions.", + ); + } + + return service; + } + + protected composeCommand(): string { + const composeCustomCommand: string | undefined = vscode.workspace + .getConfiguration("rubyLsp.rubyVersionManager") + .get("composeCustomCommand"); + + return ( + composeCustomCommand || + "docker --log-level=error compose --progress=quiet" + ); + } + + protected async ensureConfigured() { + this.composeConfig = await this.getComposeConfig(); + const services: vscode.QuickPickItem[] = []; + + const config = vscode.workspace.getConfiguration("rubyLsp"); + const currentService = config.get("rubyVersionManager.composeService") as + | string + | undefined; + + if (currentService && this.composeConfig.services[currentService]) { + return; + } + + for (const [name, _service] of Object.entries( + this.composeConfig.services, + )) { + services.push({ label: name }); + } + + const answer = await vscode.window.showQuickPick(services, { + title: "Select Docker Compose service where to run ruby-lsp", + ignoreFocusOut: true, + }); + + if (!answer) { + throw new Error("No compose service selected"); + } + + const managerConfig = config.inspect("rubyVersionManager"); + const workspaceConfig = managerConfig?.workspaceValue || {}; + + await config.update("rubyVersionManager", { + ...workspaceConfig, + ...{ composeService: answer.label }, + }); + } + + private async getComposeConfig(): Promise { + try { + const { stdout, stderr: _stderr } = await this.runScript( + `${this.composeCommand()} config --format=json`, + ); + + const config = JSON.parse(stdout) as ComposeConfig; + + return config; + } catch (error: any) { + throw new Error( + `Failed to read docker-compose configuration: ${error.message}`, + ); + } + } +} diff --git a/vscode/src/ruby/rubyInstaller.ts b/vscode/src/ruby/rubyInstaller.ts index 33f4912ab7..7bab203e0b 100644 --- a/vscode/src/ruby/rubyInstaller.ts +++ b/vscode/src/ruby/rubyInstaller.ts @@ -1,10 +1,9 @@ /* eslint-disable no-process-env */ import os from "os"; +import { ExecOptions } from "child_process"; import * as vscode from "vscode"; -import { asyncExec } from "../common"; - import { Chruby } from "./chruby"; interface RubyVersion { @@ -55,20 +54,14 @@ export class RubyInstaller extends Chruby { ); } - // Override the `runScript` method to ensure that we do not pass any `shell` to `asyncExec`. The activation script is - // only compatible with `cmd.exe`, and not Powershell, due to escaping of quotes. We need to ensure to always run the - // script on `cmd.exe`. - protected runScript(command: string) { - this.outputChannel.info( - `Running command: \`${command}\` in ${this.bundleUri.fsPath}`, - ); - this.outputChannel.debug( - `Environment used for command: ${JSON.stringify(process.env)}`, - ); - - return asyncExec(command, { + // Override the `execOptions` method to ensure that we do not pass any `shell` to `asyncExec`. The activation script + // is only compatible with `cmd.exe`, and not Powershell, due to escaping of quotes. We need to ensure to always run + // the script on `cmd.exe`. + protected execOptions(options: ExecOptions = {}): ExecOptions { + return { cwd: this.bundleUri.fsPath, - env: process.env, - }); + ...options, + env: { ...process.env, ...options.env }, + }; } } diff --git a/vscode/src/ruby/versionManager.ts b/vscode/src/ruby/versionManager.ts index f24837490d..85f6b3e8b5 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -1,11 +1,13 @@ /* eslint-disable no-process-env */ import path from "path"; import os from "os"; +import { ExecOptions } from "child_process"; import * as vscode from "vscode"; +import { Executable } from "vscode-languageclient/node"; import { WorkspaceChannel } from "../workspaceChannel"; -import { asyncExec } from "../common"; +import { asyncExec, PathConverterInterface, spawn } from "../common"; export interface ActivationResult { env: NodeJS.ProcessEnv; @@ -16,6 +18,22 @@ export interface ActivationResult { export const ACTIVATION_SEPARATOR = "RUBY_LSP_ACTIVATION_SEPARATOR"; +export class PathConverter implements PathConverterInterface { + readonly pathMapping: [string, string][] = []; + + toRemotePath(path: string) { + return path; + } + + toLocalPath(path: string) { + return path; + } + + toRemoteUri(localUri: vscode.Uri) { + return localUri; + } +} + export abstract class VersionManager { public activationScript = [ `STDERR.print("${ACTIVATION_SEPARATOR}" + `, @@ -59,9 +77,26 @@ export abstract class VersionManager { // language server abstract activate(): Promise; + runActivatedScript(command: string, options: ExecOptions = {}) { + return this.runScript(command, options); + } + + activateExecutable(executable: Executable) { + return executable; + } + + async buildPathConverter(_workspaceFolder: vscode.WorkspaceFolder) { + return new PathConverter(); + } + protected async runEnvActivationScript(activatedRuby: string) { - const result = await this.runScript( - `${activatedRuby} -W0 -rjson -e '${this.activationScript}'`, + const result = await this.runRubyCode( + `${activatedRuby} -W0 -rjson`, + this.activationScript, + ); + + this.outputChannel.debug( + `Activation script output: ${JSON.stringify(result, null, 2)}`, ); const activationContent = new RegExp( @@ -85,7 +120,72 @@ export abstract class VersionManager { // Runs the given command in the directory for the Bundle, using the user's preferred shell and inheriting the current // process environment - protected runScript(command: string) { + protected runScript(command: string, options: ExecOptions = {}) { + const execOptions = this.execOptions(options); + + this.outputChannel.info( + `Running command: \`${command}\` in ${execOptions.cwd} using shell: ${execOptions.shell}`, + ); + this.outputChannel.debug( + `Environment used for command: ${JSON.stringify(execOptions.env)}`, + ); + + return asyncExec(command, execOptions); + } + + protected runRubyCode( + rubyCommand: string, + code: string, + ): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + this.outputChannel.info( + `Ruby \`${rubyCommand}\` running Ruby code: \`${code}\``, + ); + + const { command, args, env } = this.parseCommand(rubyCommand); + const ruby = spawn(command, args, this.execOptions({ env })); + + let stdout = ""; + let stderr = ""; + + ruby.stdout.on("data", (data) => { + this.outputChannel.debug(`stdout: '${data.toString()}'`); + if (data.toString().includes("END_OF_RUBY_CODE_OUTPUT")) { + stdout += data.toString().replace(/END_OF_RUBY_CODE_OUTPUT.*/s, ""); + resolve({ stdout, stderr }); + } else { + stdout += data.toString(); + } + }); + ruby.stderr.on("data", (data) => { + this.outputChannel.debug(`stderr: '${data.toString()}'`); + stderr += data.toString(); + }); + ruby.on("error", (error) => { + reject(error); + }); + ruby.on("close", (status) => { + if (status) { + reject(new Error(`Process exited with status ${status}: ${stderr}`)); + } else { + resolve({ stdout, stderr }); + } + }); + + const script = [ + "begin", + ...code.split("\n").map((line) => ` ${line}`), + "ensure", + ' puts "END_OF_RUBY_CODE_OUTPUT"', + "end", + ].join("\n"); + + ruby.stdin.write(script); + ruby.stdin.end(); + }); + } + + protected execOptions(options: ExecOptions = {}): ExecOptions { let shell: string | undefined; // If the user has configured a default shell, we use that one since they are probably sourcing their version @@ -95,18 +195,43 @@ export abstract class VersionManager { shell = vscode.env.shell; } - this.outputChannel.info( - `Running command: \`${command}\` in ${this.bundleUri.fsPath} using shell: ${shell}`, - ); - this.outputChannel.debug( - `Environment used for command: ${JSON.stringify(process.env)}`, - ); - - return asyncExec(command, { + return { cwd: this.bundleUri.fsPath, shell, - env: process.env, - }); + ...options, + env: { ...process.env, ...options.env }, + }; + } + + // Parses a command string into its command, arguments, and environment variables + protected parseCommand(commandString: string): { + command: string; + args: string[]; + env: Record; + } { + // Regular expression to split arguments while respecting quotes + const regex = /(?:[^\s"']+|"[^"]*"|'[^']*')+/g; + + const parts = + commandString.match(regex)?.map((arg) => { + // Remove surrounding quotes, if any + return arg.replace(/^['"]|['"]$/g, ""); + }) ?? []; + + // Extract environment variables + const env: Record = {}; + while (parts[0] && parts[0].includes("=")) { + const [key, value] = parts.shift()?.split("=") ?? []; + if (key) { + env[key] = value || ""; + } + } + + // The first part is the command, the rest are arguments + const command = parts.shift() || ""; + const args = parts; + + return { command, args, env }; } // Tries to find `execName` within the given directories. Prefers the executables found in the given directories over diff --git a/vscode/src/rubyLsp.ts b/vscode/src/rubyLsp.ts index 6bc84d00e6..ea85a164f8 100644 --- a/vscode/src/rubyLsp.ts +++ b/vscode/src/rubyLsp.ts @@ -110,7 +110,7 @@ export class RubyLsp { } const decodedUri = decodeURIComponent(originalUri); - return this.virtualDocuments.get(decodedUri); + return this.virtualDocuments.get(decodedUri) || ""; }, }), LOG_CHANNEL, diff --git a/vscode/src/test/suite/ruby.test.ts b/vscode/src/test/suite/ruby.test.ts index e1c4abb348..5fb74b32cc 100644 --- a/vscode/src/test/suite/ruby.test.ts +++ b/vscode/src/test/suite/ruby.test.ts @@ -8,10 +8,10 @@ import sinon from "sinon"; import { Ruby, ManagerIdentifier } from "../../ruby"; import { WorkspaceChannel } from "../../workspaceChannel"; import { LOG_CHANNEL } from "../../common"; -import * as common from "../../common"; import { ACTIVATION_SEPARATOR } from "../../ruby/versionManager"; import { FAKE_TELEMETRY } from "./fakeTelemetry"; +import { createSpawnStub } from "./testHelpers"; suite("Ruby environment activation", () => { const workspacePath = path.dirname( @@ -130,8 +130,7 @@ suite("Ruby environment activation", () => { gemPath: ["~/.gem/ruby/3.3.5", "/opt/rubies/3.3.5/lib/ruby/gems/3.3.0"], }; - const execStub = sinon.stub(common, "asyncExec").resolves({ - stdout: "", + const { spawnStub } = createSpawnStub({ stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, }); @@ -142,7 +141,7 @@ suite("Ruby environment activation", () => { FAKE_TELEMETRY, ); await ruby.activateRuby(); - execStub.restore(); + spawnStub.restore(); configStub.restore(); assert.deepStrictEqual(ruby.gemPath, [ diff --git a/vscode/src/test/suite/ruby/asdf.test.ts b/vscode/src/test/suite/ruby/asdf.test.ts index 46a4449ad3..8eb1781e06 100644 --- a/vscode/src/test/suite/ruby/asdf.test.ts +++ b/vscode/src/test/suite/ruby/asdf.test.ts @@ -9,6 +9,7 @@ import { Asdf } from "../../../ruby/asdf"; import { WorkspaceChannel } from "../../../workspaceChannel"; import * as common from "../../../common"; import { ACTIVATION_SEPARATOR } from "../../../ruby/versionManager"; +import { createSpawnStub } from "../testHelpers"; suite("Asdf", () => { if (os.platform() === "win32") { @@ -17,6 +18,13 @@ suite("Asdf", () => { return; } + let spawnStub: sinon.SinonStub; + let stdinData: string[]; + + teardown(() => { + spawnStub?.restore(); + }); + test("Finds Ruby based on .tool-versions", async () => { // eslint-disable-next-line no-process-env const workspacePath = process.env.PWD!; @@ -33,10 +41,9 @@ suite("Asdf", () => { version: "3.0.0", }; - const execStub = sinon.stub(common, "asyncExec").resolves({ - stdout: "", + ({ spawnStub, stdinData } = createSpawnStub({ stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - }); + })); const findInstallationStub = sinon .stub(asdf, "findAsdfInstallation") @@ -46,8 +53,17 @@ suite("Asdf", () => { const { env, version, yjit } = await asdf.activate(); assert.ok( - execStub.calledOnceWithExactly( - `. ${os.homedir()}/.asdf/asdf.sh && asdf exec ruby -W0 -rjson -e '${asdf.activationScript}'`, + spawnStub.calledOnceWithExactly( + ".", + [ + `${os.homedir()}/.asdf/asdf.sh`, + "&&", + "asdf", + "exec", + "ruby", + "-W0", + "-rjson", + ], { cwd: workspacePath, shell: "/bin/bash", @@ -57,11 +73,12 @@ suite("Asdf", () => { ), ); + assert.ok(stdinData.join("\n").includes(asdf.activationScript)); + assert.strictEqual(version, "3.0.0"); assert.strictEqual(yjit, true); assert.strictEqual(env.ANY, "true"); - execStub.restore(); findInstallationStub.restore(); shellStub.restore(); }); @@ -82,10 +99,9 @@ suite("Asdf", () => { version: "3.0.0", }; - const execStub = sinon.stub(common, "asyncExec").resolves({ - stdout: "", + ({ spawnStub, stdinData } = createSpawnStub({ stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - }); + })); const findInstallationStub = sinon .stub(asdf, "findAsdfInstallation") @@ -97,8 +113,17 @@ suite("Asdf", () => { const { env, version, yjit } = await asdf.activate(); assert.ok( - execStub.calledOnceWithExactly( - `. ${os.homedir()}/.asdf/asdf.fish && asdf exec ruby -W0 -rjson -e '${asdf.activationScript}'`, + spawnStub.calledOnceWithExactly( + ".", + [ + `${os.homedir()}/.asdf/asdf.fish`, + "&&", + "asdf", + "exec", + "ruby", + "-W0", + "-rjson", + ], { cwd: workspacePath, shell: "/opt/homebrew/bin/fish", @@ -108,11 +133,12 @@ suite("Asdf", () => { ), ); + assert.ok(stdinData.join("\n").includes(asdf.activationScript)); + assert.strictEqual(version, "3.0.0"); assert.strictEqual(yjit, true); assert.strictEqual(env.ANY, "true"); - execStub.restore(); findInstallationStub.restore(); shellStub.restore(); }); diff --git a/vscode/src/test/suite/ruby/compose.test.ts b/vscode/src/test/suite/ruby/compose.test.ts new file mode 100644 index 0000000000..2aff78c8cd --- /dev/null +++ b/vscode/src/test/suite/ruby/compose.test.ts @@ -0,0 +1,126 @@ +import assert from "assert"; +import path from "path"; +import fs from "fs"; +import os from "os"; + +import * as vscode from "vscode"; +import sinon from "sinon"; + +import { WorkspaceChannel } from "../../../workspaceChannel"; +import { Compose } from "../../../ruby/compose"; +import * as common from "../../../common"; +import { ACTIVATION_SEPARATOR } from "../../../ruby/versionManager"; +import { createSpawnStub } from "../testHelpers"; +import { ComposeConfig } from "../../../docker"; + +suite("Compose", () => { + let spawnStub: sinon.SinonStub; + let execStub: sinon.SinonStub; + let configStub: sinon.SinonStub; + let stdinData: string[]; + + let workspacePath: string; + let workspaceFolder: vscode.WorkspaceFolder; + let outputChannel: WorkspaceChannel; + + const composeService = "develop"; + const composeConfig: ComposeConfig = { + services: { [composeService]: { volumes: [] } }, + }; + + setup(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + workspaceFolder = { + uri: vscode.Uri.file(workspacePath), + name: path.basename(workspacePath), + index: 0, + }; + outputChannel = new WorkspaceChannel("fake", common.LOG_CHANNEL); + }); + + teardown(() => { + spawnStub?.restore(); + execStub?.restore(); + configStub?.restore(); + + fs.rmSync(workspacePath, { recursive: true, force: true }); + }); + + test("Activates Ruby environment using Docker Compose", async () => { + const compose = new Compose(workspaceFolder, outputChannel, async () => {}); + + const envStub = { + env: { ANY: "true" }, + yjit: true, + version: "3.0.0", + }; + + ({ spawnStub, stdinData } = createSpawnStub({ + stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, + })); + + execStub = sinon + .stub(common, "asyncExec") + .resolves({ stdout: JSON.stringify(composeConfig), stderr: "" }); + + configStub = sinon.stub(vscode.workspace, "getConfiguration").returns({ + get: (name: string) => { + if ( + name === "composeService" || + name === "rubyVersionManager.composeService" + ) { + return composeService; + } else if (name === "rubyVersionManager") { + return { composeService }; + } + return undefined; + }, + } as any); + + const { version, yjit } = await compose.activate(); + + // We must not set the shell on Windows + const shell = os.platform() === "win32" ? undefined : vscode.env.shell; + + assert.ok( + spawnStub.calledOnceWithExactly( + "docker", + [ + "--log-level=error", + "compose", + "--progress=quiet", + "run", + "--rm", + "-i", + composeService, + "ruby", + "-W0", + "-rjson", + ], + { + cwd: workspaceFolder.uri.fsPath, + shell, + // eslint-disable-next-line no-process-env + env: process.env, + }, + ), + ); + + assert.ok( + execStub.calledOnceWithExactly( + "docker --log-level=error compose --progress=quiet config --format=json", + { + cwd: workspaceFolder.uri.fsPath, + shell, + // eslint-disable-next-line no-process-env + env: process.env, + }, + ), + ); + + assert.ok(stdinData.join("\n").includes(compose.activationScript)); + + assert.strictEqual(version, "3.0.0"); + assert.strictEqual(yjit, true); + }); +}); diff --git a/vscode/src/test/suite/ruby/custom.test.ts b/vscode/src/test/suite/ruby/custom.test.ts index 5e1482d1cc..925a4a9087 100644 --- a/vscode/src/test/suite/ruby/custom.test.ts +++ b/vscode/src/test/suite/ruby/custom.test.ts @@ -10,8 +10,16 @@ import { Custom } from "../../../ruby/custom"; import { WorkspaceChannel } from "../../../workspaceChannel"; import * as common from "../../../common"; import { ACTIVATION_SEPARATOR } from "../../../ruby/versionManager"; +import { createSpawnStub } from "../testHelpers"; suite("Custom", () => { + let spawnStub: sinon.SinonStub; + let stdinData: string[]; + + teardown(() => { + spawnStub?.restore(); + }); + test("Invokes custom script and then Ruby", async () => { const workspacePath = fs.mkdtempSync( path.join(os.tmpdir(), "ruby-lsp-test-"), @@ -31,10 +39,9 @@ suite("Custom", () => { version: "3.0.0", }; - const execStub = sinon.stub(common, "asyncExec").resolves({ - stdout: "", + ({ spawnStub, stdinData } = createSpawnStub({ stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - }); + })); const commandStub = sinon .stub(custom, "customCommand") @@ -45,8 +52,9 @@ suite("Custom", () => { const shell = os.platform() === "win32" ? undefined : vscode.env.shell; assert.ok( - execStub.calledOnceWithExactly( - `my_version_manager activate_env && ruby -W0 -rjson -e '${custom.activationScript}'`, + spawnStub.calledOnceWithExactly( + "my_version_manager", + ["activate_env", "&&", "ruby", "-W0", "-rjson"], { cwd: uri.fsPath, shell, @@ -56,11 +64,12 @@ suite("Custom", () => { ), ); + assert.ok(stdinData.join("\n").includes(custom.activationScript)); + assert.strictEqual(version, "3.0.0"); assert.strictEqual(yjit, true); assert.deepStrictEqual(env.ANY, "true"); - execStub.restore(); commandStub.restore(); fs.rmSync(workspacePath, { recursive: true, force: true }); }); diff --git a/vscode/src/test/suite/ruby/mise.test.ts b/vscode/src/test/suite/ruby/mise.test.ts index 5d31fa2442..c790d85f24 100644 --- a/vscode/src/test/suite/ruby/mise.test.ts +++ b/vscode/src/test/suite/ruby/mise.test.ts @@ -10,6 +10,7 @@ import { Mise } from "../../../ruby/mise"; import { WorkspaceChannel } from "../../../workspaceChannel"; import * as common from "../../../common"; import { ACTIVATION_SEPARATOR } from "../../../ruby/versionManager"; +import { createSpawnStub } from "../testHelpers"; suite("Mise", () => { if (os.platform() === "win32") { @@ -18,6 +19,13 @@ suite("Mise", () => { return; } + let spawnStub: sinon.SinonStub; + let stdinData: string[]; + + teardown(() => { + spawnStub?.restore(); + }); + test("Finds Ruby only binary path is appended to PATH", async () => { // eslint-disable-next-line no-process-env const workspacePath = process.env.PWD!; @@ -35,10 +43,10 @@ suite("Mise", () => { version: "3.0.0", }; - const execStub = sinon.stub(common, "asyncExec").resolves({ - stdout: "", + ({ spawnStub, stdinData } = createSpawnStub({ stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - }); + })); + const findStub = sinon .stub(mise, "findMiseUri") .resolves( @@ -53,8 +61,9 @@ suite("Mise", () => { const { env, version, yjit } = await mise.activate(); assert.ok( - execStub.calledOnceWithExactly( - `${os.homedir()}/.local/bin/mise x -- ruby -W0 -rjson -e '${mise.activationScript}'`, + spawnStub.calledOnceWithExactly( + `${os.homedir()}/.local/bin/mise`, + ["x", "--", "ruby", "-W0", "-rjson"], { cwd: workspacePath, shell: vscode.env.shell, @@ -64,11 +73,12 @@ suite("Mise", () => { ), ); + assert.ok(stdinData.join("\n").includes(mise.activationScript)); + assert.strictEqual(version, "3.0.0"); assert.strictEqual(yjit, true); assert.deepStrictEqual(env.ANY, "true"); - execStub.restore(); findStub.restore(); }); @@ -90,10 +100,9 @@ suite("Mise", () => { version: "3.0.0", }; - const execStub = sinon.stub(common, "asyncExec").resolves({ - stdout: "", + ({ spawnStub, stdinData } = createSpawnStub({ stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - }); + })); const misePath = path.join(workspacePath, "mise"); fs.writeFileSync(misePath, "fakeMiseBinary"); @@ -112,8 +121,9 @@ suite("Mise", () => { const { env, version, yjit } = await mise.activate(); assert.ok( - execStub.calledOnceWithExactly( - `${misePath} x -- ruby -W0 -rjson -e '${mise.activationScript}'`, + spawnStub.calledOnceWithExactly( + misePath, + ["x", "--", "ruby", "-W0", "-rjson"], { cwd: workspacePath, shell: vscode.env.shell, @@ -123,11 +133,12 @@ suite("Mise", () => { ), ); + assert.ok(stdinData.join("\n").includes(mise.activationScript)); + assert.strictEqual(version, "3.0.0"); assert.strictEqual(yjit, true); assert.deepStrictEqual(env.ANY, "true"); - execStub.restore(); configStub.restore(); fs.rmSync(workspacePath, { recursive: true, force: true }); }); diff --git a/vscode/src/test/suite/ruby/none.test.ts b/vscode/src/test/suite/ruby/none.test.ts index f42fbaf648..c7edf4e3a5 100644 --- a/vscode/src/test/suite/ruby/none.test.ts +++ b/vscode/src/test/suite/ruby/none.test.ts @@ -4,14 +4,21 @@ import fs from "fs"; import os from "os"; import * as vscode from "vscode"; -import sinon from "sinon"; import { None } from "../../../ruby/none"; import { WorkspaceChannel } from "../../../workspaceChannel"; import * as common from "../../../common"; import { ACTIVATION_SEPARATOR } from "../../../ruby/versionManager"; +import { createSpawnStub } from "../testHelpers"; suite("None", () => { + let spawnStub: sinon.SinonStub; + let stdinData: string[]; + + teardown(() => { + spawnStub?.restore(); + }); + test("Invokes Ruby directly", async () => { const workspacePath = fs.mkdtempSync( path.join(os.tmpdir(), "ruby-lsp-test-"), @@ -31,10 +38,9 @@ suite("None", () => { version: "3.0.0", }; - const execStub = sinon.stub(common, "asyncExec").resolves({ - stdout: "", + ({ spawnStub, stdinData } = createSpawnStub({ stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - }); + })); const { env, version, yjit } = await none.activate(); @@ -42,22 +48,20 @@ suite("None", () => { const shell = os.platform() === "win32" ? undefined : vscode.env.shell; assert.ok( - execStub.calledOnceWithExactly( - `ruby -W0 -rjson -e '${none.activationScript}'`, - { - cwd: uri.fsPath, - shell, - // eslint-disable-next-line no-process-env - env: process.env, - }, - ), + spawnStub.calledOnceWithExactly("ruby", ["-W0", "-rjson"], { + cwd: uri.fsPath, + shell, + // eslint-disable-next-line no-process-env + env: process.env, + }), ); + assert.ok(stdinData.join("\n").includes(none.activationScript)); + assert.strictEqual(version, "3.0.0"); assert.strictEqual(yjit, true); assert.deepStrictEqual(env.ANY, "true"); - execStub.restore(); fs.rmSync(workspacePath, { recursive: true, force: true }); }); }); diff --git a/vscode/src/test/suite/ruby/rbenv.test.ts b/vscode/src/test/suite/ruby/rbenv.test.ts index 1463c2f3ec..816b629788 100644 --- a/vscode/src/test/suite/ruby/rbenv.test.ts +++ b/vscode/src/test/suite/ruby/rbenv.test.ts @@ -10,6 +10,7 @@ import { Rbenv } from "../../../ruby/rbenv"; import { WorkspaceChannel } from "../../../workspaceChannel"; import * as common from "../../../common"; import { ACTIVATION_SEPARATOR } from "../../../ruby/versionManager"; +import { createSpawnStub } from "../testHelpers"; suite("Rbenv", () => { if (os.platform() === "win32") { @@ -18,6 +19,13 @@ suite("Rbenv", () => { return; } + let spawnStub: sinon.SinonStub; + let stdinData: string[]; + + teardown(() => { + spawnStub?.restore(); + }); + test("Finds Ruby based on .ruby-version", async () => { // eslint-disable-next-line no-process-env const workspacePath = process.env.PWD!; @@ -35,16 +43,16 @@ suite("Rbenv", () => { version: "3.0.0", }; - const execStub = sinon.stub(common, "asyncExec").resolves({ - stdout: "", + ({ spawnStub, stdinData } = createSpawnStub({ stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - }); + })); const { env, version, yjit } = await rbenv.activate(); assert.ok( - execStub.calledOnceWithExactly( - `rbenv exec ruby -W0 -rjson -e '${rbenv.activationScript}'`, + spawnStub.calledOnceWithExactly( + "rbenv", + ["exec", "ruby", "-W0", "-rjson"], { cwd: workspacePath, shell: vscode.env.shell, @@ -54,10 +62,11 @@ suite("Rbenv", () => { ), ); + assert.ok(stdinData.join("\n").includes(rbenv.activationScript)); + assert.strictEqual(version, "3.0.0"); assert.strictEqual(yjit, true); assert.strictEqual(env.ANY, "true"); - execStub.restore(); }); test("Allows configuring where rbenv is installed", async () => { @@ -78,10 +87,9 @@ suite("Rbenv", () => { version: "3.0.0", }; - const execStub = sinon.stub(common, "asyncExec").resolves({ - stdout: "", + ({ spawnStub, stdinData } = createSpawnStub({ stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - }); + })); const rbenvPath = path.join(workspacePath, "rbenv"); fs.writeFileSync(rbenvPath, "fakeRbenvBinary"); @@ -100,8 +108,9 @@ suite("Rbenv", () => { const { env, version, yjit } = await rbenv.activate(); assert.ok( - execStub.calledOnceWithExactly( - `${rbenvPath} exec ruby -W0 -rjson -e '${rbenv.activationScript}'`, + spawnStub.calledOnceWithExactly( + rbenvPath, + ["exec", "ruby", "-W0", "-rjson"], { cwd: workspacePath, shell: vscode.env.shell, @@ -111,11 +120,12 @@ suite("Rbenv", () => { ), ); + assert.ok(stdinData.join("\n").includes(rbenv.activationScript)); + assert.strictEqual(version, "3.0.0"); assert.strictEqual(yjit, true); assert.deepStrictEqual(env.ANY, "true"); - execStub.restore(); configStub.restore(); fs.rmSync(workspacePath, { recursive: true, force: true }); }); @@ -131,10 +141,9 @@ suite("Rbenv", () => { const outputChannel = new WorkspaceChannel("fake", common.LOG_CHANNEL); const rbenv = new Rbenv(workspaceFolder, outputChannel, async () => {}); - const execStub = sinon.stub(common, "asyncExec").resolves({ - stdout: "", + ({ spawnStub, stdinData } = createSpawnStub({ stderr: `${ACTIVATION_SEPARATOR}not a json${ACTIVATION_SEPARATOR}`, - }); + })); const errorStub = sinon.stub(outputChannel, "error"); @@ -144,8 +153,9 @@ suite("Rbenv", () => { ); assert.ok( - execStub.calledOnceWithExactly( - `rbenv exec ruby -W0 -rjson -e '${rbenv.activationScript}'`, + spawnStub.calledOnceWithExactly( + "rbenv", + ["exec", "ruby", "-W0", "-rjson"], { cwd: workspacePath, shell: vscode.env.shell, @@ -155,13 +165,14 @@ suite("Rbenv", () => { ), ); + assert.ok(stdinData.join("\n").includes(rbenv.activationScript)); + assert.ok( errorStub.calledOnceWithExactly( "Tried parsing invalid JSON environment: not a json", ), ); - execStub.restore(); errorStub.restore(); }); }); diff --git a/vscode/src/test/suite/ruby/rvm.test.ts b/vscode/src/test/suite/ruby/rvm.test.ts index a1ff3c5b4a..475860d60a 100644 --- a/vscode/src/test/suite/ruby/rvm.test.ts +++ b/vscode/src/test/suite/ruby/rvm.test.ts @@ -10,6 +10,7 @@ import { Rvm } from "../../../ruby/rvm"; import { WorkspaceChannel } from "../../../workspaceChannel"; import * as common from "../../../common"; import { ACTIVATION_SEPARATOR } from "../../../ruby/versionManager"; +import { createSpawnStub } from "../testHelpers"; suite("RVM", () => { if (os.platform() === "win32") { @@ -18,6 +19,13 @@ suite("RVM", () => { return; } + let spawnStub: sinon.SinonStub; + let stdinData: string[]; + + teardown(() => { + spawnStub?.restore(); + }); + test("Populates the gem env and path", async () => { const workspacePath = process.env.PWD!; const workspaceFolder = { @@ -47,16 +55,16 @@ suite("RVM", () => { version: "3.0.0", }; - const execStub = sinon.stub(common, "asyncExec").resolves({ - stdout: "", + ({ spawnStub, stdinData } = createSpawnStub({ stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - }); + })); const { env, version, yjit } = await rvm.activate(); assert.ok( - execStub.calledOnceWithExactly( - `${path.join(os.homedir(), ".rvm", "bin", "rvm-auto-ruby")} -W0 -rjson -e '${rvm.activationScript}'`, + spawnStub.calledOnceWithExactly( + path.join(os.homedir(), ".rvm", "bin", "rvm-auto-ruby"), + ["-W0", "-rjson"], { cwd: workspacePath, shell: vscode.env.shell, @@ -65,11 +73,12 @@ suite("RVM", () => { ), ); + assert.ok(stdinData.join("\n").includes(rvm.activationScript)); + assert.strictEqual(version, "3.0.0"); assert.strictEqual(yjit, true); assert.deepStrictEqual(env.ANY, "true"); - execStub.restore(); installationPathStub.restore(); }); }); diff --git a/vscode/src/test/suite/ruby/shadowenv.test.ts b/vscode/src/test/suite/ruby/shadowenv.test.ts index 0ba34ceab4..7381a37444 100644 --- a/vscode/src/test/suite/ruby/shadowenv.test.ts +++ b/vscode/src/test/suite/ruby/shadowenv.test.ts @@ -13,6 +13,7 @@ import { WorkspaceChannel } from "../../../workspaceChannel"; import { LOG_CHANNEL, asyncExec } from "../../../common"; import { RUBY_VERSION } from "../../rubyVersion"; import * as common from "../../../common"; +import { createSpawnStub } from "../testHelpers"; suite("Shadowenv", () => { if (os.platform() === "win32") { @@ -26,6 +27,8 @@ suite("Shadowenv", () => { let workspaceFolder: vscode.WorkspaceFolder; let outputChannel: WorkspaceChannel; let rubyBinPath: string; + let spawnStub: sinon.SinonStub; + const [major, minor, patch] = RUBY_VERSION.split("."); if (process.env.CI && os.platform() === "linux") { @@ -111,6 +114,8 @@ suite("Shadowenv", () => { afterEach(() => { fs.rmSync(rootPath, { recursive: true, force: true }); + + spawnStub?.restore(); }); test("Finds Ruby only binary path is appended to PATH", async () => { @@ -226,12 +231,13 @@ suite("Shadowenv", () => { async () => {}, ); - // First, reject the call to `shadowenv exec`. Then resolve the call to `which shadowenv` to return nothing + // First, reject the call to `shadowenv exec`. + ({ spawnStub } = createSpawnStub()); + spawnStub.rejects(new Error("shadowenv: command not found")); + + // Then reject the call to `shadowenv --version` const execStub = sinon .stub(common, "asyncExec") - .onFirstCall() - .rejects(new Error("shadowenv: command not found")) - .onSecondCall() .rejects(new Error("shadowenv: command not found")); await assert.rejects(async () => { diff --git a/vscode/src/test/suite/testHelpers.ts b/vscode/src/test/suite/testHelpers.ts new file mode 100644 index 0000000000..65d7cf4f13 --- /dev/null +++ b/vscode/src/test/suite/testHelpers.ts @@ -0,0 +1,59 @@ +import { EventEmitter, Readable, Writable } from "stream"; +import { SpawnOptions } from "child_process"; + +import sinon from "sinon"; + +import * as common from "../../common"; + +interface SpawnStubOptions { + stdout?: string; + stderr?: string; + exitCode?: number; +} + +export function createSpawnStub({ + stdout = "", + stderr = "", + exitCode = 0, +}: SpawnStubOptions = {}): { spawnStub: sinon.SinonStub; stdinData: string[] } { + const stdinData: string[] = []; + + const spawnStub = sinon + .stub(common, "spawn") + .callsFake( + ( + _command: string, + _args: ReadonlyArray, + _options: SpawnOptions, + ) => { + const childProcess = new EventEmitter() as any; + + childProcess.stdout = new Readable({ + read() {}, + }); + childProcess.stderr = new Readable({ + read() {}, + }); + childProcess.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdinData.push(chunk.toString()); + callback(); + }, + final(callback) { + process.nextTick(() => { + childProcess.stdout.emit("data", stdout); + childProcess.stderr.emit("data", stderr); + + childProcess.emit("close", exitCode); + }); + + callback(); + }, + }); + + return childProcess; + }, + ); + + return { spawnStub, stdinData }; +} diff --git a/vscode/src/workspace.ts b/vscode/src/workspace.ts index 5fcf552201..e6efdbaa5b 100644 --- a/vscode/src/workspace.ts +++ b/vscode/src/workspace.ts @@ -6,7 +6,6 @@ import { CodeLens, State } from "vscode-languageclient/node"; import { Ruby } from "./ruby"; import Client from "./client"; import { - asyncExec, LOG_CHANNEL, WorkspaceInterface, STATUS_EMITTER, @@ -268,16 +267,19 @@ export class Workspace implements WorkspaceInterface { "sorbet-runtime", ]; - const { stdout } = await asyncExec(`gem list ${dependencies.join(" ")}`, { - cwd: this.workspaceFolder.uri.fsPath, - env: this.ruby.env, - }); + const { stdout } = await this.ruby.runActivatedScript( + `gem list ${dependencies.join(" ")}`, + { + cwd: this.workspaceFolder.uri.fsPath, + env: this.ruby.env, + }, + ); // If any of the Ruby LSP's dependencies are missing, we need to install them. For example, if the user runs `gem // uninstall prism`, then we must ensure it's installed or else rubygems will fail when trying to launch the // executable if (!dependencies.every((dep) => new RegExp(`${dep}\\s`).exec(stdout))) { - await asyncExec("gem install ruby-lsp", { + await this.ruby.runActivatedScript("gem install ruby-lsp", { cwd: this.workspaceFolder.uri.fsPath, env: this.ruby.env, }); @@ -311,7 +313,7 @@ export class Workspace implements WorkspaceInterface { Date.now() - lastUpdatedAt > oneDayInMs ) { try { - await asyncExec("gem update ruby-lsp", { + await this.ruby.runActivatedScript("gem update ruby-lsp", { cwd: this.workspaceFolder.uri.fsPath, env: this.ruby.env, }); @@ -347,7 +349,7 @@ export class Workspace implements WorkspaceInterface { this.outputChannel.info(`Running "${command}"`); } - const result = await asyncExec(command, { + const result = await this.ruby.runActivatedScript(command, { env: this.ruby.env, cwd: this.workspaceFolder.uri.fsPath, });