diff --git a/vscode/src/client.ts b/vscode/src/client.ts index a9cbf92aa7..d388d80ac1 100644 --- a/vscode/src/client.ts +++ b/vscode/src/client.ts @@ -279,7 +279,7 @@ function collectClientOptions( }); } - outputChannel.info( + outputChannel.debug( `Document Selector Paths: ${JSON.stringify(documentSelector)}`, ); diff --git a/vscode/src/common.ts b/vscode/src/common.ts index 02173c7ef9..54b7e64cea 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -4,6 +4,7 @@ import { promisify } from "util"; import * as vscode from "vscode"; import { State } from "vscode-languageclient"; +import { Executable } from "vscode-languageclient/node"; export enum Command { Start = "rubyLsp.start", @@ -70,6 +71,22 @@ export interface PathConverterInterface { toRemoteUri: (localUri: vscode.Uri) => vscode.Uri; } +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; + } +} + // Event emitter used to signal that the language status items need to be refreshed export const STATUS_EMITTER = new vscode.EventEmitter< WorkspaceInterface | undefined @@ -152,3 +169,29 @@ export function featureEnabled(feature: keyof typeof FEATURE_FLAGS): boolean { // If that number is below the percentage, then the feature is enabled for this user return hashNum < percentage; } + +export function parseCommand(commandString: string): Executable { + // 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, options: { env } }; +} diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index 592bbc17f0..70b46aa560 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -1,16 +1,21 @@ /* 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, PathConverterInterface, RubyInterface } from "./common"; +import { Executable, ExecutableOptions } from "vscode-languageclient/node"; + +import { + asyncExec, + parseCommand, + PathConverter, + PathConverterInterface, + RubyInterface, +} from "./common"; import { WorkspaceChannel } from "./workspaceChannel"; import { Shadowenv } from "./ruby/shadowenv"; import { Chruby } from "./ruby/chruby"; -import { PathConverter, VersionManager } from "./ruby/versionManager"; +import { VersionManager } from "./ruby/versionManager"; import { Mise } from "./ruby/mise"; import { RubyInstaller } from "./ruby/rubyInstaller"; import { Rbenv } from "./ruby/rbenv"; @@ -51,9 +56,9 @@ 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 _pathConverter: PathConverterInterface; + private _wrapCommand: (executable: Executable) => Executable; private readonly context: vscode.ExtensionContext; private readonly customBundleGemfile?: string; private readonly outputChannel: WorkspaceChannel; @@ -69,6 +74,8 @@ export class Ruby implements RubyInterface { this.workspaceFolder = workspaceFolder; this.outputChannel = outputChannel; this.telemetry = telemetry; + this._pathConverter = new PathConverter(); + this._wrapCommand = (executable: Executable) => executable; const customBundleGemfile: string = vscode.workspace .getConfiguration("rubyLsp") @@ -101,10 +108,6 @@ export class Ruby implements RubyInterface { return this._pathConverter; } - set pathConverter(pathConverter: PathConverterInterface) { - this._pathConverter = pathConverter; - } - get env() { return this._env; } @@ -185,12 +188,22 @@ export class Ruby implements RubyInterface { } } - runActivatedScript(command: string, options: ExecOptions = {}) { - return this._manager!.runActivatedScript(command, options); + runActivatedScript(script: string, options: ExecutableOptions = {}) { + const parsedExecutable = parseCommand(script); + const executable = this.activateExecutable({ + ...parsedExecutable, + options, + }); + const command = [executable.command, ...(executable.args || [])].join(" "); + + return asyncExec(command, { + cwd: this.workspaceFolder.uri.fsPath || executable.options?.cwd, + env: { ...process.env, ...executable.options?.env }, + }); } activateExecutable(executable: Executable) { - return this._manager!.activateExecutable(executable); + return this._wrapCommand(executable); } async manuallySelectRuby() { @@ -245,20 +258,25 @@ export class Ruby implements RubyInterface { } private async runActivation(manager: VersionManager) { - const { env, version, yjit, gemPath } = await manager.activate(); + const { env, version, yjit, gemPath, pathConverter, wrapCommand } = + await manager.activate(); const [major, minor, _patch] = version.split(".").map(Number); 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); + + if (pathConverter) { + this._pathConverter = pathConverter; + } + if (wrapCommand) { + this._wrapCommand = wrapCommand; + } } // Fetch information related to the Ruby version. This can only be invoked after activation, so that `rubyVersion` is diff --git a/vscode/src/ruby/compose.ts b/vscode/src/ruby/compose.ts index 6996942d79..53a5b81200 100644 --- a/vscode/src/ruby/compose.ts +++ b/vscode/src/ruby/compose.ts @@ -1,6 +1,8 @@ /* eslint-disable no-process-env */ -import { ExecOptions } from "child_process"; import path from "path"; +import os from "os"; +import { StringDecoder } from "string_decoder"; +import { ExecOptions } from "child_process"; import * as vscode from "vscode"; import { Executable } from "vscode-languageclient/node"; @@ -10,8 +12,13 @@ import { ContainerPathConverter, fetchPathMapping, } from "../docker"; +import { parseCommand, spawn } from "../common"; -import { VersionManager, ActivationResult } from "./versionManager"; +import { + VersionManager, + ActivationResult, + ACTIVATION_SEPARATOR, +} from "./versionManager"; // Compose // @@ -24,52 +31,63 @@ export class Compose extends VersionManager { async activate(): Promise { await this.ensureConfigured(); - const parsedResult = await this.runEnvActivationScript( - `${this.composeRunCommand()} ${this.composeServiceName()} ruby`, + const rubyCommand = `${this.composeRunCommand()} ${this.composeServiceName()} ruby -W0 -rjson`; + const { stderr: output } = await this.runRubyCode( + rubyCommand, + this.activationScript, ); + this.outputChannel.debug(`Activation output: ${output}`); + + const activationContent = new RegExp( + `${ACTIVATION_SEPARATOR}(.*)${ACTIVATION_SEPARATOR}`, + ).exec(output); + + const parsedResult = this.parseWithErrorHandling(activationContent![1]); + const pathConverter = await this.buildPathConverter(); + + const wrapCommand = (executable: Executable) => { + const composeCommad = parseCommand( + `${this.composeRunCommand()} ${this.composeServiceName()}`, + ); + + const command = { + command: composeCommad.command, + args: [ + ...(composeCommad.args ?? []), + executable.command, + ...(executable.args ?? []), + ], + options: { + ...executable.options, + env: { + ...executable.options?.env, + ...composeCommad.options?.env, + }, + }, + }; + + return command; + }; + return { env: { ...process.env }, yjit: parsedResult.yjit, version: parsedResult.version, gemPath: parsedResult.gemPath, + pathConverter, + wrapCommand, }; } - 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) { + protected async buildPathConverter() { const pathMapping = fetchPathMapping( this.composeConfig, this.composeServiceName(), ); const stats = Object.entries(pathMapping).map(([local, remote]) => { - const absolute = path.resolve(workspaceFolder.uri.fsPath, local); + const absolute = path.resolve(this.workspaceFolder.uri.fsPath, local); return vscode.workspace.fs.stat(vscode.Uri.file(absolute)).then( (stat) => ({ stat, local, remote, absolute }), () => ({ stat: undefined, local, remote, absolute }), @@ -162,6 +180,83 @@ export class Compose extends VersionManager { }); } + 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, + options: { env } = { env: {} }, + } = parseCommand(rubyCommand); + const ruby = spawn(command, args, this.execOptions({ env })); + + let stdout = ""; + let stderr = ""; + + const stdoutDecoder = new StringDecoder("utf-8"); + const stderrDecoder = new StringDecoder("utf-8"); + + ruby.stdout.on("data", (data) => { + stdout += stdoutDecoder.write(data); + + if (stdout.includes("END_OF_RUBY_CODE_OUTPUT")) { + stdout = stdout.replace(/END_OF_RUBY_CODE_OUTPUT.*/s, ""); + resolve({ stdout, stderr }); + } + }); + ruby.stderr.on("data", (data) => { + stderr += stderrDecoder.write(data); + }); + 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"); + + this.outputChannel.info(`Running Ruby code:\n${script}`); + + 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 + // manager scripts in that shell's configuration files. On Windows, we never set the shell no matter what to ensure + // that activation runs on `cmd.exe` and not PowerShell, which avoids complex quoting and escaping issues. + if (vscode.env.shell.length > 0 && os.platform() !== "win32") { + shell = vscode.env.shell; + } + + return { + cwd: this.bundleUri.fsPath, + shell, + ...options, + env: { ...process.env, ...options.env }, + }; + } + private async getComposeConfig(): Promise { try { const { stdout, stderr: _stderr } = await this.runScript( diff --git a/vscode/src/ruby/rubyInstaller.ts b/vscode/src/ruby/rubyInstaller.ts index 7bab203e0b..33f4912ab7 100644 --- a/vscode/src/ruby/rubyInstaller.ts +++ b/vscode/src/ruby/rubyInstaller.ts @@ -1,9 +1,10 @@ /* 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 { @@ -54,14 +55,20 @@ export class RubyInstaller extends Chruby { ); } - // 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 { + // 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, { cwd: this.bundleUri.fsPath, - ...options, - env: { ...process.env, ...options.env }, - }; + env: process.env, + }); } } diff --git a/vscode/src/ruby/versionManager.ts b/vscode/src/ruby/versionManager.ts index 85f6b3e8b5..e4c4372d35 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -1,39 +1,24 @@ /* 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, PathConverterInterface, spawn } from "../common"; +import { asyncExec, PathConverterInterface } from "../common"; export interface ActivationResult { env: NodeJS.ProcessEnv; yjit: boolean; version: string; gemPath: string[]; + pathConverter?: PathConverterInterface; + wrapCommand?: (executable: Executable) => Executable; } 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}" + `, @@ -77,26 +62,9 @@ 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.runRubyCode( - `${activatedRuby} -W0 -rjson`, - this.activationScript, - ); - - this.outputChannel.debug( - `Activation script output: ${JSON.stringify(result, null, 2)}`, + const result = await this.runScript( + `${activatedRuby} -W0 -rjson -e '${this.activationScript}'`, ); const activationContent = new RegExp( @@ -120,72 +88,7 @@ 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, 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 { + protected runScript(command: string) { let shell: string | undefined; // If the user has configured a default shell, we use that one since they are probably sourcing their version @@ -195,43 +98,18 @@ export abstract class VersionManager { shell = vscode.env.shell; } - return { + 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, { cwd: this.bundleUri.fsPath, shell, - ...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 }; + env: process.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 ea85a164f8..6bc84d00e6 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 5fb74b32cc..e1c4abb348 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,7 +130,8 @@ suite("Ruby environment activation", () => { gemPath: ["~/.gem/ruby/3.3.5", "/opt/rubies/3.3.5/lib/ruby/gems/3.3.0"], }; - const { spawnStub } = createSpawnStub({ + const execStub = sinon.stub(common, "asyncExec").resolves({ + stdout: "", stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, }); @@ -141,7 +142,7 @@ suite("Ruby environment activation", () => { FAKE_TELEMETRY, ); await ruby.activateRuby(); - spawnStub.restore(); + execStub.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 8eb1781e06..46a4449ad3 100644 --- a/vscode/src/test/suite/ruby/asdf.test.ts +++ b/vscode/src/test/suite/ruby/asdf.test.ts @@ -9,7 +9,6 @@ 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") { @@ -18,13 +17,6 @@ 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!; @@ -41,9 +33,10 @@ suite("Asdf", () => { version: "3.0.0", }; - ({ spawnStub, stdinData } = createSpawnStub({ + const execStub = sinon.stub(common, "asyncExec").resolves({ + stdout: "", stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - })); + }); const findInstallationStub = sinon .stub(asdf, "findAsdfInstallation") @@ -53,17 +46,8 @@ suite("Asdf", () => { const { env, version, yjit } = await asdf.activate(); assert.ok( - spawnStub.calledOnceWithExactly( - ".", - [ - `${os.homedir()}/.asdf/asdf.sh`, - "&&", - "asdf", - "exec", - "ruby", - "-W0", - "-rjson", - ], + execStub.calledOnceWithExactly( + `. ${os.homedir()}/.asdf/asdf.sh && asdf exec ruby -W0 -rjson -e '${asdf.activationScript}'`, { cwd: workspacePath, shell: "/bin/bash", @@ -73,12 +57,11 @@ 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(); }); @@ -99,9 +82,10 @@ suite("Asdf", () => { version: "3.0.0", }; - ({ spawnStub, stdinData } = createSpawnStub({ + const execStub = sinon.stub(common, "asyncExec").resolves({ + stdout: "", stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - })); + }); const findInstallationStub = sinon .stub(asdf, "findAsdfInstallation") @@ -113,17 +97,8 @@ suite("Asdf", () => { const { env, version, yjit } = await asdf.activate(); assert.ok( - spawnStub.calledOnceWithExactly( - ".", - [ - `${os.homedir()}/.asdf/asdf.fish`, - "&&", - "asdf", - "exec", - "ruby", - "-W0", - "-rjson", - ], + execStub.calledOnceWithExactly( + `. ${os.homedir()}/.asdf/asdf.fish && asdf exec ruby -W0 -rjson -e '${asdf.activationScript}'`, { cwd: workspacePath, shell: "/opt/homebrew/bin/fish", @@ -133,12 +108,11 @@ 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/custom.test.ts b/vscode/src/test/suite/ruby/custom.test.ts index 925a4a9087..5e1482d1cc 100644 --- a/vscode/src/test/suite/ruby/custom.test.ts +++ b/vscode/src/test/suite/ruby/custom.test.ts @@ -10,16 +10,8 @@ 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-"), @@ -39,9 +31,10 @@ suite("Custom", () => { version: "3.0.0", }; - ({ spawnStub, stdinData } = createSpawnStub({ + const execStub = sinon.stub(common, "asyncExec").resolves({ + stdout: "", stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - })); + }); const commandStub = sinon .stub(custom, "customCommand") @@ -52,9 +45,8 @@ suite("Custom", () => { const shell = os.platform() === "win32" ? undefined : vscode.env.shell; assert.ok( - spawnStub.calledOnceWithExactly( - "my_version_manager", - ["activate_env", "&&", "ruby", "-W0", "-rjson"], + execStub.calledOnceWithExactly( + `my_version_manager activate_env && ruby -W0 -rjson -e '${custom.activationScript}'`, { cwd: uri.fsPath, shell, @@ -64,12 +56,11 @@ 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 c790d85f24..5d31fa2442 100644 --- a/vscode/src/test/suite/ruby/mise.test.ts +++ b/vscode/src/test/suite/ruby/mise.test.ts @@ -10,7 +10,6 @@ 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") { @@ -19,13 +18,6 @@ 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!; @@ -43,10 +35,10 @@ suite("Mise", () => { version: "3.0.0", }; - ({ spawnStub, stdinData } = createSpawnStub({ + const execStub = sinon.stub(common, "asyncExec").resolves({ + stdout: "", stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - })); - + }); const findStub = sinon .stub(mise, "findMiseUri") .resolves( @@ -61,9 +53,8 @@ suite("Mise", () => { const { env, version, yjit } = await mise.activate(); assert.ok( - spawnStub.calledOnceWithExactly( - `${os.homedir()}/.local/bin/mise`, - ["x", "--", "ruby", "-W0", "-rjson"], + execStub.calledOnceWithExactly( + `${os.homedir()}/.local/bin/mise x -- ruby -W0 -rjson -e '${mise.activationScript}'`, { cwd: workspacePath, shell: vscode.env.shell, @@ -73,12 +64,11 @@ 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(); }); @@ -100,9 +90,10 @@ suite("Mise", () => { version: "3.0.0", }; - ({ spawnStub, stdinData } = createSpawnStub({ + const execStub = sinon.stub(common, "asyncExec").resolves({ + stdout: "", stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - })); + }); const misePath = path.join(workspacePath, "mise"); fs.writeFileSync(misePath, "fakeMiseBinary"); @@ -121,9 +112,8 @@ suite("Mise", () => { const { env, version, yjit } = await mise.activate(); assert.ok( - spawnStub.calledOnceWithExactly( - misePath, - ["x", "--", "ruby", "-W0", "-rjson"], + execStub.calledOnceWithExactly( + `${misePath} x -- ruby -W0 -rjson -e '${mise.activationScript}'`, { cwd: workspacePath, shell: vscode.env.shell, @@ -133,12 +123,11 @@ 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 c7edf4e3a5..f42fbaf648 100644 --- a/vscode/src/test/suite/ruby/none.test.ts +++ b/vscode/src/test/suite/ruby/none.test.ts @@ -4,21 +4,14 @@ 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-"), @@ -38,9 +31,10 @@ suite("None", () => { version: "3.0.0", }; - ({ spawnStub, stdinData } = createSpawnStub({ + const execStub = sinon.stub(common, "asyncExec").resolves({ + stdout: "", stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - })); + }); const { env, version, yjit } = await none.activate(); @@ -48,20 +42,22 @@ suite("None", () => { const shell = os.platform() === "win32" ? undefined : vscode.env.shell; assert.ok( - spawnStub.calledOnceWithExactly("ruby", ["-W0", "-rjson"], { - cwd: uri.fsPath, - shell, - // eslint-disable-next-line no-process-env - env: process.env, - }), + execStub.calledOnceWithExactly( + `ruby -W0 -rjson -e '${none.activationScript}'`, + { + 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 816b629788..1463c2f3ec 100644 --- a/vscode/src/test/suite/ruby/rbenv.test.ts +++ b/vscode/src/test/suite/ruby/rbenv.test.ts @@ -10,7 +10,6 @@ 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") { @@ -19,13 +18,6 @@ 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!; @@ -43,16 +35,16 @@ suite("Rbenv", () => { version: "3.0.0", }; - ({ spawnStub, stdinData } = createSpawnStub({ + const execStub = sinon.stub(common, "asyncExec").resolves({ + stdout: "", stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - })); + }); const { env, version, yjit } = await rbenv.activate(); assert.ok( - spawnStub.calledOnceWithExactly( - "rbenv", - ["exec", "ruby", "-W0", "-rjson"], + execStub.calledOnceWithExactly( + `rbenv exec ruby -W0 -rjson -e '${rbenv.activationScript}'`, { cwd: workspacePath, shell: vscode.env.shell, @@ -62,11 +54,10 @@ 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 () => { @@ -87,9 +78,10 @@ suite("Rbenv", () => { version: "3.0.0", }; - ({ spawnStub, stdinData } = createSpawnStub({ + const execStub = sinon.stub(common, "asyncExec").resolves({ + stdout: "", stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - })); + }); const rbenvPath = path.join(workspacePath, "rbenv"); fs.writeFileSync(rbenvPath, "fakeRbenvBinary"); @@ -108,9 +100,8 @@ suite("Rbenv", () => { const { env, version, yjit } = await rbenv.activate(); assert.ok( - spawnStub.calledOnceWithExactly( - rbenvPath, - ["exec", "ruby", "-W0", "-rjson"], + execStub.calledOnceWithExactly( + `${rbenvPath} exec ruby -W0 -rjson -e '${rbenv.activationScript}'`, { cwd: workspacePath, shell: vscode.env.shell, @@ -120,12 +111,11 @@ 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 }); }); @@ -141,9 +131,10 @@ suite("Rbenv", () => { const outputChannel = new WorkspaceChannel("fake", common.LOG_CHANNEL); const rbenv = new Rbenv(workspaceFolder, outputChannel, async () => {}); - ({ spawnStub, stdinData } = createSpawnStub({ + const execStub = sinon.stub(common, "asyncExec").resolves({ + stdout: "", stderr: `${ACTIVATION_SEPARATOR}not a json${ACTIVATION_SEPARATOR}`, - })); + }); const errorStub = sinon.stub(outputChannel, "error"); @@ -153,9 +144,8 @@ suite("Rbenv", () => { ); assert.ok( - spawnStub.calledOnceWithExactly( - "rbenv", - ["exec", "ruby", "-W0", "-rjson"], + execStub.calledOnceWithExactly( + `rbenv exec ruby -W0 -rjson -e '${rbenv.activationScript}'`, { cwd: workspacePath, shell: vscode.env.shell, @@ -165,14 +155,13 @@ 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 475860d60a..a1ff3c5b4a 100644 --- a/vscode/src/test/suite/ruby/rvm.test.ts +++ b/vscode/src/test/suite/ruby/rvm.test.ts @@ -10,7 +10,6 @@ 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") { @@ -19,13 +18,6 @@ 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 = { @@ -55,16 +47,16 @@ suite("RVM", () => { version: "3.0.0", }; - ({ spawnStub, stdinData } = createSpawnStub({ + const execStub = sinon.stub(common, "asyncExec").resolves({ + stdout: "", stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, - })); + }); const { env, version, yjit } = await rvm.activate(); assert.ok( - spawnStub.calledOnceWithExactly( - path.join(os.homedir(), ".rvm", "bin", "rvm-auto-ruby"), - ["-W0", "-rjson"], + execStub.calledOnceWithExactly( + `${path.join(os.homedir(), ".rvm", "bin", "rvm-auto-ruby")} -W0 -rjson -e '${rvm.activationScript}'`, { cwd: workspacePath, shell: vscode.env.shell, @@ -73,12 +65,11 @@ 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 7381a37444..0ba34ceab4 100644 --- a/vscode/src/test/suite/ruby/shadowenv.test.ts +++ b/vscode/src/test/suite/ruby/shadowenv.test.ts @@ -13,7 +13,6 @@ 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") { @@ -27,8 +26,6 @@ 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") { @@ -114,8 +111,6 @@ suite("Shadowenv", () => { afterEach(() => { fs.rmSync(rootPath, { recursive: true, force: true }); - - spawnStub?.restore(); }); test("Finds Ruby only binary path is appended to PATH", async () => { @@ -231,13 +226,12 @@ suite("Shadowenv", () => { async () => {}, ); - // First, reject the call to `shadowenv exec`. - ({ spawnStub } = createSpawnStub()); - spawnStub.rejects(new Error("shadowenv: command not found")); - - // Then reject the call to `shadowenv --version` + // First, reject the call to `shadowenv exec`. Then resolve the call to `which shadowenv` to return nothing 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 () => {