From c2a3537c65c2adfb92ed78fe0331acec80c2c2c2 Mon Sep 17 00:00:00 2001 From: Naoto Ono Date: Tue, 23 Sep 2025 23:06:28 +0900 Subject: [PATCH 1/2] Extract version managers from ruby-lsp --- eslint.config.mjs | 5 + src/common.ts | 15 ++ src/ruby/asdf.ts | 88 ++++++++ src/ruby/chruby.ts | 440 +++++++++++++++++++++++++++++++++++++ src/ruby/custom.ts | 35 +++ src/ruby/mise.ts | 65 ++++++ src/ruby/none.ts | 39 ++++ src/ruby/rbenv.ts | 42 ++++ src/ruby/rubyInstaller.ts | 65 ++++++ src/ruby/rvm.ts | 50 +++++ src/ruby/shadowenv.ts | 72 ++++++ src/ruby/versionManager.ts | 111 ++++++++++ src/workspaceChannel.ts | 72 ++++++ 13 files changed, 1099 insertions(+) create mode 100644 src/common.ts create mode 100644 src/ruby/asdf.ts create mode 100644 src/ruby/chruby.ts create mode 100644 src/ruby/custom.ts create mode 100644 src/ruby/mise.ts create mode 100644 src/ruby/none.ts create mode 100644 src/ruby/rbenv.ts create mode 100644 src/ruby/rubyInstaller.ts create mode 100644 src/ruby/rvm.ts create mode 100644 src/ruby/shadowenv.ts create mode 100644 src/ruby/versionManager.ts create mode 100644 src/workspaceChannel.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 9ddc125..3044296 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -36,6 +36,11 @@ export default tseslint.config( }, { rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unsafe-assignment": "off", + "@typescript-eslint/no-unsafe-argument": "off", + "@typescript-eslint/no-unsafe-member-access": "off", + "@typescript-eslint/no-unsafe-return": "off", "@typescript-eslint/no-unused-vars": [ "error", { diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 0000000..e8b6f63 --- /dev/null +++ b/src/common.ts @@ -0,0 +1,15 @@ +import { exec } from "child_process"; +import { promisify } from "util"; +import { window } from "vscode"; + +export interface RubyInterface { + error: boolean; + versionManager: { identifier: string }; + rubyVersion?: string; +} + +export const asyncExec = promisify(exec); +export const RUBY_ENVIRONMENTS = "Ruby Environments"; +export const LOG_CHANNEL = window.createOutputChannel(RUBY_ENVIRONMENTS, { + log: true, +}); diff --git a/src/ruby/asdf.ts b/src/ruby/asdf.ts new file mode 100644 index 0000000..e06ff5a --- /dev/null +++ b/src/ruby/asdf.ts @@ -0,0 +1,88 @@ +import os from "os"; +import path from "path"; + +import * as vscode from "vscode"; + +import { VersionManager, ActivationResult } from "./versionManager"; + +// A tool to manage multiple runtime versions with a single CLI tool +// +// Learn more: https://github.com/asdf-vm/asdf +export class Asdf extends VersionManager { + async activate(): Promise { + // These directories are where we can find the ASDF executable for v0.16 and above + const possibleExecutablePaths = [ + vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "homebrew", "bin"), + vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "local", "bin"), + ]; + + // Prefer the path configured by the user, then the ASDF scripts for versions below v0.16 and finally the + // executables for v0.16 and above + const asdfPath = + (await this.getConfiguredAsdfPath()) ?? + (await this.findAsdfInstallation()) ?? + (await this.findExec(possibleExecutablePaths, "asdf")); + + // If there's no extension name, then we are using the ASDF executable directly. If there is an extension, then it's + // a shell script and we have to source it first + const baseCommand = path.extname(asdfPath) === "" ? asdfPath : `. ${asdfPath} && asdf`; + + const parsedResult = await this.runEnvActivationScript(`${baseCommand} exec ruby`); + + return { + env: { ...process.env, ...parsedResult.env }, + yjit: parsedResult.yjit, + version: parsedResult.version, + gemPath: parsedResult.gemPath, + }; + } + + // Only public for testing. Finds the ASDF installation URI based on what's advertised in the ASDF documentation + async findAsdfInstallation(): Promise { + const scriptName = path.basename(vscode.env.shell) === "fish" ? "asdf.fish" : "asdf.sh"; + + // Possible ASDF installation paths as described in https://asdf-vm.com/guide/getting-started.html#_3-install-asdf. + // In order, the methods of installation are: + // 1. Git + // 2. Pacman + // 3. Homebrew M series + // 4. Homebrew Intel series + const possiblePaths = [ + vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".asdf", scriptName), + vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "asdf-vm", scriptName), + vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "homebrew", "opt", "asdf", "libexec", scriptName), + vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "local", "opt", "asdf", "libexec", scriptName), + ]; + + for (const possiblePath of possiblePaths) { + try { + await vscode.workspace.fs.stat(possiblePath); + return possiblePath.fsPath; + } catch (_error: any) { + // Continue looking + } + } + + this.outputChannel.info(`Could not find installation for ASDF < v0.16. Searched in ${possiblePaths.join(", ")}`); + return undefined; + } + + private async getConfiguredAsdfPath(): Promise { + const config = vscode.workspace.getConfiguration("rubyLsp"); + const asdfPath = config.get("rubyVersionManager.asdfExecutablePath"); + + if (!asdfPath) { + return; + } + + const configuredPath = vscode.Uri.file(asdfPath); + + try { + await vscode.workspace.fs.stat(configuredPath); + this.outputChannel.info(`Using configured ASDF executable path: ${asdfPath}`); + return configuredPath.fsPath; + } catch (_error: any) { + throw new Error(`ASDF executable configured as ${configuredPath.fsPath}, but that file doesn't exist`); + } + } +} diff --git a/src/ruby/chruby.ts b/src/ruby/chruby.ts new file mode 100644 index 0000000..0036efb --- /dev/null +++ b/src/ruby/chruby.ts @@ -0,0 +1,440 @@ +import os from "os"; +import path from "path"; + +import * as vscode from "vscode"; + +import { WorkspaceChannel } from "../workspaceChannel"; + +import { ActivationResult, VersionManager, ACTIVATION_SEPARATOR } from "./versionManager"; + +interface RubyVersion { + engine?: string; + version: string; +} + +class RubyActivationCancellationError extends Error {} + +// A tool to change the current Ruby version +// Learn more: https://github.com/postmodern/chruby +export class Chruby extends VersionManager { + // Only public so that we can point to a different directory in tests + public rubyInstallationUris = [ + vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".rubies"), + vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "rubies"), + ]; + + constructor( + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + context: vscode.ExtensionContext, + manuallySelectRuby: () => Promise, + ) { + super(workspaceFolder, outputChannel, context, manuallySelectRuby); + + const configuredRubies = vscode.workspace + .getConfiguration("rubyLsp") + .get("rubyVersionManager.chrubyRubies"); + + if (configuredRubies) { + this.rubyInstallationUris.push(...configuredRubies.map((path) => vscode.Uri.file(path))); + } + } + + async activate(): Promise { + let versionInfo = await this.discoverRubyVersion(); + let rubyUri: vscode.Uri | undefined; + + // If the version informed is available, try to find the Ruby installation. Otherwise, try to fall back to an + // existing version + try { + if (versionInfo) { + rubyUri = await this.findRubyUri(versionInfo); + } else { + const fallback = await this.fallbackWithCancellation( + "No .ruby-version file found. Trying to fall back to latest installed Ruby in 10 seconds", + "You can create a .ruby-version file in a parent directory to configure a fallback", + this.findFallbackRuby.bind(this), + this.rubyVersionError.bind(this), + ); + + versionInfo = fallback.rubyVersion; + rubyUri = fallback.uri; + } + } catch (error: any) { + if (error instanceof RubyActivationCancellationError) { + // Try to re-activate if the user has configured a fallback during cancellation + return this.activate(); + } + + throw error; + } + + // If we couldn't find a Ruby installation, that means there's a `.ruby-version` file, but that Ruby is not + // installed. In this case, we fallback to a closest installation of Ruby - preferably only varying in patch + try { + if (!rubyUri) { + const currentVersionInfo = { ...versionInfo }; + + const fallback = await this.fallbackWithCancellation( + `Couldn't find installation for ${versionInfo.version}. Trying to fall back to other Ruby in 10 seconds`, + "You can cancel this fallback and install the required Ruby", + async () => this.findClosestRubyInstallation(currentVersionInfo), + () => this.missingRubyError(currentVersionInfo.version), + ); + + versionInfo = fallback.rubyVersion; + rubyUri = fallback.uri; + } + } catch (error: any) { + if (error instanceof RubyActivationCancellationError) { + // Try to re-activate if the user has configured a fallback during cancellation + return this.activate(); + } + + throw error; + } + + this.outputChannel.info(`Discovered Ruby installation at ${rubyUri.fsPath}`); + + const { defaultGems, gemHome, yjit, version } = await this.runActivationScript(rubyUri, versionInfo); + + this.outputChannel.info(`Activated Ruby environment: defaultGems=${defaultGems} gemHome=${gemHome} yjit=${yjit}`); + + const rubyEnv = { + GEM_HOME: gemHome, + GEM_PATH: `${gemHome}${path.delimiter}${defaultGems}`, + PATH: `${path.join(gemHome, "bin")}${path.delimiter}${path.join( + defaultGems, + "bin", + )}${path.delimiter}${path.dirname(rubyUri.fsPath)}${path.delimiter}${this.getProcessPath()}`, + }; + + return { + env: { ...process.env, ...rubyEnv }, + yjit, + version, + gemPath: [gemHome, defaultGems], + }; + } + + protected getProcessPath() { + return process.env.PATH; + } + + // Returns the full URI to the Ruby executable + protected async findRubyUri(rubyVersion: RubyVersion): Promise { + const possibleVersionNames = rubyVersion.engine + ? [`${rubyVersion.engine}-${rubyVersion.version}`, rubyVersion.version] + : [rubyVersion.version, `ruby-${rubyVersion.version}`]; + + for (const uri of this.rubyInstallationUris) { + let directories; + + try { + directories = (await vscode.workspace.fs.readDirectory(uri)).sort((left, right) => + right[0].localeCompare(left[0]), + ); + } catch (_error: any) { + // If the directory doesn't exist, keep searching + this.outputChannel.debug(`Tried searching for Ruby installation in ${uri.fsPath} but it doesn't exist`); + continue; + } + + for (const versionName of possibleVersionNames) { + const targetDirectory = directories.find(([name]) => name.startsWith(versionName)); + + if (targetDirectory) { + return vscode.Uri.joinPath(uri, targetDirectory[0], "bin", "ruby"); + } + } + } + + return undefined; + } + + // Run the activation script using the Ruby installation we found so that we can discover gem paths + protected async runActivationScript( + rubyExecutableUri: vscode.Uri, + rubyVersion: RubyVersion, + ): Promise<{ + defaultGems: string; + gemHome: string; + yjit: boolean; + version: string; + }> { + const activationUri = vscode.Uri.joinPath(this.context.extensionUri, "chruby_activation.rb"); + + const result = await this.runScript( + `${rubyExecutableUri.fsPath} -EUTF-8:UTF-8 '${activationUri.fsPath}' ${rubyVersion.version}`, + ); + + const [defaultGems, gemHome, yjit, version] = result.stderr.split(ACTIVATION_SEPARATOR); + + return { defaultGems, gemHome, yjit: yjit === "true", version }; + } + + private async findClosestRubyInstallation(rubyVersion: RubyVersion): Promise<{ + uri: vscode.Uri; + rubyVersion: RubyVersion; + }> { + const [major, minor, _patch] = rubyVersion.version.split("."); + const directories: { uri: vscode.Uri; rubyVersion: RubyVersion }[] = []; + + for (const uri of this.rubyInstallationUris) { + try { + // Accumulate all directories that match the `engine-version` pattern and that start with the same requested + // major version. We do not try to approximate major versions + (await vscode.workspace.fs.readDirectory(uri)).forEach(([name]) => { + const match = /((?[A-Za-z]+)-)?(?\d+\.\d+(\.\d+)?(-[A-Za-z0-9]+)?)/.exec(name); + + if (match?.groups && match.groups.version.startsWith(major)) { + directories.push({ + uri: vscode.Uri.joinPath(uri, name, "bin", "ruby"), + rubyVersion: { + engine: match.groups.engine, + version: match.groups.version, + }, + }); + } + }); + } catch (_error: any) { + // If the directory doesn't exist, keep searching + this.outputChannel.debug(`Tried searching for Ruby installation in ${uri.fsPath} but it doesn't exist`); + continue; + } + } + + // Sort the directories based on the difference between the minor version and the requested minor version. On + // conflicts, we use the patch version to break the tie. If there's no distance, we prefer the higher patch version + const closest = directories.sort((left, right) => { + const leftVersion = left.rubyVersion.version.split("."); + const rightVersion = right.rubyVersion.version.split("."); + + const leftDiff = Math.abs(Number(leftVersion[1]) - Number(minor)); + const rightDiff = Math.abs(Number(rightVersion[1]) - Number(minor)); + + // If the distance to minor version is the same, prefer higher patch number + if (leftDiff === rightDiff) { + return Number(rightVersion[2] || 0) - Number(leftVersion[2] || 0); + } + + return leftDiff - rightDiff; + })[0]; + + if (closest) { + return closest; + } + + throw new Error("Cannot find any Ruby installations"); + } + + // Returns the Ruby version information including version and engine. E.g.: ruby-3.3.0, truffleruby-21.3.0 + private async discoverRubyVersion(): Promise { + let uri = this.bundleUri; + const root = path.parse(uri.fsPath).root; + let version: string; + let rubyVersionUri: vscode.Uri; + + while (uri.fsPath !== root) { + try { + rubyVersionUri = vscode.Uri.joinPath(uri, ".ruby-version"); + const content = await vscode.workspace.fs.readFile(rubyVersionUri); + version = content.toString().trim(); + } catch (_error: any) { + // If the file doesn't exist, continue going up the directory tree + uri = vscode.Uri.file(path.dirname(uri.fsPath)); + continue; + } + + if (version === "") { + throw new Error(`Ruby version file ${rubyVersionUri.fsPath} is empty`); + } + + const match = /((?[A-Za-z]+)-)?(?\d+\.\d+(\.\d+)?(-[A-Za-z0-9]+)?)/.exec(version); + + if (!match?.groups) { + throw new Error( + `Ruby version file ${rubyVersionUri.fsPath} contains invalid format. Expected (engine-)?version, got ${version}`, + ); + } + + this.outputChannel.info(`Discovered Ruby version ${version} from ${rubyVersionUri.fsPath}`); + return { engine: match.groups.engine, version: match.groups.version }; + } + + return undefined; + } + + private async fallbackWithCancellation( + title: string, + message: string, + fallbackFn: () => Promise, + errorFn: () => Error, + ): Promise { + let gemfileContents; + + try { + gemfileContents = await vscode.workspace.fs.readFile(vscode.Uri.joinPath(this.workspaceFolder.uri, "Gemfile")); + } catch (_error: any) { + // The Gemfile doesn't exist + } + + // If the Gemfile includes ruby version restrictions, then trying to fall back may lead to errors + if (gemfileContents && /^ruby(\s|\()("|')[\d.]+/.test(gemfileContents.toString())) { + throw errorFn(); + } + + const fallback = await vscode.window.withProgress( + { + title, + location: vscode.ProgressLocation.Notification, + cancellable: true, + }, + async (progress, token) => { + progress.report({ message }); + + // If they don't cancel, we wait 10 seconds before falling back so that they are aware of what's happening + await new Promise((resolve) => { + setTimeout(resolve, 10000); + + // If the user cancels the fallback, resolve immediately so that they don't have to wait 10 seconds + token.onCancellationRequested(() => { + resolve(); + }); + }); + + if (token.isCancellationRequested) { + await this.handleCancelledFallback(errorFn); + + // We throw this error to be able to catch and re-run activation after the user has configured a fallback + throw new RubyActivationCancellationError(); + } + + return fallbackFn(); + }, + ); + + return fallback; + } + + private async handleCancelledFallback(errorFn: () => Error) { + const answer = await vscode.window.showInformationMessage( + `The Ruby LSP requires a Ruby version to launch. + You can define a fallback for the system or for the Ruby LSP only`, + "System", + "Ruby LSP only", + ); + + if (answer === "System") { + await this.createParentRubyVersionFile(errorFn); + } else if (answer === "Ruby LSP only") { + await this.manuallySelectRuby(); + } + + throw errorFn(); + } + + private async createParentRubyVersionFile(errorFn: () => Error) { + const items: vscode.QuickPickItem[] = []; + + for (const uri of this.rubyInstallationUris) { + let directories; + + try { + directories = (await vscode.workspace.fs.readDirectory(uri)).sort((left, right) => + right[0].localeCompare(left[0]), + ); + + directories.forEach((directory) => { + items.push({ + label: directory[0], + }); + }); + } catch (_error: any) { + continue; + } + } + + const answer = await vscode.window.showQuickPick(items, { + title: "Select a Ruby version to use as fallback", + ignoreFocusOut: true, + }); + + if (!answer) { + throw errorFn(); + } + + const targetDirectory = await vscode.window.showOpenDialog({ + defaultUri: vscode.Uri.file(os.homedir()), + openLabel: "Add fallback in this directory", + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + title: "Select the directory to create the .ruby-version fallback in", + }); + + if (!targetDirectory) { + throw errorFn(); + } + + await vscode.workspace.fs.writeFile( + vscode.Uri.joinPath(targetDirectory[0], ".ruby-version"), + Buffer.from(answer.label), + ); + } + + private async findFallbackRuby(): Promise<{ + uri: vscode.Uri; + rubyVersion: RubyVersion; + }> { + for (const uri of this.rubyInstallationUris) { + let directories; + + try { + directories = (await vscode.workspace.fs.readDirectory(uri)).sort((left, right) => + right[0].localeCompare(left[0]), + ); + + let groups; + let targetDirectory; + + for (const directory of directories) { + const match = /((?[A-Za-z]+)-)?(?\d+\.\d+(\.\d+)?(-[A-Za-z0-9]+)?)/.exec(directory[0]); + + if (match?.groups) { + groups = match.groups; + targetDirectory = directory; + break; + } + } + + if (targetDirectory && groups) { + return { + uri: vscode.Uri.joinPath(uri, targetDirectory[0], "bin", "ruby"), + rubyVersion: { + engine: groups.engine, + version: groups.version, + }, + }; + } + } catch (_error: any) { + // If the directory doesn't exist, keep searching + this.outputChannel.debug(`Tried searching for Ruby installation in ${uri.fsPath} but it doesn't exist`); + continue; + } + } + + throw new Error("Cannot find any Ruby installations"); + } + + private missingRubyError(version: string) { + return new Error(`Cannot find Ruby installation for version ${version}`); + } + + private rubyVersionError() { + return new Error( + `Cannot find .ruby-version file. Please specify the Ruby version in a + .ruby-version either in ${this.bundleUri.fsPath} or in a parent directory`, + ); + } +} diff --git a/src/ruby/custom.ts b/src/ruby/custom.ts new file mode 100644 index 0000000..c4564c7 --- /dev/null +++ b/src/ruby/custom.ts @@ -0,0 +1,35 @@ +import * as vscode from "vscode"; + +import { VersionManager, ActivationResult } from "./versionManager"; + +// Custom +// +// Custom 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 Custom extends VersionManager { + async activate(): Promise { + const parsedResult = await this.runEnvActivationScript(`${this.customCommand()} && ruby`); + + return { + env: { ...process.env, ...parsedResult.env }, + yjit: parsedResult.yjit, + version: parsedResult.version, + gemPath: parsedResult.gemPath, + }; + } + + customCommand() { + const configuration = vscode.workspace.getConfiguration("rubyLsp"); + const customCommand: string | undefined = configuration.get("customRubyCommand"); + + if (customCommand === undefined) { + throw new Error( + "The customRubyCommand configuration must be set when 'custom' is selected as the version manager. \ + See the [README](https://shopify.github.io/ruby-lsp/version-managers.html) for instructions.", + ); + } + + return customCommand; + } +} diff --git a/src/ruby/mise.ts b/src/ruby/mise.ts new file mode 100644 index 0000000..2c647ed --- /dev/null +++ b/src/ruby/mise.ts @@ -0,0 +1,65 @@ +import os from "os"; + +import * as vscode from "vscode"; + +import { VersionManager, ActivationResult } from "./versionManager"; + +// Mise (mise en place) is a manager for dev tools, environment variables and tasks +// +// Learn more: https://github.com/jdx/mise +export class Mise extends VersionManager { + async activate(): Promise { + const miseUri = await this.findMiseUri(); + + // The exec command in Mise is called `x` + const parsedResult = await this.runEnvActivationScript(`${miseUri.fsPath} x -- ruby`); + + return { + env: { ...process.env, ...parsedResult.env }, + yjit: parsedResult.yjit, + version: parsedResult.version, + gemPath: parsedResult.gemPath, + }; + } + + async findMiseUri(): Promise { + const config = vscode.workspace.getConfiguration("rubyLsp"); + const misePath = config.get("rubyVersionManager.miseExecutablePath"); + + if (misePath) { + const configuredPath = vscode.Uri.file(misePath); + + try { + await vscode.workspace.fs.stat(configuredPath); + return configuredPath; + } catch (_error: any) { + throw new Error(`Mise executable configured as ${configuredPath.fsPath}, but that file doesn't exist`); + } + } + + // Possible mise installation paths + // + // 1. Installation from curl | sh (per mise.jdx.dev Getting Started) + // 2. Homebrew M series + // 3. Installation from `apt install mise` + const possiblePaths = [ + vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".local", "bin", "mise"), + vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "homebrew", "bin", "mise"), + vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "bin", "mise"), + ]; + + for (const possiblePath of possiblePaths) { + try { + await vscode.workspace.fs.stat(possiblePath); + return possiblePath; + } catch (_error: any) { + // Continue looking + } + } + + throw new Error( + `The Ruby LSP version manager is configured to be Mise, but could not find Mise installation. Searched in + ${possiblePaths.join(", ")}`, + ); + } +} diff --git a/src/ruby/none.ts b/src/ruby/none.ts new file mode 100644 index 0000000..0c6a8c7 --- /dev/null +++ b/src/ruby/none.ts @@ -0,0 +1,39 @@ +import * as vscode from "vscode"; + +import { WorkspaceChannel } from "../workspaceChannel"; + +import { VersionManager, ActivationResult } from "./versionManager"; + +// None +// +// This "version manager" represents the case where no manager is used, but the environment still needs to be inserted +// into the NodeJS process. For example, when you use Docker, install Ruby through Homebrew or use some other mechanism +// to have Ruby available in your PATH automatically. +// +// If you don't have Ruby automatically available in your PATH and are not using a version manager, look into +// configuring custom Ruby activation +export class None extends VersionManager { + private readonly rubyPath: string; + + constructor( + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + context: vscode.ExtensionContext, + manuallySelectRuby: () => Promise, + rubyPath?: string, + ) { + super(workspaceFolder, outputChannel, context, manuallySelectRuby); + this.rubyPath = rubyPath ?? "ruby"; + } + + async activate(): Promise { + const parsedResult = await this.runEnvActivationScript(this.rubyPath); + + return { + env: { ...process.env, ...parsedResult.env }, + yjit: parsedResult.yjit, + version: parsedResult.version, + gemPath: parsedResult.gemPath, + }; + } +} diff --git a/src/ruby/rbenv.ts b/src/ruby/rbenv.ts new file mode 100644 index 0000000..4c7308c --- /dev/null +++ b/src/ruby/rbenv.ts @@ -0,0 +1,42 @@ +import * as vscode from "vscode"; + +import { VersionManager, ActivationResult } from "./versionManager"; + +// Seamlessly manage your app’s Ruby environment with rbenv. +// +// Learn more: https://github.com/rbenv/rbenv +export class Rbenv extends VersionManager { + async activate(): Promise { + const rbenvExec = await this.findRbenv(); + + const parsedResult = await this.runEnvActivationScript(`${rbenvExec} exec ruby`); + + return { + env: { ...process.env, ...parsedResult.env }, + yjit: parsedResult.yjit, + version: parsedResult.version, + gemPath: parsedResult.gemPath, + }; + } + + private async findRbenv(): Promise { + const config = vscode.workspace.getConfiguration("rubyLsp"); + const configuredRbenvPath = config.get("rubyVersionManager.rbenvExecutablePath"); + + if (configuredRbenvPath) { + return this.ensureRbenvExistsAt(configuredRbenvPath); + } else { + return this.findExec([vscode.Uri.file("/opt/homebrew/bin"), vscode.Uri.file("/usr/local/bin")], "rbenv"); + } + } + + private async ensureRbenvExistsAt(path: string): Promise { + try { + await vscode.workspace.fs.stat(vscode.Uri.file(path)); + + return path; + } catch (_error: any) { + throw new Error(`The Ruby LSP version manager is configured to be rbenv, but ${path} does not exist`); + } + } +} diff --git a/src/ruby/rubyInstaller.ts b/src/ruby/rubyInstaller.ts new file mode 100644 index 0000000..3adc050 --- /dev/null +++ b/src/ruby/rubyInstaller.ts @@ -0,0 +1,65 @@ +import os from "os"; + +import * as vscode from "vscode"; + +import { Chruby } from "./chruby"; + +interface RubyVersion { + engine?: string; + version: string; +} + +// Most version managers do not support Windows. One popular way of installing Ruby on Windows is via RubyInstaller, +// which places the rubies in directories like C:\Ruby32-x64 (i.e.: Ruby{major}{minor}-{arch}). To automatically switch +// Ruby versions on Windows, we use the same mechanism as Chruby to discover the Ruby version based on `.ruby-version` +// files and then try to search the directories commonly used by RubyInstaller. +// +// If we can't find it there, then we throw an error and rely on the user to manually select where Ruby is installed. +export class RubyInstaller extends Chruby { + // Environment variables are case sensitive on Windows when we access them through NodeJS. We need to ensure that + // we're searching through common variations, so that we don't accidentally miss the path we should inherit + protected getProcessPath() { + return process.env.Path ?? process.env.PATH ?? process.env.path; + } + + // Returns the full URI to the Ruby executable + protected async findRubyUri(rubyVersion: RubyVersion): Promise { + const [major, minor, _patch] = rubyVersion.version.split(".").map(Number); + + const possibleInstallationUris = [ + vscode.Uri.joinPath(vscode.Uri.file("C:"), `Ruby${major}${minor}-${os.arch()}`), + vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), `Ruby${major}${minor}-${os.arch()}`), + ]; + + for (const installationUri of possibleInstallationUris) { + try { + await vscode.workspace.fs.stat(installationUri); + return vscode.Uri.joinPath(installationUri, "bin", "ruby"); + } catch (_error: any) { + // Continue searching + } + } + + throw new Error( + `Cannot find installation directory for Ruby version ${rubyVersion.version}.\ + Searched in ${possibleInstallationUris.map((uri) => uri.fsPath).join(", ")}`, + ); + } + + protected async runActivationScript( + rubyExecutableUri: vscode.Uri, + rubyVersion: RubyVersion, + ): Promise<{ + defaultGems: string; + gemHome: string; + yjit: boolean; + version: string; + }> { + const activationResult = await super.runActivationScript(rubyExecutableUri, rubyVersion); + + activationResult.gemHome = activationResult.gemHome.replace(/\//g, "\\"); + activationResult.defaultGems = activationResult.defaultGems.replace(/\//g, "\\"); + + return activationResult; + } +} diff --git a/src/ruby/rvm.ts b/src/ruby/rvm.ts new file mode 100644 index 0000000..66f508d --- /dev/null +++ b/src/ruby/rvm.ts @@ -0,0 +1,50 @@ +import os from "os"; + +import * as vscode from "vscode"; + +import { ActivationResult, VersionManager } from "./versionManager"; + +// Ruby enVironment Manager. It manages Ruby application environments and enables switching between them. +// Learn more: +// - https://github.com/rvm/rvm +// - https://rvm.io +export class Rvm extends VersionManager { + async activate(): Promise { + const installationPath = await this.findRvmInstallation(); + const parsedResult = await this.runEnvActivationScript(installationPath.fsPath); + + const activatedKeys = Object.entries(parsedResult.env) + .map(([key, value]) => `${key}=${value}`) + .join(" "); + + this.outputChannel.info(`Activated Ruby environment: ${activatedKeys}`); + + return { + env: { ...process.env, ...parsedResult.env }, + yjit: parsedResult.yjit, + version: parsedResult.version, + gemPath: parsedResult.gemPath, + }; + } + + async findRvmInstallation(): Promise { + const possiblePaths = [ + vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".rvm", "bin", "rvm-auto-ruby"), + vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "local", "rvm", "bin", "rvm-auto-ruby"), + vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "share", "rvm", "bin", "rvm-auto-ruby"), + ]; + + for (const uri of possiblePaths) { + try { + await vscode.workspace.fs.stat(uri); + return uri; + } catch (_error: any) { + // Continue to the next installation path + } + } + + throw new Error( + `Cannot find RVM installation directory. Searched in ${possiblePaths.map((uri) => uri.fsPath).join(",")}`, + ); + } +} diff --git a/src/ruby/shadowenv.ts b/src/ruby/shadowenv.ts new file mode 100644 index 0000000..a683ad8 --- /dev/null +++ b/src/ruby/shadowenv.ts @@ -0,0 +1,72 @@ +import * as vscode from "vscode"; + +import { asyncExec } from "../common"; + +import { VersionManager, ActivationResult } from "./versionManager"; + +// Shadowenv is a tool that allows managing environment variables upon entering a directory. It allows users to manage +// which Ruby version should be used for each project, in addition to other customizations such as GEM_HOME. +// +// Learn more: https://github.com/Shopify/shadowenv +export class UntrustedWorkspaceError extends Error {} + +export class Shadowenv extends VersionManager { + async activate(): Promise { + try { + await vscode.workspace.fs.stat(vscode.Uri.joinPath(this.bundleUri, ".shadowenv.d")); + } catch (_error: any) { + throw new Error( + "The Ruby LSP version manager is configured to be shadowenv, \ + but no .shadowenv.d directory was found in the workspace", + ); + } + + const shadowenvExec = await this.findExec([vscode.Uri.file("/opt/homebrew/bin")], "shadowenv"); + + try { + const parsedResult = await this.runEnvActivationScript(`${shadowenvExec} exec -- ruby`); + + // Do not let Shadowenv change the BUNDLE_GEMFILE. The server has to be able to control this in order to properly + // set up the environment + delete parsedResult.env.BUNDLE_GEMFILE; + + return { + env: { ...process.env, ...parsedResult.env }, + yjit: parsedResult.yjit, + version: parsedResult.version, + gemPath: parsedResult.gemPath, + }; + } catch (error: any) { + const err = error as Error; + // If the workspace is untrusted, offer to trust it for the user + if (err.message.includes("untrusted shadowenv program")) { + const answer = await vscode.window.showErrorMessage( + `Tried to activate Shadowenv, but the workspace is untrusted. + Workspaces must be trusted to before allowing Shadowenv to load the environment for security reasons.`, + "Trust workspace", + "Shutdown Ruby LSP", + ); + + if (answer === "Trust workspace") { + await asyncExec("shadowenv trust", { cwd: this.bundleUri.fsPath }); + return this.activate(); + } + + throw new UntrustedWorkspaceError("Cannot activate Ruby environment in an untrusted workspace"); + } + + try { + await asyncExec("shadowenv --version"); + } catch (_error: any) { + throw new Error( + `Shadowenv executable not found. Ensure it is installed and available in the PATH. + This error may happen if your shell configuration is failing to be sourced from the editor or if + another extension is mutating the process PATH.`, + ); + } + + // If it failed for some other reason, present the error to the user + throw new Error(`Failed to activate Ruby environment with Shadowenv: ${error.message}`); + } + } +} diff --git a/src/ruby/versionManager.ts b/src/ruby/versionManager.ts new file mode 100644 index 0000000..92fdcd2 --- /dev/null +++ b/src/ruby/versionManager.ts @@ -0,0 +1,111 @@ +import path from "path"; +import os from "os"; + +import * as vscode from "vscode"; + +import { WorkspaceChannel } from "../workspaceChannel"; +import { asyncExec } from "../common"; + +export interface ActivationResult { + env: NodeJS.ProcessEnv; + yjit: boolean; + version: string; + gemPath: string[]; +} + +// Changes to either one of these values have to be synchronized with a corresponding update in `activation.rb` +export const ACTIVATION_SEPARATOR = "RUBY_LSP_ACTIVATION_SEPARATOR"; +export const VALUE_SEPARATOR = "RUBY_LSP_VS"; +export const FIELD_SEPARATOR = "RUBY_LSP_FS"; + +export abstract class VersionManager { + protected readonly outputChannel: WorkspaceChannel; + protected readonly workspaceFolder: vscode.WorkspaceFolder; + protected readonly bundleUri: vscode.Uri; + protected readonly manuallySelectRuby: () => Promise; + protected readonly context: vscode.ExtensionContext; + private readonly customBundleGemfile?: string; + + constructor( + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + context: vscode.ExtensionContext, + manuallySelectRuby: () => Promise, + ) { + this.workspaceFolder = workspaceFolder; + this.outputChannel = outputChannel; + this.context = context; + this.manuallySelectRuby = manuallySelectRuby; + const customBundleGemfile = vscode.workspace.getConfiguration("rubyLsp").get("bundleGemfile"); + + if (customBundleGemfile && customBundleGemfile.length > 0) { + this.customBundleGemfile = path.isAbsolute(customBundleGemfile) + ? customBundleGemfile + : path.resolve(path.join(this.workspaceFolder.uri.fsPath, customBundleGemfile)); + } + + this.bundleUri = this.customBundleGemfile + ? vscode.Uri.file(path.dirname(this.customBundleGemfile)) + : workspaceFolder.uri; + } + + // Activate the Ruby environment for the version manager, returning all of the necessary information to boot the + // language server + abstract activate(): Promise; + + protected async runEnvActivationScript(activatedRuby: string): Promise { + const activationUri = vscode.Uri.joinPath(this.context.extensionUri, "activation.rb"); + + const result = await this.runScript(`${activatedRuby} -EUTF-8:UTF-8 '${activationUri.fsPath}'`); + + const activationContent = new RegExp(`${ACTIVATION_SEPARATOR}([^]*)${ACTIVATION_SEPARATOR}`).exec(result.stderr); + + const [version, gemPath, yjit, ...envEntries] = activationContent![1].split(FIELD_SEPARATOR); + + return { + version, + gemPath: gemPath.split(","), + yjit: yjit === "true", + env: Object.fromEntries(envEntries.map((entry) => entry.split(VALUE_SEPARATOR))), + }; + } + + // 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) { + 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; + } + + this.outputChannel.info(`Running command: \`${command}\` in ${this.bundleUri.fsPath} using shell: ${shell}`); + + return asyncExec(command, { + cwd: this.bundleUri.fsPath, + shell, + env: process.env, + encoding: "utf-8", + }); + } + + // Tries to find `execName` within the given directories. Prefers the executables found in the given directories over + // finding the executable in the PATH + protected async findExec(directories: vscode.Uri[], execName: string) { + for (const uri of directories) { + try { + const fullUri = vscode.Uri.joinPath(uri, execName); + await vscode.workspace.fs.stat(fullUri); + this.outputChannel.info(`Found ${execName} executable at ${uri.fsPath}`); + return fullUri.fsPath; + } catch (_error: any) { + // continue searching + } + } + + return execName; + } +} diff --git a/src/workspaceChannel.ts b/src/workspaceChannel.ts new file mode 100644 index 0000000..733e46a --- /dev/null +++ b/src/workspaceChannel.ts @@ -0,0 +1,72 @@ +import * as vscode from "vscode"; + +export class WorkspaceChannel implements vscode.LogOutputChannel { + public readonly onDidChangeLogLevel: vscode.Event; + private readonly actualChannel: vscode.LogOutputChannel; + private readonly prefix: string; + + constructor(workspaceName: string, actualChannel: vscode.LogOutputChannel) { + this.prefix = `(${workspaceName})`; + this.actualChannel = actualChannel; + this.onDidChangeLogLevel = this.actualChannel.onDidChangeLogLevel; + } + + get name(): string { + return this.actualChannel.name; + } + + get logLevel(): vscode.LogLevel { + return this.actualChannel.logLevel; + } + + trace(message: string, ...args: any[]): void { + this.actualChannel.trace(`${this.prefix} ${message}`, ...args); + } + + debug(message: string, ...args: any[]): void { + this.actualChannel.debug(`${this.prefix} ${message}`, ...args); + } + + info(message: string, ...args: any[]): void { + this.actualChannel.info(`${this.prefix} ${message}`, ...args); + } + + warn(message: string, ...args: any[]): void { + this.actualChannel.warn(`${this.prefix} ${message}`, ...args); + } + + error(error: string | Error, ...args: any[]): void { + this.actualChannel.error(`${this.prefix} ${error}`, ...args); + } + + append(value: string): void { + this.actualChannel.append(`${this.prefix} ${value}`); + } + + appendLine(value: string): void { + this.actualChannel.appendLine(`${this.prefix} ${value}`); + } + + replace(value: string): void { + this.actualChannel.replace(`${this.prefix} ${value}`); + } + + clear(): void { + this.actualChannel.clear(); + } + + show(preserveFocus?: boolean): void; + show(column?: vscode.ViewColumn, preserveFocus?: boolean): void; + + show(_column?: unknown, preserveFocus?: boolean): void { + this.actualChannel.show(preserveFocus); + } + + hide(): void { + this.actualChannel.hide(); + } + + dispose(): void { + this.actualChannel.dispose(); + } +} From fae39e21a4a984f32e55f9a2a72ea142f48e5d6c Mon Sep 17 00:00:00 2001 From: Naoto Ono Date: Tue, 23 Sep 2025 23:26:31 +0900 Subject: [PATCH 2/2] Extract tests related to version managers --- .ruby-version | 1 + activation.rb | 7 + chruby_activation.rb | 27 +++ package.json | 15 +- src/test/extension.test.ts | 7 - src/test/rubyVersion.ts | 9 + src/test/runTest.ts | 25 ++ src/test/suite/helpers.ts | 72 ++++++ src/test/suite/index.ts | 44 ++++ src/test/suite/ruby/asdf.test.ts | 167 +++++++++++++ src/test/suite/ruby/chruby.test.ts | 275 ++++++++++++++++++++++ src/test/suite/ruby/custom.test.ts | 73 ++++++ src/test/suite/ruby/mise.test.ts | 130 ++++++++++ src/test/suite/ruby/none.test.ts | 69 ++++++ src/test/suite/ruby/rbenv.test.ts | 121 ++++++++++ src/test/suite/ruby/rubyInstaller.test.ts | 118 ++++++++++ src/test/suite/ruby/rvm.test.ts | 79 +++++++ src/test/suite/ruby/shadowenv.test.ts | 196 +++++++++++++++ src/test/suite/workspaceChannel.test.ts | 24 ++ yarn.lock | 59 ++++- 20 files changed, 1503 insertions(+), 15 deletions(-) create mode 100644 .ruby-version create mode 100644 activation.rb create mode 100644 chruby_activation.rb delete mode 100644 src/test/extension.test.ts create mode 100644 src/test/rubyVersion.ts create mode 100644 src/test/runTest.ts create mode 100644 src/test/suite/helpers.ts create mode 100644 src/test/suite/index.ts create mode 100644 src/test/suite/ruby/asdf.test.ts create mode 100644 src/test/suite/ruby/chruby.test.ts create mode 100644 src/test/suite/ruby/custom.test.ts create mode 100644 src/test/suite/ruby/mise.test.ts create mode 100644 src/test/suite/ruby/none.test.ts create mode 100644 src/test/suite/ruby/rbenv.test.ts create mode 100644 src/test/suite/ruby/rubyInstaller.test.ts create mode 100644 src/test/suite/ruby/rvm.test.ts create mode 100644 src/test/suite/ruby/shadowenv.test.ts create mode 100644 src/test/suite/workspaceChannel.test.ts diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..3ec370e --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +3.4.5 \ No newline at end of file diff --git a/activation.rb b/activation.rb new file mode 100644 index 0000000..dfc33dd --- /dev/null +++ b/activation.rb @@ -0,0 +1,7 @@ +# Using .map.compact just so that it doesn't crash immediately on Ruby 2.6 +env = ENV.map do |k, v| + utf_8_value = v.dup.force_encoding(Encoding::UTF_8) + "#{k}RUBY_LSP_VS#{utf_8_value}" if utf_8_value.valid_encoding? +end.compact +env.unshift(RUBY_VERSION, Gem.path.join(","), !!defined?(RubyVM::YJIT)) +STDERR.print("RUBY_LSP_ACTIVATION_SEPARATOR#{env.join("RUBY_LSP_FS")}RUBY_LSP_ACTIVATION_SEPARATOR") diff --git a/chruby_activation.rb b/chruby_activation.rb new file mode 100644 index 0000000..35e082d --- /dev/null +++ b/chruby_activation.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +# Typically, GEM_HOME points to $HOME/.gem/ruby/version_without_patch. For example, for Ruby 3.2.2, it would be +# $HOME/.gem/ruby/3.2.0. However, chruby overrides GEM_HOME to use the patch part of the version, resulting in +# $HOME/.gem/ruby/3.2.2. In our activation script, we check if a directory using the patch exists and then prefer +# that over the default one. +user_dir = Gem.user_dir +paths = Gem.path +default_dir = Gem.default_dir + +if paths.length > 2 + paths.delete(default_dir) + paths.delete(user_dir) + first_path = paths[0] + user_dir = first_path if first_path && Dir.exist?(first_path) +end + +newer_gem_home = File.join(File.dirname(user_dir), ARGV.first) +gems = Dir.exist?(newer_gem_home) ? newer_gem_home : user_dir +STDERR.print( + [ + default_dir, + gems, + !!defined?(RubyVM::YJIT), + RUBY_VERSION + ].join("RUBY_LSP_ACTIVATION_SEPARATOR") +) diff --git a/package.json b/package.json index 15f57fd..bc80b29 100644 --- a/package.json +++ b/package.json @@ -36,20 +36,23 @@ "test": "vscode-test" }, "devDependencies": { + "@eslint/js": "^9.32.0", "@types/mocha": "^10.0.10", "@types/node": "20.x", + "@types/sinon": "^17.0.4", "@types/vscode": "^1.102.0", - "@eslint/js": "^9.32.0", - "eslint": "^9.30.1", - "typescript-eslint": "^8.38.0", - "eslint-plugin-prettier": "^5.5.3", - "prettier": "^3.6.2", "@vscode/test-cli": "^0.0.11", "@vscode/test-electron": "^2.5.2", "@vscode/vsce": "^3.6.0", "esbuild": "^0.25.3", + "eslint": "^9.30.1", + "eslint-plugin-prettier": "^5.5.3", + "glob": "^11.0.3", "npm-run-all": "^4.1.5", "ovsx": "^0.10.5", - "typescript": "^5.8.3" + "prettier": "^3.6.2", + "sinon": "^21.0.0", + "typescript": "^5.8.3", + "typescript-eslint": "^8.38.0" } } diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts deleted file mode 100644 index 5b97c5e..0000000 --- a/src/test/extension.test.ts +++ /dev/null @@ -1,7 +0,0 @@ -import * as assert from "assert"; - -suite("Extension Test Suite", () => { - test("Sample test", () => { - assert.strictEqual(1 + 1, 2); - }); -}); diff --git a/src/test/rubyVersion.ts b/src/test/rubyVersion.ts new file mode 100644 index 0000000..13f2faa --- /dev/null +++ b/src/test/rubyVersion.ts @@ -0,0 +1,9 @@ +import fs from "fs"; +import path from "path"; + +export const RUBY_VERSION = fs + .readFileSync(path.join(path.dirname(path.dirname(__dirname)), ".ruby-version"), "utf-8") + .trim(); + +export const [MAJOR, MINOR, PATCH] = RUBY_VERSION.split("."); +export const VERSION_REGEX = `${MAJOR}\\.${MINOR}\\.\\d+`; diff --git a/src/test/runTest.ts b/src/test/runTest.ts new file mode 100644 index 0000000..cf3dca3 --- /dev/null +++ b/src/test/runTest.ts @@ -0,0 +1,25 @@ +import * as path from "path"; + +import { runTests } from "@vscode/test-electron"; + +async function main() { + try { + // The folder containing the Extension Manifest package.json + // Passed to `--extensionDevelopmentPath` + const extensionDevelopmentPath = path.resolve(__dirname, "../../"); + + // The path to test runner + // Passed to --extensionTestsPath + const extensionTestsPath = path.resolve(__dirname, "./suite/index"); + + // Download VS Code, unzip it and run the integration test + await runTests({ extensionDevelopmentPath, extensionTestsPath }); + } catch (_err) { + // eslint-disable-next-line no-console + console.error("Failed to run tests"); + process.exit(1); + } +} + +// eslint-disable-next-line @typescript-eslint/no-floating-promises +main(); diff --git a/src/test/suite/helpers.ts b/src/test/suite/helpers.ts new file mode 100644 index 0000000..87c6bfa --- /dev/null +++ b/src/test/suite/helpers.ts @@ -0,0 +1,72 @@ +import path from "path"; +import os from "os"; +import fs from "fs"; + +import * as vscode from "vscode"; + +import { MAJOR, MINOR, RUBY_VERSION } from "../rubyVersion"; + +export function createRubySymlinks() { + if (os.platform() === "linux") { + const linkPath = path.join(os.homedir(), ".rubies", RUBY_VERSION); + + if (!fs.existsSync(linkPath)) { + fs.mkdirSync(path.join(os.homedir(), ".rubies"), { recursive: true }); + fs.symlinkSync(`/opt/hostedtoolcache/Ruby/${RUBY_VERSION}/x64`, linkPath); + } + } else if (os.platform() === "darwin") { + const linkPath = path.join(os.homedir(), ".rubies", RUBY_VERSION); + + if (!fs.existsSync(linkPath)) { + fs.mkdirSync(path.join(os.homedir(), ".rubies"), { recursive: true }); + fs.symlinkSync(`/Users/runner/hostedtoolcache/Ruby/${RUBY_VERSION}/arm64`, linkPath); + } + } else { + const linkPath = path.join("C:", `Ruby${MAJOR}${MINOR}-${os.arch()}`); + + if (!fs.existsSync(linkPath)) { + fs.symlinkSync(path.join("C:", "hostedtoolcache", "windows", "Ruby", RUBY_VERSION, "x64"), linkPath); + } + } +} + +class FakeWorkspaceState implements vscode.Memento { + private store: Record = {}; + + keys(): ReadonlyArray { + return Object.keys(this.store); + } + + get(key: string): T | undefined { + return this.store[key]; + } + + update(key: string, value: any): Thenable { + this.store[key] = value; + return Promise.resolve(); + } +} + +export const LSP_WORKSPACE_PATH = path.dirname(path.dirname(path.dirname(__dirname))); +export const LSP_WORKSPACE_URI = vscode.Uri.file(LSP_WORKSPACE_PATH); +export const LSP_WORKSPACE_FOLDER: vscode.WorkspaceFolder = { + uri: LSP_WORKSPACE_URI, + name: path.basename(LSP_WORKSPACE_PATH), + index: 0, +}; + +export type FakeContext = vscode.ExtensionContext & { dispose: () => void }; + +export function createContext() { + const subscriptions: vscode.Disposable[] = []; + + return { + extensionMode: vscode.ExtensionMode.Test, + subscriptions, + workspaceState: new FakeWorkspaceState(), + extensionUri: LSP_WORKSPACE_URI, + dispose: () => { + subscriptions.forEach((subscription) => subscription.dispose()); + }, + } as unknown as FakeContext; +} diff --git a/src/test/suite/index.ts b/src/test/suite/index.ts new file mode 100644 index 0000000..e19e942 --- /dev/null +++ b/src/test/suite/index.ts @@ -0,0 +1,44 @@ +import * as path from "path"; + +import Mocha from "mocha"; +import { glob } from "glob"; + +export function run(): Promise { + // Create the mocha test + const mocha = new Mocha({ + ui: "tdd", + color: true, + }); + + const testsRoot = path.resolve(__dirname, ".."); + + return new Promise((resolve, reject) => { + glob("**/**.test.js", { cwd: testsRoot }) + .then((files: string[]) => { + // Add files to the test suite + files.forEach((file) => mocha.addFile(path.resolve(testsRoot, file))); + + try { + // Run the mocha test + mocha.run((failures) => { + if (failures > 0) { + reject(new Error(`${failures} tests failed.`)); + } else { + resolve(); + } + }); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + const error = err as Error; + reject(error); + } + }) + .catch((globError) => { + if (globError) { + const error = globError as Error; + return reject(error); + } + }); + }); +} diff --git a/src/test/suite/ruby/asdf.test.ts b/src/test/suite/ruby/asdf.test.ts new file mode 100644 index 0000000..aba133c --- /dev/null +++ b/src/test/suite/ruby/asdf.test.ts @@ -0,0 +1,167 @@ +import assert from "assert"; +import path from "path"; +import os from "os"; + +import * as vscode from "vscode"; +import sinon from "sinon"; +import { afterEach, beforeEach } from "mocha"; + +import { Asdf } from "../../../ruby/asdf"; +import { WorkspaceChannel } from "../../../workspaceChannel"; +import * as common from "../../../common"; +import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../../../ruby/versionManager"; +import { createContext, FakeContext } from "../helpers"; + +suite("Asdf", () => { + if (os.platform() === "win32") { + // eslint-disable-next-line no-console + console.log("Skipping Asdf tests on Windows"); + return; + } + let context: FakeContext; + let activationPath: vscode.Uri; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + context = createContext(); + activationPath = vscode.Uri.joinPath(context.extensionUri, "activation.rb"); + }); + + afterEach(() => { + sandbox.restore(); + context.dispose(); + }); + + const workspacePath = process.env.PWD!; + const workspaceFolder = { + uri: vscode.Uri.from({ scheme: "file", path: workspacePath }), + name: path.basename(workspacePath), + index: 0, + }; + const outputChannel = new WorkspaceChannel("fake", common.LOG_CHANNEL); + + test("Finds Ruby based on .tool-versions", async () => { + const asdf = new Asdf(workspaceFolder, outputChannel, context, async () => {}); + const envStub = ["3.0.0", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR); + + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + + sandbox.stub(asdf, "findAsdfInstallation").resolves(`${os.homedir()}/.asdf/asdf.sh`); + sandbox.stub(vscode.env, "shell").get(() => "/bin/bash"); + + const { env, version, yjit } = await asdf.activate(); + + assert.ok( + execStub.calledOnceWithExactly( + `. ${os.homedir()}/.asdf/asdf.sh && asdf exec ruby -EUTF-8:UTF-8 '${activationPath.fsPath}'`, + { + cwd: workspacePath, + shell: "/bin/bash", + + env: process.env, + encoding: "utf-8", + }, + ), + ); + + assert.strictEqual(version, "3.0.0"); + assert.strictEqual(yjit, true); + assert.strictEqual(env.ANY, "true"); + }); + + test("Searches for asdf.fish when using the fish shell", async () => { + const asdf = new Asdf(workspaceFolder, outputChannel, context, async () => {}); + + const envStub = ["3.0.0", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR); + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + + sandbox.stub(asdf, "findAsdfInstallation").resolves(`${os.homedir()}/.asdf/asdf.fish`); + sandbox.stub(vscode.env, "shell").get(() => "/opt/homebrew/bin/fish"); + + const { env, version, yjit } = await asdf.activate(); + + assert.ok( + execStub.calledOnceWithExactly( + `. ${os.homedir()}/.asdf/asdf.fish && asdf exec ruby -EUTF-8:UTF-8 '${activationPath.fsPath}'`, + { + cwd: workspacePath, + shell: "/opt/homebrew/bin/fish", + + env: process.env, + encoding: "utf-8", + }, + ), + ); + + assert.strictEqual(version, "3.0.0"); + assert.strictEqual(yjit, true); + assert.strictEqual(env.ANY, "true"); + }); + + test("Finds ASDF executable for Homebrew if script is not available", async () => { + const asdf = new Asdf(workspaceFolder, outputChannel, context, async () => {}); + + const envStub = ["3.0.0", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR); + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + + sandbox.stub(asdf, "findAsdfInstallation").resolves(undefined); + + sandbox.stub(vscode.workspace, "fs").value({ + stat: () => Promise.resolve(undefined), + }); + + const { env, version, yjit } = await asdf.activate(); + + assert.ok( + execStub.calledOnceWithExactly(`/opt/homebrew/bin/asdf exec ruby -EUTF-8:UTF-8 '${activationPath.fsPath}'`, { + cwd: workspacePath, + shell: vscode.env.shell, + + env: process.env, + encoding: "utf-8", + }), + ); + + assert.strictEqual(version, "3.0.0"); + assert.strictEqual(yjit, true); + assert.strictEqual(env.ANY, "true"); + }); + + test("Uses ASDF executable in PATH if script and Homebrew executable are not available", async () => { + const asdf = new Asdf(workspaceFolder, outputChannel, context, async () => {}); + + const envStub = ["3.0.0", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR); + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + + sandbox.stub(asdf, "findAsdfInstallation").resolves(undefined); + + const { env, version, yjit } = await asdf.activate(); + + assert.ok( + execStub.calledOnceWithExactly(`asdf exec ruby -EUTF-8:UTF-8 '${activationPath.fsPath}'`, { + cwd: workspacePath, + shell: vscode.env.shell, + + env: process.env, + encoding: "utf-8", + }), + ); + + assert.strictEqual(version, "3.0.0"); + assert.strictEqual(yjit, true); + assert.strictEqual(env.ANY, "true"); + }); +}); diff --git a/src/test/suite/ruby/chruby.test.ts b/src/test/suite/ruby/chruby.test.ts new file mode 100644 index 0000000..6606e8f --- /dev/null +++ b/src/test/suite/ruby/chruby.test.ts @@ -0,0 +1,275 @@ +import fs from "fs"; +import assert from "assert"; +import path from "path"; +import os from "os"; + +import { beforeEach, afterEach } from "mocha"; +import * as vscode from "vscode"; +import sinon from "sinon"; + +import { Chruby } from "../../../ruby/chruby"; +import { WorkspaceChannel } from "../../../workspaceChannel"; +import { LOG_CHANNEL } from "../../../common"; +import { RUBY_VERSION, MAJOR, MINOR, VERSION_REGEX } from "../../rubyVersion"; +import { ActivationResult } from "../../../ruby/versionManager"; +import { createContext, FakeContext } from "../helpers"; + +// Create links to the real Ruby installations on CI and on our local machines +function createRubySymlinks(destination: string) { + if (process.env.CI && os.platform() === "linux") { + fs.symlinkSync(`/opt/hostedtoolcache/Ruby/${RUBY_VERSION}/x64/bin/ruby`, destination); + } else if (process.env.CI) { + fs.symlinkSync(`/Users/runner/hostedtoolcache/Ruby/${RUBY_VERSION}/arm64/bin/ruby`, destination); + } else { + const possibleLocations = [ + `${os.homedir()}/.rubies/${RUBY_VERSION}/bin/ruby`, + `${os.homedir()}/.rubies/ruby-${RUBY_VERSION}/bin/ruby`, + `/opt/rubies/${RUBY_VERSION}/bin/ruby`, + `/opt/rubies/ruby-${RUBY_VERSION}/bin/ruby`, + ]; + + for (const location of possibleLocations) { + if (fs.existsSync(location)) { + fs.symlinkSync(location, destination); + break; + } + } + } +} + +suite("Chruby", () => { + if (os.platform() === "win32") { + // eslint-disable-next-line no-console + console.log("Skipping Chruby tests on Windows"); + return; + } + + let rootPath: string; + let workspacePath: string; + let workspaceFolder: vscode.WorkspaceFolder; + let outputChannel: WorkspaceChannel; + let context: FakeContext; + + beforeEach(() => { + rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-chruby-")); + + fs.mkdirSync(path.join(rootPath, "opt", "rubies", RUBY_VERSION, "bin"), { + recursive: true, + }); + + createRubySymlinks(path.join(rootPath, "opt", "rubies", RUBY_VERSION, "bin", "ruby")); + + workspacePath = path.join(rootPath, "workspace"); + fs.mkdirSync(workspacePath); + + workspaceFolder = { + uri: vscode.Uri.from({ scheme: "file", path: workspacePath }), + name: path.basename(workspacePath), + index: 0, + }; + outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL); + context = createContext(); + }); + + afterEach(() => { + fs.rmSync(rootPath, { recursive: true, force: true }); + context.dispose(); + }); + + test("Finds Ruby when .ruby-version is inside workspace", async () => { + fs.writeFileSync(path.join(workspacePath, ".ruby-version"), RUBY_VERSION); + + const chruby = new Chruby(workspaceFolder, outputChannel, context, async () => {}); + chruby.rubyInstallationUris = [vscode.Uri.file(path.join(rootPath, "opt", "rubies"))]; + + const result = await chruby.activate(); + assertActivatedRuby(result); + }).timeout(10000); // 10 seconds + + test("Finds Ruby when .ruby-version is inside on parent directories", async () => { + fs.writeFileSync(path.join(rootPath, ".ruby-version"), RUBY_VERSION); + + const chruby = new Chruby(workspaceFolder, outputChannel, context, async () => {}); + chruby.rubyInstallationUris = [vscode.Uri.file(path.join(rootPath, "opt", "rubies"))]; + + const result = await chruby.activate(); + assertActivatedRuby(result); + }); + + test("Considers any version with a suffix to be the latest", async () => { + // chruby always considers anything with a suffix to be the latest version, even if that's not accurate. For + // example, 3.3.0-rc1 is older than the stable 3.3.0, but running `chruby 3.3.0` will prefer the release candidate + fs.mkdirSync(path.join(rootPath, "opt", "rubies", `${RUBY_VERSION}-rc1`, "bin"), { + recursive: true, + }); + + createRubySymlinks(path.join(rootPath, "opt", "rubies", `${RUBY_VERSION}-rc1`, "bin", "ruby")); + + fs.writeFileSync(path.join(rootPath, ".ruby-version"), RUBY_VERSION); + + const chruby = new Chruby(workspaceFolder, outputChannel, context, async () => {}); + chruby.rubyInstallationUris = [vscode.Uri.file(path.join(rootPath, "opt", "rubies"))]; + + const { env, yjit } = await chruby.activate(); + + // Since we symlink the stable Ruby as if it were a release candidate, we cannot assert the version of gem paths + // because those will match the stable version that is running the activation script. It is enough to verify that we + // inserted the correct Ruby path into the PATH + assert.match(env.PATH!, new RegExp(`\\/opt\\/rubies\\/${VERSION_REGEX}-rc1`)); + assert.notStrictEqual(yjit, undefined); + fs.rmSync(path.join(rootPath, "opt", "rubies", `${RUBY_VERSION}-rc1`), { + recursive: true, + force: true, + }); + }); + + test("Finds right Ruby with explicit release candidate but omitted engine", async () => { + fs.mkdirSync(path.join(rootPath, "opt", "rubies", `${RUBY_VERSION}-rc1`, "bin"), { + recursive: true, + }); + + createRubySymlinks(path.join(rootPath, "opt", "rubies", `${RUBY_VERSION}-rc1`, "bin", "ruby")); + + fs.writeFileSync(path.join(rootPath, ".ruby-version"), `${RUBY_VERSION}-rc1`); + + const chruby = new Chruby(workspaceFolder, outputChannel, context, async () => {}); + chruby.rubyInstallationUris = [vscode.Uri.file(path.join(rootPath, "opt", "rubies"))]; + + const { env, yjit } = await chruby.activate(); + + assert.match(env.PATH!, new RegExp(`\\/opt\\/rubies\\/${VERSION_REGEX}-rc1`)); + assert.notStrictEqual(yjit, undefined); + fs.rmSync(path.join(rootPath, "opt", "rubies", `${RUBY_VERSION}-rc1`), { + recursive: true, + force: true, + }); + }); + + test("Considers Ruby as the default engine if missing", async () => { + const rubyHome = path.join(rootPath, "fakehome", ".rubies"); + fs.mkdirSync(path.join(rubyHome, `ruby-${RUBY_VERSION}`, "bin"), { + recursive: true, + }); + + createRubySymlinks(path.join(rubyHome, `ruby-${RUBY_VERSION}`, "bin", "ruby")); + + fs.writeFileSync(path.join(rootPath, ".ruby-version"), RUBY_VERSION); + + const chruby = new Chruby(workspaceFolder, outputChannel, context, async () => {}); + chruby.rubyInstallationUris = [vscode.Uri.file(rubyHome)]; + + const { env, version, yjit } = await chruby.activate(); + + assert.match(env.PATH!, new RegExp(`/ruby-${RUBY_VERSION}/bin`)); + assert.strictEqual(version, RUBY_VERSION); + assert.notStrictEqual(yjit, undefined); + }); + + test("Finds Ruby when extra RUBIES are configured", async () => { + fs.writeFileSync(path.join(workspacePath, ".ruby-version"), RUBY_VERSION); + + const configStub = sinon.stub(vscode.workspace, "getConfiguration").returns({ + get: (name: string) => (name === "rubyVersionManager.chrubyRubies" ? [path.join(rootPath, "opt", "rubies")] : ""), + } as any); + + const chruby = new Chruby(workspaceFolder, outputChannel, context, async () => {}); + configStub.restore(); + + const result = await chruby.activate(); + assertActivatedRuby(result); + }); + + test("Finds Ruby when .ruby-version omits patch", async () => { + fs.mkdirSync(path.join(rootPath, "opt", "rubies", `${MAJOR}.${MINOR}.0`, "bin"), { + recursive: true, + }); + + fs.writeFileSync(path.join(workspacePath, ".ruby-version"), `${MAJOR}.${MINOR}`); + + const chruby = new Chruby(workspaceFolder, outputChannel, context, async () => {}); + chruby.rubyInstallationUris = [vscode.Uri.file(path.join(rootPath, "opt", "rubies"))]; + + const result = await chruby.activate(); + assertActivatedRuby(result); + + fs.rmSync(path.join(rootPath, "opt", "rubies", `${MAJOR}.${MINOR}.0`), { + recursive: true, + force: true, + }); + }); + + test("Continues searching if first directory doesn't exist for omitted patch", async () => { + fs.mkdirSync(path.join(rootPath, "opt", "rubies", `${MAJOR}.${MINOR}.0`, "bin"), { + recursive: true, + }); + + fs.writeFileSync(path.join(workspacePath, ".ruby-version"), `${MAJOR}.${MINOR}`); + + const chruby = new Chruby(workspaceFolder, outputChannel, context, async () => {}); + chruby.rubyInstallationUris = [ + vscode.Uri.file(path.join(rootPath, ".rubies")), + vscode.Uri.file(path.join(rootPath, "opt", "rubies")), + ]; + + const result = await chruby.activate(); + assertActivatedRuby(result); + }); + + test("Uses latest Ruby as a fallback if no .ruby-version is found", async () => { + const chruby = new Chruby(workspaceFolder, outputChannel, context, async () => {}); + chruby.rubyInstallationUris = [vscode.Uri.file(path.join(rootPath, "opt", "rubies"))]; + + const result = await chruby.activate(); + assertActivatedRuby(result); + }).timeout(20000); + + test("Doesn't try to fallback to latest version if there's a Gemfile with ruby constraints", async () => { + fs.writeFileSync(path.join(workspacePath, "Gemfile"), "ruby '3.3.0'"); + + const chruby = new Chruby(workspaceFolder, outputChannel, context, async () => {}); + chruby.rubyInstallationUris = [vscode.Uri.file(path.join(rootPath, "opt", "rubies"))]; + + await assert.rejects(() => { + return chruby.activate(); + }); + }); + + test("Uses closest Ruby if the version specified in .ruby-version is not installed (patch difference)", async () => { + fs.writeFileSync(path.join(workspacePath, ".ruby-version"), "ruby '3.3.3'"); + + const chruby = new Chruby(workspaceFolder, outputChannel, context, async () => {}); + chruby.rubyInstallationUris = [vscode.Uri.file(path.join(rootPath, "opt", "rubies"))]; + + const result = await chruby.activate(); + assertActivatedRuby(result); + }).timeout(20000); + + test("Uses closest Ruby if the version specified in .ruby-version is not installed (minor difference)", async () => { + fs.writeFileSync(path.join(workspacePath, ".ruby-version"), "ruby '3.2.0'"); + + const chruby = new Chruby(workspaceFolder, outputChannel, context, async () => {}); + chruby.rubyInstallationUris = [vscode.Uri.file(path.join(rootPath, "opt", "rubies"))]; + + const result = await chruby.activate(); + assertActivatedRuby(result); + }).timeout(20000); + + test("Uses closest Ruby if the version specified in .ruby-version is not installed (previews)", async () => { + fs.writeFileSync(path.join(workspacePath, ".ruby-version"), "ruby '3.4.0-preview1'"); + + const chruby = new Chruby(workspaceFolder, outputChannel, context, async () => {}); + chruby.rubyInstallationUris = [vscode.Uri.file(path.join(rootPath, "opt", "rubies"))]; + + const result = await chruby.activate(); + assertActivatedRuby(result); + }).timeout(20000); + + function assertActivatedRuby(activationResult: ActivationResult) { + const { env, version, yjit } = activationResult; + + assert.match(env.GEM_PATH!, new RegExp(`ruby/${VERSION_REGEX}`)); + assert.match(env.GEM_PATH!, new RegExp(`lib/ruby/gems/${VERSION_REGEX}`)); + assert.strictEqual(version, RUBY_VERSION); + assert.notStrictEqual(yjit, undefined); + } +}); diff --git a/src/test/suite/ruby/custom.test.ts b/src/test/suite/ruby/custom.test.ts new file mode 100644 index 0000000..0eec027 --- /dev/null +++ b/src/test/suite/ruby/custom.test.ts @@ -0,0 +1,73 @@ +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 { afterEach, beforeEach } from "mocha"; + +import { Custom } from "../../../ruby/custom"; +import { WorkspaceChannel } from "../../../workspaceChannel"; +import * as common from "../../../common"; +import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../../../ruby/versionManager"; +import { createContext, FakeContext } from "../helpers"; + +suite("Custom", () => { + let context: FakeContext; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + context = createContext(); + }); + + afterEach(() => { + sandbox.restore(); + context.dispose(); + }); + + test("Invokes custom script and then Ruby", async () => { + const workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const uri = vscode.Uri.file(workspacePath); + const workspaceFolder = { + uri, + name: path.basename(workspacePath), + index: 0, + }; + const outputChannel = new WorkspaceChannel("fake", common.LOG_CHANNEL); + const custom = new Custom(workspaceFolder, outputChannel, context, async () => {}); + + const envStub = ["3.0.0", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR); + + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + + sandbox.stub(custom, "customCommand").returns("my_version_manager activate_env"); + const { env, version, yjit } = await custom.activate(); + const activationUri = vscode.Uri.joinPath(context.extensionUri, "activation.rb"); + + // We must not set the shell on Windows + const shell = os.platform() === "win32" ? undefined : vscode.env.shell; + + assert.ok( + execStub.calledOnceWithExactly( + `my_version_manager activate_env && ruby -EUTF-8:UTF-8 '${activationUri.fsPath}'`, + { + cwd: uri.fsPath, + shell, + env: process.env, + encoding: "utf-8", + }, + ), + ); + + assert.strictEqual(version, "3.0.0"); + assert.strictEqual(yjit, true); + assert.deepStrictEqual(env.ANY, "true"); + + fs.rmSync(workspacePath, { recursive: true, force: true }); + }); +}); diff --git a/src/test/suite/ruby/mise.test.ts b/src/test/suite/ruby/mise.test.ts new file mode 100644 index 0000000..a37d5ee --- /dev/null +++ b/src/test/suite/ruby/mise.test.ts @@ -0,0 +1,130 @@ +import assert from "assert"; +import path from "path"; +import os from "os"; +import fs from "fs"; + +import * as vscode from "vscode"; +import sinon from "sinon"; +import { afterEach, beforeEach } from "mocha"; + +import { Mise } from "../../../ruby/mise"; +import { WorkspaceChannel } from "../../../workspaceChannel"; +import * as common from "../../../common"; +import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../../../ruby/versionManager"; +import { createContext, FakeContext } from "../helpers"; + +suite("Mise", () => { + if (os.platform() === "win32") { + // eslint-disable-next-line no-console + console.log("Skipping Mise tests on Windows"); + return; + } + + let context: FakeContext; + let activationPath: vscode.Uri; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + context = createContext(); + activationPath = vscode.Uri.joinPath(context.extensionUri, "activation.rb"); + }); + + afterEach(() => { + sandbox.restore(); + context.dispose(); + }); + + test("Finds Ruby only binary path is appended to PATH", async () => { + const workspacePath = process.env.PWD!; + const workspaceFolder = { + uri: vscode.Uri.from({ scheme: "file", path: workspacePath }), + name: path.basename(workspacePath), + index: 0, + }; + const outputChannel = new WorkspaceChannel("fake", common.LOG_CHANNEL); + const mise = new Mise(workspaceFolder, outputChannel, context, async () => {}); + + const envStub = ["3.0.0", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR); + + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + const findStub = sandbox + .stub(mise, "findMiseUri") + .resolves(vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".local", "bin", "mise")); + + const { env, version, yjit } = await mise.activate(); + + assert.ok( + execStub.calledOnceWithExactly( + `${os.homedir()}/.local/bin/mise x -- ruby -EUTF-8:UTF-8 '${activationPath.fsPath}'`, + { + cwd: workspacePath, + shell: vscode.env.shell, + + env: process.env, + encoding: "utf-8", + }, + ), + ); + + assert.strictEqual(version, "3.0.0"); + assert.strictEqual(yjit, true); + assert.deepStrictEqual(env.ANY, "true"); + + execStub.restore(); + findStub.restore(); + }); + + test("Allows configuring where Mise is installed", async () => { + const workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const workspaceFolder = { + uri: vscode.Uri.from({ scheme: "file", path: workspacePath }), + name: path.basename(workspacePath), + index: 0, + }; + const outputChannel = new WorkspaceChannel("fake", common.LOG_CHANNEL); + const mise = new Mise(workspaceFolder, outputChannel, context, async () => {}); + + const envStub = ["3.0.0", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR); + + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + + const misePath = path.join(workspacePath, "mise"); + fs.writeFileSync(misePath, "fakeMiseBinary"); + + const configStub = sandbox.stub(vscode.workspace, "getConfiguration").returns({ + get: (name: string) => { + if (name === "rubyVersionManager.miseExecutablePath") { + return misePath; + } + return ""; + }, + } as any); + + const { env, version, yjit } = await mise.activate(); + + assert.ok( + execStub.calledOnceWithExactly(`${misePath} x -- ruby -EUTF-8:UTF-8 '${activationPath.fsPath}'`, { + cwd: workspacePath, + shell: vscode.env.shell, + + env: process.env, + encoding: "utf-8", + }), + ); + + 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/src/test/suite/ruby/none.test.ts b/src/test/suite/ruby/none.test.ts new file mode 100644 index 0000000..7879930 --- /dev/null +++ b/src/test/suite/ruby/none.test.ts @@ -0,0 +1,69 @@ +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 { afterEach, beforeEach } from "mocha"; + +import { None } from "../../../ruby/none"; +import { WorkspaceChannel } from "../../../workspaceChannel"; +import * as common from "../../../common"; +import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../../../ruby/versionManager"; +import { createContext, FakeContext } from "../helpers"; + +suite("None", () => { + let context: FakeContext; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + context = createContext(); + }); + + afterEach(() => { + sandbox.restore(); + context.dispose(); + }); + + test("Invokes Ruby directly", async () => { + const workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const uri = vscode.Uri.file(workspacePath); + const workspaceFolder = { + uri, + name: path.basename(workspacePath), + index: 0, + }; + const outputChannel = new WorkspaceChannel("fake", common.LOG_CHANNEL); + const none = new None(workspaceFolder, outputChannel, context, async () => {}); + + const envStub = ["3.0.0", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR); + + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + + const { env, version, yjit } = await none.activate(); + const activationUri = vscode.Uri.joinPath(context.extensionUri, "activation.rb"); + + // We must not set the shell on Windows + const shell = os.platform() === "win32" ? undefined : vscode.env.shell; + + assert.ok( + execStub.calledOnceWithExactly(`ruby -EUTF-8:UTF-8 '${activationUri.fsPath}'`, { + cwd: uri.fsPath, + shell, + env: process.env, + encoding: "utf-8", + }), + ); + + assert.strictEqual(version, "3.0.0"); + assert.strictEqual(yjit, true); + assert.deepStrictEqual(env.ANY, "true"); + + fs.rmSync(workspacePath, { recursive: true, force: true }); + }); +}); diff --git a/src/test/suite/ruby/rbenv.test.ts b/src/test/suite/ruby/rbenv.test.ts new file mode 100644 index 0000000..3ed1a72 --- /dev/null +++ b/src/test/suite/ruby/rbenv.test.ts @@ -0,0 +1,121 @@ +import assert from "assert"; +import path from "path"; +import os from "os"; +import fs from "fs"; + +import * as vscode from "vscode"; +import sinon from "sinon"; +import { afterEach, beforeEach } from "mocha"; + +import { Rbenv } from "../../../ruby/rbenv"; +import { WorkspaceChannel } from "../../../workspaceChannel"; +import * as common from "../../../common"; +import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../../../ruby/versionManager"; +import { createContext, FakeContext } from "../helpers"; + +suite("Rbenv", () => { + if (os.platform() === "win32") { + // eslint-disable-next-line no-console + console.log("Skipping Rbenv tests on Windows"); + return; + } + + let activationPath: vscode.Uri; + let sandbox: sinon.SinonSandbox; + let context: FakeContext; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + context = createContext(); + activationPath = vscode.Uri.joinPath(context.extensionUri, "activation.rb"); + }); + + afterEach(() => { + sandbox.restore(); + context.dispose(); + }); + + test("Finds Ruby based on .ruby-version", async () => { + const workspacePath = process.env.PWD!; + const workspaceFolder = { + uri: vscode.Uri.from({ scheme: "file", path: workspacePath }), + name: path.basename(workspacePath), + index: 0, + }; + const outputChannel = new WorkspaceChannel("fake", common.LOG_CHANNEL); + const rbenv = new Rbenv(workspaceFolder, outputChannel, context, async () => {}); + + const envStub = ["3.0.0", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR); + + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + + const { env, version, yjit } = await rbenv.activate(); + + assert.ok( + execStub.calledOnceWithExactly(`rbenv exec ruby -EUTF-8:UTF-8 '${activationPath.fsPath}'`, { + cwd: workspacePath, + shell: vscode.env.shell, + + env: process.env, + encoding: "utf-8", + }), + ); + + assert.strictEqual(version, "3.0.0"); + assert.strictEqual(yjit, true); + assert.strictEqual(env.ANY, "true"); + }); + + test("Allows configuring where rbenv is installed", async () => { + const workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const workspaceFolder = { + uri: vscode.Uri.from({ scheme: "file", path: workspacePath }), + name: path.basename(workspacePath), + index: 0, + }; + const outputChannel = new WorkspaceChannel("fake", common.LOG_CHANNEL); + const rbenv = new Rbenv(workspaceFolder, outputChannel, context, async () => {}); + + const envStub = ["3.0.0", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR); + + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + + const rbenvPath = path.join(workspacePath, "rbenv"); + fs.writeFileSync(rbenvPath, "fakeRbenvBinary"); + + const configStub = sinon.stub(vscode.workspace, "getConfiguration").returns({ + get: (name: string) => { + if (name === "rubyVersionManager.rbenvExecutablePath") { + return rbenvPath; + } + return ""; + }, + } as any); + + const { env, version, yjit } = await rbenv.activate(); + + assert.ok( + execStub.calledOnceWithExactly(`${rbenvPath} exec ruby -EUTF-8:UTF-8 '${activationPath.fsPath}'`, { + cwd: workspacePath, + shell: vscode.env.shell, + + env: process.env, + encoding: "utf-8", + }), + ); + + 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/src/test/suite/ruby/rubyInstaller.test.ts b/src/test/suite/ruby/rubyInstaller.test.ts new file mode 100644 index 0000000..a962d33 --- /dev/null +++ b/src/test/suite/ruby/rubyInstaller.test.ts @@ -0,0 +1,118 @@ +import fs from "fs"; +import assert from "assert"; +import path from "path"; +import os from "os"; + +import sinon from "sinon"; +import { before, after, beforeEach, afterEach } from "mocha"; +import * as vscode from "vscode"; + +import * as common from "../../../common"; +import { RubyInstaller } from "../../../ruby/rubyInstaller"; +import { WorkspaceChannel } from "../../../workspaceChannel"; +import { LOG_CHANNEL } from "../../../common"; +import { RUBY_VERSION, VERSION_REGEX } from "../../rubyVersion"; +import { ACTIVATION_SEPARATOR } from "../../../ruby/versionManager"; +import { createRubySymlinks, createContext, FakeContext } from "../helpers"; + +suite("RubyInstaller", () => { + if (os.platform() !== "win32") { + // eslint-disable-next-line no-console + console.log("This test can only run on Windows"); + return; + } + + let rootPath: string; + let workspacePath: string; + let workspaceFolder: vscode.WorkspaceFolder; + let outputChannel: WorkspaceChannel; + let context: FakeContext; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + if (process.env.CI) { + createRubySymlinks(); + } + context = createContext(); + }); + + afterEach(() => { + sandbox.restore(); + context.dispose(); + }); + + before(() => { + rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + + workspacePath = path.join(rootPath, "workspace"); + fs.mkdirSync(workspacePath); + + workspaceFolder = { + uri: vscode.Uri.from({ scheme: "file", path: workspacePath }), + name: path.basename(workspacePath), + index: 0, + }; + outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL); + }); + + after(() => { + fs.rmSync(rootPath, { recursive: true, force: true }); + }); + + test("Finds Ruby when under C:/RubyXY-arch", async () => { + fs.writeFileSync(path.join(workspacePath, ".ruby-version"), RUBY_VERSION); + + const windows = new RubyInstaller(workspaceFolder, outputChannel, context, async () => {}); + const { env, version, yjit } = await windows.activate(); + + assert.match(env.GEM_PATH!, new RegExp(`ruby\\\\${VERSION_REGEX}`)); + assert.match(env.GEM_PATH!, new RegExp(`lib\\\\ruby\\\\gems\\\\${VERSION_REGEX}`)); + assert.strictEqual(version, RUBY_VERSION); + assert.notStrictEqual(yjit, undefined); + }); + + test("Finds Ruby when under C:/Users/Username/RubyXY-arch", async () => { + fs.writeFileSync(path.join(workspacePath, ".ruby-version"), RUBY_VERSION); + + const windows = new RubyInstaller(workspaceFolder, outputChannel, context, async () => {}); + const { env, version, yjit } = await windows.activate(); + + assert.match(env.GEM_PATH!, new RegExp(`ruby\\\\${VERSION_REGEX}`)); + assert.match(env.GEM_PATH!, new RegExp(`lib\\\\ruby\\\\gems\\\\${VERSION_REGEX}`)); + assert.strictEqual(version, RUBY_VERSION); + assert.notStrictEqual(yjit, undefined); + }); + + test("Doesn't set the shell when invoking activation script", async () => { + fs.writeFileSync(path.join(workspacePath, ".ruby-version"), RUBY_VERSION); + + const windows = new RubyInstaller(workspaceFolder, outputChannel, context, async () => {}); + const result = ["/fake/dir", "/other/fake/dir", true, RUBY_VERSION].join(ACTIVATION_SEPARATOR); + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: result, + }); + + await windows.activate(); + + assert.strictEqual(execStub.callCount, 1); + const callArgs = execStub.getCall(0).args; + assert.strictEqual(callArgs[1]?.shell, undefined); + }); + + test("Normalizes long file formats to back slashes", async () => { + fs.writeFileSync(path.join(workspacePath, ".ruby-version"), RUBY_VERSION); + + const windows = new RubyInstaller(workspaceFolder, outputChannel, context, async () => {}); + const result = ["//?/C:/Ruby32/gems", "//?/C:/Ruby32/default_gems", true, RUBY_VERSION].join(ACTIVATION_SEPARATOR); + sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: result, + }); + + const { gemPath } = await windows.activate(); + + assert.deepStrictEqual(gemPath, ["\\\\?\\C:\\Ruby32\\default_gems", "\\\\?\\C:\\Ruby32\\gems"]); + }); +}); diff --git a/src/test/suite/ruby/rvm.test.ts b/src/test/suite/ruby/rvm.test.ts new file mode 100644 index 0000000..b1d6c8d --- /dev/null +++ b/src/test/suite/ruby/rvm.test.ts @@ -0,0 +1,79 @@ +import assert from "assert"; +import path from "path"; +import os from "os"; + +import * as vscode from "vscode"; +import sinon from "sinon"; +import { afterEach, beforeEach } from "mocha"; + +import { Rvm } from "../../../ruby/rvm"; +import { WorkspaceChannel } from "../../../workspaceChannel"; +import * as common from "../../../common"; +import { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR } from "../../../ruby/versionManager"; +import { createContext, FakeContext } from "../helpers"; + +suite("RVM", () => { + if (os.platform() === "win32") { + // eslint-disable-next-line no-console + console.log("Skipping RVM tests on Windows"); + return; + } + + let context: FakeContext; + let activationPath: vscode.Uri; + let sandbox: sinon.SinonSandbox; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + context = createContext(); + activationPath = vscode.Uri.joinPath(context.extensionUri, "activation.rb"); + }); + + afterEach(() => { + sandbox.restore(); + context.dispose(); + }); + + test("Populates the gem env and path", async () => { + const workspacePath = process.env.PWD!; + const workspaceFolder = { + uri: vscode.Uri.from({ scheme: "file", path: workspacePath }), + name: path.basename(workspacePath), + index: 0, + }; + const outputChannel = new WorkspaceChannel("fake", common.LOG_CHANNEL); + const rvm = new Rvm(workspaceFolder, outputChannel, context, async () => {}); + + const installationPathStub = sandbox + .stub(rvm, "findRvmInstallation") + .resolves(vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".rvm", "bin", "rvm-auto-ruby")); + + const envStub = ["3.0.0", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR); + + const execStub = sandbox.stub(common, "asyncExec").resolves({ + stdout: "", + stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, + }); + + const { env, version, yjit } = await rvm.activate(); + + assert.ok( + execStub.calledOnceWithExactly( + `${path.join(os.homedir(), ".rvm", "bin", "rvm-auto-ruby")} -EUTF-8:UTF-8 '${activationPath.fsPath}'`, + { + cwd: workspacePath, + shell: vscode.env.shell, + env: process.env, + encoding: "utf-8", + }, + ), + ); + + assert.strictEqual(version, "3.0.0"); + assert.strictEqual(yjit, true); + assert.deepStrictEqual(env.ANY, "true"); + + execStub.restore(); + installationPathStub.restore(); + }); +}); diff --git a/src/test/suite/ruby/shadowenv.test.ts b/src/test/suite/ruby/shadowenv.test.ts new file mode 100644 index 0000000..8e18b71 --- /dev/null +++ b/src/test/suite/ruby/shadowenv.test.ts @@ -0,0 +1,196 @@ +import fs from "fs"; +import assert from "assert"; +import path from "path"; +import os from "os"; +import { execSync } from "child_process"; + +import { beforeEach, afterEach } from "mocha"; +import * as vscode from "vscode"; +import sinon from "sinon"; + +import { Shadowenv } from "../../../ruby/shadowenv"; +import { WorkspaceChannel } from "../../../workspaceChannel"; +import { LOG_CHANNEL, asyncExec } from "../../../common"; +import { RUBY_VERSION } from "../../rubyVersion"; +import * as common from "../../../common"; +import { createContext, FakeContext } from "../helpers"; + +suite("Shadowenv", () => { + if (os.platform() === "win32") { + // eslint-disable-next-line no-console + console.log("Skipping Shadowenv tests on Windows"); + return; + } + + try { + execSync("shadowenv --version >/dev/null 2>&1"); + } catch { + // eslint-disable-next-line no-console + console.log("Skipping Shadowenv tests because no `shadowenv` found"); + return; + } + + let context: FakeContext; + beforeEach(() => { + context = createContext(); + }); + afterEach(() => { + context.dispose(); + }); + + let rootPath: string; + let workspacePath: string; + let workspaceFolder: vscode.WorkspaceFolder; + let outputChannel: WorkspaceChannel; + let rubyBinPath: string; + const [major, minor, patch] = RUBY_VERSION.split("."); + + if (process.env.CI && os.platform() === "linux") { + rubyBinPath = path.join("/", "opt", "hostedtoolcache", "Ruby", RUBY_VERSION, "x64", "bin"); + } else if (process.env.CI) { + rubyBinPath = path.join("/", "Users", "runner", "hostedtoolcache", "Ruby", RUBY_VERSION, "arm64", "bin"); + } else { + rubyBinPath = path.join("/", "opt", "rubies", RUBY_VERSION, "bin"); + } + + assert.ok(fs.existsSync(rubyBinPath), `Ruby bin path does not exist ${rubyBinPath}`); + + const shadowLispFile = ` + (provide "ruby" "${RUBY_VERSION}") + + (when-let ((ruby-root (env/get "RUBY_ROOT"))) + (env/remove-from-pathlist "PATH" (path-concat ruby-root "bin")) + (when-let ((gem-root (env/get "GEM_ROOT"))) + (env/remove-from-pathlist "PATH" (path-concat gem-root "bin"))) + (when-let ((gem-home (env/get "GEM_HOME"))) + (env/remove-from-pathlist "PATH" (path-concat gem-home "bin")))) + + (env/set "BUNDLE_PATH" ()) + (env/set "GEM_PATH" ()) + (env/set "GEM_HOME" ()) + (env/set "RUBYOPT" ()) + (env/set "RUBYLIB" ()) + + (env/set "RUBY_ROOT" "${path.dirname(rubyBinPath)}") + (env/prepend-to-pathlist "PATH" "${rubyBinPath}") + (env/set "RUBY_ENGINE" "ruby") + (env/set "RUBY_VERSION" "${RUBY_VERSION}") + (env/set "GEM_ROOT" "${path.dirname(rubyBinPath)}/lib/ruby/gems/${major}.${minor}.0") + + (when-let ((gem-root (env/get "GEM_ROOT"))) + (env/prepend-to-pathlist "GEM_PATH" gem-root) + (env/prepend-to-pathlist "PATH" (path-concat gem-root "bin"))) + + (let ((gem-home + (path-concat (env/get "HOME") ".gem" (env/get "RUBY_ENGINE") "${RUBY_VERSION}"))) + (do + (env/set "GEM_HOME" gem-home) + (env/prepend-to-pathlist "GEM_PATH" gem-home) + (env/prepend-to-pathlist "PATH" (path-concat gem-home "bin")))) + `; + + beforeEach(() => { + rootPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-shadowenv-")); + workspacePath = path.join(rootPath, "workspace"); + + fs.mkdirSync(workspacePath); + fs.mkdirSync(path.join(workspacePath, ".shadowenv.d")); + + workspaceFolder = { + uri: vscode.Uri.from({ scheme: "file", path: workspacePath }), + name: path.basename(workspacePath), + index: 0, + }; + outputChannel = new WorkspaceChannel("fake", LOG_CHANNEL); + }); + + afterEach(() => { + fs.rmSync(rootPath, { recursive: true, force: true }); + }); + + test("Finds Ruby only binary path is appended to PATH", async () => { + await asyncExec("shadowenv trust", { cwd: workspacePath }); + + fs.writeFileSync( + path.join(workspacePath, ".shadowenv.d", "500_ruby.lisp"), + `(env/prepend-to-pathlist "PATH" "${rubyBinPath}")`, + ); + + const shadowenv = new Shadowenv(workspaceFolder, outputChannel, context, async () => {}); + const { env, version, yjit } = await shadowenv.activate(); + + assert.match(env.PATH!, new RegExp(rubyBinPath)); + assert.strictEqual(version, RUBY_VERSION); + assert.notStrictEqual(yjit, undefined); + }); + + test("Finds Ruby on a complete shadowenv configuration", async () => { + await asyncExec("shadowenv trust", { cwd: workspacePath }); + + fs.writeFileSync(path.join(workspacePath, ".shadowenv.d", "500_ruby.lisp"), shadowLispFile); + + const shadowenv = new Shadowenv(workspaceFolder, outputChannel, context, async () => {}); + const { env, version, yjit } = await shadowenv.activate(); + + assert.match(env.PATH!, new RegExp(rubyBinPath)); + assert.strictEqual(env.GEM_ROOT, `${path.dirname(rubyBinPath)}/lib/ruby/gems/${major}.${minor}.0`); + assert.strictEqual(version, RUBY_VERSION); + assert.notStrictEqual(yjit, undefined); + }); + + test("Untrusted workspace offers to trust it", async () => { + fs.writeFileSync(path.join(workspacePath, ".shadowenv.d", "500_ruby.lisp"), shadowLispFile); + + const stub = sinon.stub(vscode.window, "showErrorMessage").resolves("Trust workspace" as any); + + const shadowenv = new Shadowenv(workspaceFolder, outputChannel, context, async () => {}); + const { env, version, yjit } = await shadowenv.activate(); + + assert.match(env.PATH!, new RegExp(rubyBinPath)); + assert.match(env.GEM_HOME!, new RegExp(`\\.gem\\/ruby\\/${major}\\.${minor}\\.${patch}`)); + assert.strictEqual(version, RUBY_VERSION); + assert.notStrictEqual(yjit, undefined); + + assert.ok(stub.calledOnce); + + stub.restore(); + }); + + test("Deciding not to trust the workspace fails activation", async () => { + fs.writeFileSync(path.join(workspacePath, ".shadowenv.d", "500_ruby.lisp"), shadowLispFile); + + const stub = sinon.stub(vscode.window, "showErrorMessage").resolves("Cancel" as any); + + const shadowenv = new Shadowenv(workspaceFolder, outputChannel, context, async () => {}); + + await assert.rejects(async () => { + await shadowenv.activate(); + }); + + assert.ok(stub.calledOnce); + + stub.restore(); + }); + + test("Warns user is shadowenv executable can't be found", async () => { + await asyncExec("shadowenv trust", { cwd: workspacePath }); + + fs.writeFileSync(path.join(workspacePath, ".shadowenv.d", "500_ruby.lisp"), shadowLispFile); + + const shadowenv = new Shadowenv(workspaceFolder, outputChannel, context, async () => {}); + + // 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 () => { + await shadowenv.activate(); + }); + + execStub.restore(); + }); +}); diff --git a/src/test/suite/workspaceChannel.test.ts b/src/test/suite/workspaceChannel.test.ts new file mode 100644 index 0000000..86b1e98 --- /dev/null +++ b/src/test/suite/workspaceChannel.test.ts @@ -0,0 +1,24 @@ +import * as assert from "assert"; + +import * as vscode from "vscode"; + +import { WorkspaceChannel } from "../../workspaceChannel"; + +class FakeChannel { + public readonly messages: string[] = []; + + info(message: string) { + this.messages.push(message); + } +} + +suite("Workspace channel", () => { + test("prepends name as a prefix", () => { + const fakeChannel = new FakeChannel(); + const channel = new WorkspaceChannel("test", fakeChannel as unknown as vscode.LogOutputChannel); + + channel.info("hello!"); + assert.strictEqual(fakeChannel.messages.length, 1); + assert.strictEqual(fakeChannel.messages[0], "(test) hello!"); + }); +}); diff --git a/yarn.lock b/yarn.lock index 5bc843e..cdb741d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -669,6 +669,28 @@ resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz#719df7fb41766bc143369eaa0dd56d8dc87c9958" integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg== +"@sinonjs/commons@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^13.0.5": + version "13.0.5" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz#36b9dbc21ad5546486ea9173d6bea063eb1717d5" + integrity sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw== + dependencies: + "@sinonjs/commons" "^3.0.1" + +"@sinonjs/samsam@^8.0.1": + version "8.0.3" + resolved "https://registry.yarnpkg.com/@sinonjs/samsam/-/samsam-8.0.3.tgz#eb6ffaef421e1e27783cc9b52567de20cb28072d" + integrity sha512-hw6HbX+GyVZzmaYNh82Ecj1vdGZrqVIn/keDTg63IgAwiQPO+xCz99uG6Woqgb4tM0mUiFENKZ4cqd7IX94AXQ== + dependencies: + "@sinonjs/commons" "^3.0.1" + type-detect "^4.1.0" + "@textlint/ast-node-types@15.2.1": version "15.2.1" resolved "https://registry.yarnpkg.com/@textlint/ast-node-types/-/ast-node-types-15.2.1.tgz#b98ce5bdf9e39941caa02e4cfcee459656c82b21" @@ -755,6 +777,18 @@ resolved "https://registry.yarnpkg.com/@types/sarif/-/sarif-2.1.7.tgz#dab4d16ba7568e9846c454a8764f33c5d98e5524" integrity sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ== +"@types/sinon@^17.0.4": + version "17.0.4" + resolved "https://registry.yarnpkg.com/@types/sinon/-/sinon-17.0.4.tgz#fd9a3e8e07eea1a3f4a6f82a972c899e5778f369" + integrity sha512-RHnIrhfPO3+tJT0s7cFaXGZvsL4bbR3/k7z3P312qMS4JaS2Tk+KiwiLx1S0rQ56ERj00u1/BtdyVd0FY+Pdew== + dependencies: + "@types/sinonjs__fake-timers" "*" + +"@types/sinonjs__fake-timers@*": + version "8.1.5" + resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-8.1.5.tgz#5fd3592ff10c1e9695d377020c033116cc2889f2" + integrity sha512-mQkU2jY8jJEF7YHjHvsQO8+3ughTL1mcnn96igfhONmR+fUPSKIkefQYpSe8bsly2Ep7oQbn/6VG5/9/0qcArQ== + "@types/vscode@^1.102.0": version "1.102.0" resolved "https://registry.yarnpkg.com/@types/vscode/-/vscode-1.102.0.tgz#186dd6d4755807754a18ca869384c93b821039f2" @@ -2198,7 +2232,7 @@ glob@^10.3.10, glob@^10.4.5: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" -glob@^11.0.0: +glob@^11.0.0, glob@^11.0.3: version "11.0.3" resolved "https://registry.yarnpkg.com/glob/-/glob-11.0.3.tgz#9d8087e6d72ddb3c4707b1d2778f80ea3eaefcd6" integrity sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA== @@ -3943,6 +3977,17 @@ simple-invariant@^2.0.1: resolved "https://registry.yarnpkg.com/simple-invariant/-/simple-invariant-2.0.1.tgz#b8935284d31bc0c2719582f9cddf17bee8f57526" integrity sha512-1sbhsxqI+I2tqlmjbz99GXNmZtr6tKIyEgGGnJw/MKGblalqk/XoOYYFJlBzTKZCxx8kLaD3FD5s9BEEjx5Pyg== +sinon@^21.0.0: + version "21.0.0" + resolved "https://registry.yarnpkg.com/sinon/-/sinon-21.0.0.tgz#dbda73abc7e6cb803fef3368cfbecbb5936e8a9e" + integrity sha512-TOgRcwFPbfGtpqvZw+hyqJDvqfapr1qUlOizROIk4bBLjlsjlB00Pg6wMFXNtJRpu+eCZuVOaLatG7M8105kAw== + dependencies: + "@sinonjs/commons" "^3.0.1" + "@sinonjs/fake-timers" "^13.0.5" + "@sinonjs/samsam" "^8.0.1" + diff "^7.0.0" + supports-color "^7.2.0" + slash@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/slash/-/slash-5.1.0.tgz#be3adddcdf09ac38eebe8dcdc7b1a57a75b095ce" @@ -4143,7 +4188,7 @@ supports-color@^5.3.0: dependencies: has-flag "^3.0.0" -supports-color@^7.0.0, supports-color@^7.1.0: +supports-color@^7.0.0, supports-color@^7.1.0, supports-color@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== @@ -4289,6 +4334,16 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-detect@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.1.0.tgz#deb2453e8f08dcae7ae98c626b13dddb0155906c" + integrity sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw== + type-fest@^4.39.1, type-fest@^4.6.0: version "4.41.0" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-4.41.0.tgz#6ae1c8e5731273c2bf1f58ad39cbae2c91a46c58"