diff --git a/vscode/src/common.ts b/vscode/src/common.ts index d7f38cbd50..e85f376db1 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -144,3 +144,9 @@ export function featureEnabled(feature: keyof typeof FEATURE_FLAGS): boolean { // If that number is below the percentage, then the feature is enabled for this user return hashNum < percentage; } + +// Helper to create a URI from a file path and optional path segments +// Usage: pathToUri("/", "opt", "bin") instead of vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "bin") +export function pathToUri(basePath: string, ...segments: string[]): vscode.Uri { + return vscode.Uri.joinPath(vscode.Uri.file(basePath), ...segments); +} diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index abf45d180d..416a2f313a 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -1,9 +1,8 @@ import path from "path"; -import os from "os"; import * as vscode from "vscode"; -import { asyncExec, RubyInterface } from "./common"; +import { RubyInterface } from "./common"; import { WorkspaceChannel } from "./workspaceChannel"; import { Shadowenv, UntrustedWorkspaceError } from "./ruby/shadowenv"; import { Chruby } from "./ruby/chruby"; @@ -16,25 +15,6 @@ import { None } from "./ruby/none"; import { Custom } from "./ruby/custom"; import { Asdf } from "./ruby/asdf"; -async function detectMise() { - 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 true; - } catch (_error: any) { - // Continue looking - } - } - - return false; -} - export enum ManagerIdentifier { Asdf = "asdf", Auto = "auto", @@ -52,6 +32,30 @@ export interface ManagerConfiguration { identifier: ManagerIdentifier; } +interface ManagerClass { + new ( + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + context: vscode.ExtensionContext, + manuallySelectRuby: () => Promise, + ...args: any[] + ): VersionManager; + detect: (workspaceFolder: vscode.WorkspaceFolder, outputChannel: WorkspaceChannel) => Promise; +} + +const VERSION_MANAGERS: Record = { + [ManagerIdentifier.Shadowenv]: Shadowenv, + [ManagerIdentifier.Asdf]: Asdf, + [ManagerIdentifier.Chruby]: Chruby, + [ManagerIdentifier.Rbenv]: Rbenv, + [ManagerIdentifier.Rvm]: Rvm, + [ManagerIdentifier.Mise]: Mise, + [ManagerIdentifier.RubyInstaller]: RubyInstaller, + [ManagerIdentifier.Custom]: Custom, + [ManagerIdentifier.Auto]: None, // Auto is handled specially + [ManagerIdentifier.None]: None, // None is last as the fallback +}; + export class Ruby implements RubyInterface { public rubyVersion?: string; // This property indicates that Ruby has been compiled with YJIT support and that we're running on a Ruby version @@ -63,7 +67,6 @@ export class Ruby implements RubyInterface { .getConfiguration("rubyLsp") .get("rubyVersionManager")!; - private readonly shell = process.env.SHELL?.replace(/(\s+)/g, "\\$1"); private _env: NodeJS.ProcessEnv = {}; private _error = false; private readonly context: vscode.ExtensionContext; @@ -287,53 +290,14 @@ export class Ruby implements RubyInterface { } private async runManagerActivation() { - switch (this.versionManager.identifier) { - case ManagerIdentifier.Asdf: - await this.runActivation( - new Asdf(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - case ManagerIdentifier.Chruby: - await this.runActivation( - new Chruby(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - case ManagerIdentifier.Rbenv: - await this.runActivation( - new Rbenv(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - case ManagerIdentifier.Rvm: - await this.runActivation( - new Rvm(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - case ManagerIdentifier.Mise: - await this.runActivation( - new Mise(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - case ManagerIdentifier.RubyInstaller: - await this.runActivation( - new RubyInstaller(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - case ManagerIdentifier.Custom: - await this.runActivation( - new Custom(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - case ManagerIdentifier.None: - await this.runActivation( - new None(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - default: - await this.runActivation( - new Shadowenv(this.workspaceFolder, this.outputChannel, this.context, this.manuallySelectRuby.bind(this)), - ); - break; - } + const ManagerClass = VERSION_MANAGERS[this.versionManager.identifier]; + const manager = new ManagerClass( + this.workspaceFolder, + this.outputChannel, + this.context, + this.manuallySelectRuby.bind(this), + ); + await this.runActivation(manager); } private async setupBundlePath() { @@ -352,59 +316,18 @@ export class Ruby implements RubyInterface { } private async discoverVersionManager() { - // For shadowenv, it wouldn't be enough to check for the executable's existence. We need to check if the project has - // created a .shadowenv.d folder - try { - await vscode.workspace.fs.stat(vscode.Uri.joinPath(this.workspaceFolder.uri, ".shadowenv.d")); - this.versionManager.identifier = ManagerIdentifier.Shadowenv; - return; - } catch (_error: any) { - // If .shadowenv.d doesn't exist, then we check the other version managers - } - - const managers = [ManagerIdentifier.Chruby, ManagerIdentifier.Rbenv, ManagerIdentifier.Rvm, ManagerIdentifier.Asdf]; - - for (const tool of managers) { - const exists = await this.toolExists(tool); + // Check all managers for detection + const entries = Object.entries(VERSION_MANAGERS) as [ManagerIdentifier, ManagerClass][]; - if (exists) { - this.versionManager = tool; - return; + for (const [identifier, ManagerClass] of entries) { + if (identifier === ManagerIdentifier.Auto) { + continue; } - } - - if (await detectMise()) { - this.versionManager = ManagerIdentifier.Mise; - return; - } - - if (os.platform() === "win32") { - this.versionManager = ManagerIdentifier.RubyInstaller; - return; - } - // If we can't find a version manager, just return None - this.versionManager = ManagerIdentifier.None; - } - - private async toolExists(tool: string) { - try { - let command = this.shell ? `${this.shell} -i -c '` : ""; - command += `${tool} --version`; - - if (this.shell) { - command += "'"; + if (await ManagerClass.detect(this.workspaceFolder, this.outputChannel)) { + this.versionManager = identifier; + return; } - - this.outputChannel.info(`Checking if ${tool} is available on the path with command: ${command}`); - - await asyncExec(command, { - cwd: this.workspaceFolder.uri.fsPath, - timeout: 1000, - }); - return true; - } catch { - return false; } } diff --git a/vscode/src/ruby/asdf.ts b/vscode/src/ruby/asdf.ts index e06ff5a34b..6a3b15bc7a 100644 --- a/vscode/src/ruby/asdf.ts +++ b/vscode/src/ruby/asdf.ts @@ -4,25 +4,68 @@ import path from "path"; import * as vscode from "vscode"; import { VersionManager, ActivationResult } from "./versionManager"; +import { WorkspaceChannel } from "../workspaceChannel"; +import { pathToUri } from "../common"; // 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 { + private static getPossibleExecutablePaths(): vscode.Uri[] { // 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"), + return [pathToUri("/", "opt", "homebrew", "bin"), pathToUri("/", "usr", "local", "bin")]; + } + + private static getPossibleScriptPaths(): vscode.Uri[] { + 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 + return [ + pathToUri(os.homedir(), ".asdf", scriptName), + pathToUri("/", "opt", "asdf-vm", scriptName), + pathToUri("/", "opt", "homebrew", "opt", "asdf", "libexec", scriptName), + pathToUri("/", "usr", "local", "opt", "asdf", "libexec", scriptName), ]; + } + + static async detect( + _workspaceFolder: vscode.WorkspaceFolder, + _outputChannel: WorkspaceChannel, + ): Promise { + // Check for v0.16+ executables first + const executablePaths = Asdf.getPossibleExecutablePaths(); + const asdfExecPaths = executablePaths.map((dir) => vscode.Uri.joinPath(dir, "asdf")); + const execResult = await VersionManager.findFirst(asdfExecPaths); + if (execResult) { + return execResult; + } + + // Check for < v0.16 scripts + return VersionManager.findFirst(Asdf.getPossibleScriptPaths()); + } - // 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")); + async activate(): Promise { + // Prefer the path configured by the user, then use detect() to find ASDF + const configuredPath = await this.getConfiguredAsdfPath(); + const asdfUri = configuredPath + ? vscode.Uri.file(configuredPath) + : await Asdf.detect(this.workspaceFolder, this.outputChannel); + if (!asdfUri) { + throw new Error( + `Could not find ASDF installation. Searched in ${[ + ...Asdf.getPossibleExecutablePaths(), + ...Asdf.getPossibleScriptPaths(), + ].join(", ")}`, + ); + } + + const asdfPath = asdfUri.fsPath; // 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`; @@ -37,36 +80,6 @@ export class Asdf extends VersionManager { }; } - // 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"); diff --git a/vscode/src/ruby/chruby.ts b/vscode/src/ruby/chruby.ts index 0036efb672..ccf57c34c2 100644 --- a/vscode/src/ruby/chruby.ts +++ b/vscode/src/ruby/chruby.ts @@ -4,6 +4,7 @@ import path from "path"; import * as vscode from "vscode"; import { WorkspaceChannel } from "../workspaceChannel"; +import { pathToUri } from "../common"; import { ActivationResult, VersionManager, ACTIVATION_SEPARATOR } from "./versionManager"; @@ -17,11 +18,16 @@ class RubyActivationCancellationError extends Error {} // A tool to change the current Ruby version // Learn more: https://github.com/postmodern/chruby export class Chruby extends VersionManager { + static async detect( + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + ): Promise { + const exists = await VersionManager.toolExists("chruby", workspaceFolder, outputChannel); + return exists ? vscode.Uri.file("chruby") : undefined; + } + // 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"), - ]; + public rubyInstallationUris = [pathToUri(os.homedir(), ".rubies"), pathToUri("/", "opt", "rubies")]; constructor( workspaceFolder: vscode.WorkspaceFolder, diff --git a/vscode/src/ruby/custom.ts b/vscode/src/ruby/custom.ts index c4564c7946..f64924ddcb 100644 --- a/vscode/src/ruby/custom.ts +++ b/vscode/src/ruby/custom.ts @@ -8,6 +8,14 @@ import { VersionManager, ActivationResult } from "./versionManager"; // 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 { + // eslint-disable-next-line @typescript-eslint/require-await + static async detect( + _workspaceFolder: vscode.WorkspaceFolder, + _outputChannel: vscode.LogOutputChannel, + ): Promise { + return undefined; + } + async activate(): Promise { const parsedResult = await this.runEnvActivationScript(`${this.customCommand()} && ruby`); diff --git a/vscode/src/ruby/mise.ts b/vscode/src/ruby/mise.ts index 2c647ed4de..43797b4b1e 100644 --- a/vscode/src/ruby/mise.ts +++ b/vscode/src/ruby/mise.ts @@ -3,11 +3,33 @@ import os from "os"; import * as vscode from "vscode"; import { VersionManager, ActivationResult } from "./versionManager"; +import { WorkspaceChannel } from "../workspaceChannel"; +import { pathToUri } from "../common"; // 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 { + // 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` + private static getPossiblePaths(): vscode.Uri[] { + return [ + pathToUri(os.homedir(), ".local", "bin", "mise"), + pathToUri("/", "opt", "homebrew", "bin", "mise"), + pathToUri("/", "usr", "bin", "mise"), + ]; + } + + static async detect( + _workspaceFolder: vscode.WorkspaceFolder, + _outputChannel: WorkspaceChannel, + ): Promise { + return VersionManager.findFirst(Mise.getPossiblePaths()); + } + async activate(): Promise { const miseUri = await this.findMiseUri(); @@ -37,26 +59,13 @@ export class Mise extends VersionManager { } } - // 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"), - ]; + const detectedPath = await Mise.detect(this.workspaceFolder, this.outputChannel); - for (const possiblePath of possiblePaths) { - try { - await vscode.workspace.fs.stat(possiblePath); - return possiblePath; - } catch (_error: any) { - // Continue looking - } + if (detectedPath) { + return detectedPath; } + const possiblePaths = Mise.getPossiblePaths(); 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/vscode/src/ruby/none.ts b/vscode/src/ruby/none.ts index 0c6a8c7e54..a6fb89079c 100644 --- a/vscode/src/ruby/none.ts +++ b/vscode/src/ruby/none.ts @@ -13,6 +13,15 @@ import { VersionManager, ActivationResult } from "./versionManager"; // 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 { + // eslint-disable-next-line @typescript-eslint/require-await + static async detect( + _workspaceFolder: vscode.WorkspaceFolder, + _outputChannel: vscode.LogOutputChannel, + ): Promise { + // None always matches as the final fallback + return vscode.Uri.file("none"); + } + private readonly rubyPath: string; constructor( diff --git a/vscode/src/ruby/rbenv.ts b/vscode/src/ruby/rbenv.ts index 4c7308ca9d..b3db87bd17 100644 --- a/vscode/src/ruby/rbenv.ts +++ b/vscode/src/ruby/rbenv.ts @@ -1,11 +1,20 @@ import * as vscode from "vscode"; import { VersionManager, ActivationResult } from "./versionManager"; +import { WorkspaceChannel } from "../workspaceChannel"; // Seamlessly manage your app’s Ruby environment with rbenv. // // Learn more: https://github.com/rbenv/rbenv export class Rbenv extends VersionManager { + static async detect( + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + ): Promise { + const exists = await VersionManager.toolExists("rbenv", workspaceFolder, outputChannel); + return exists ? vscode.Uri.file("rbenv") : undefined; + } + async activate(): Promise { const rbenvExec = await this.findRbenv(); diff --git a/vscode/src/ruby/rubyInstaller.ts b/vscode/src/ruby/rubyInstaller.ts index 3adc0503bb..cf6ecddcf6 100644 --- a/vscode/src/ruby/rubyInstaller.ts +++ b/vscode/src/ruby/rubyInstaller.ts @@ -3,6 +3,7 @@ import os from "os"; import * as vscode from "vscode"; import { Chruby } from "./chruby"; +import { pathToUri } from "../common"; interface RubyVersion { engine?: string; @@ -16,6 +17,14 @@ interface RubyVersion { // // 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 { + // eslint-disable-next-line @typescript-eslint/require-await + static async detect( + _workspaceFolder: vscode.WorkspaceFolder, + _outputChannel: vscode.LogOutputChannel, + ): Promise { + return os.platform() === "win32" ? vscode.Uri.file("RubyInstaller") : undefined; + } + // 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() { @@ -27,8 +36,8 @@ export class RubyInstaller extends Chruby { 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()}`), + pathToUri("C:", `Ruby${major}${minor}-${os.arch()}`), + pathToUri(os.homedir(), `Ruby${major}${minor}-${os.arch()}`), ]; for (const installationUri of possibleInstallationUris) { diff --git a/vscode/src/ruby/rvm.ts b/vscode/src/ruby/rvm.ts index 66f508db0c..47fd7fced4 100644 --- a/vscode/src/ruby/rvm.ts +++ b/vscode/src/ruby/rvm.ts @@ -3,12 +3,22 @@ import os from "os"; import * as vscode from "vscode"; import { ActivationResult, VersionManager } from "./versionManager"; +import { WorkspaceChannel } from "../workspaceChannel"; +import { pathToUri } from "../common"; // 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 { + static async detect( + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + ): Promise { + const exists = await VersionManager.toolExists("rvm", workspaceFolder, outputChannel); + return exists ? vscode.Uri.file("rvm") : undefined; + } + async activate(): Promise { const installationPath = await this.findRvmInstallation(); const parsedResult = await this.runEnvActivationScript(installationPath.fsPath); @@ -29,9 +39,9 @@ export class Rvm extends VersionManager { 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"), + pathToUri(os.homedir(), ".rvm", "bin", "rvm-auto-ruby"), + pathToUri("/", "usr", "local", "rvm", "bin", "rvm-auto-ruby"), + pathToUri("/", "usr", "share", "rvm", "bin", "rvm-auto-ruby"), ]; for (const uri of possiblePaths) { diff --git a/vscode/src/ruby/shadowenv.ts b/vscode/src/ruby/shadowenv.ts index a683ad8e2f..b6b03d6e09 100644 --- a/vscode/src/ruby/shadowenv.ts +++ b/vscode/src/ruby/shadowenv.ts @@ -11,10 +11,26 @@ import { VersionManager, ActivationResult } from "./versionManager"; export class UntrustedWorkspaceError extends Error {} export class Shadowenv extends VersionManager { - async activate(): Promise { + private static async shadowenvDirExists(workspaceUri: vscode.Uri): Promise { try { - await vscode.workspace.fs.stat(vscode.Uri.joinPath(this.bundleUri, ".shadowenv.d")); + await vscode.workspace.fs.stat(vscode.Uri.joinPath(workspaceUri, ".shadowenv.d")); + return true; } catch (_error: any) { + return false; + } + } + + static async detect( + workspaceFolder: vscode.WorkspaceFolder, + _outputChannel: vscode.LogOutputChannel, + ): Promise { + const exists = await Shadowenv.shadowenvDirExists(workspaceFolder.uri); + return exists ? vscode.Uri.joinPath(workspaceFolder.uri, ".shadowenv.d") : undefined; + } + + async activate(): Promise { + const exists = await Shadowenv.shadowenvDirExists(this.bundleUri); + if (!exists) { throw new Error( "The Ruby LSP version manager is configured to be shadowenv, \ but no .shadowenv.d directory was found in the workspace", diff --git a/vscode/src/ruby/versionManager.ts b/vscode/src/ruby/versionManager.ts index 66c591ce7f..f80bae2ba9 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -19,6 +19,8 @@ export const VALUE_SEPARATOR = "RUBY_LSP_VS"; export const FIELD_SEPARATOR = "RUBY_LSP_FS"; export abstract class VersionManager { + private static readonly shell = process.env.SHELL?.replace(/(\s+)/g, "\\$1"); + protected readonly outputChannel: WorkspaceChannel; protected readonly workspaceFolder: vscode.WorkspaceFolder; protected readonly bundleUri: vscode.Uri; @@ -53,6 +55,46 @@ export abstract class VersionManager { // language server abstract activate(): Promise; + // Finds the first existing path from a list of possible paths + protected static async findFirst(paths: vscode.Uri[]): Promise { + for (const possiblePath of paths) { + try { + await vscode.workspace.fs.stat(possiblePath); + return possiblePath; + } catch (_error: any) { + // Continue looking + } + } + + return undefined; + } + + // Checks if a tool exists by running `tool --version` + static async toolExists( + tool: string, + workspaceFolder: vscode.WorkspaceFolder, + outputChannel: WorkspaceChannel, + ): Promise { + try { + let command = this.shell ? `${this.shell} -i -c '` : ""; + command += `${tool} --version`; + + if (this.shell) { + command += "'"; + } + + outputChannel.info(`Checking if ${tool} is available on the path`); + + await asyncExec(command, { + cwd: workspaceFolder.uri.fsPath, + timeout: 1000, + }); + return true; + } catch { + return false; + } + } + protected async runEnvActivationScript(activatedRuby: string): Promise { const activationUri = vscode.Uri.joinPath(this.context.extensionUri, "activation.rb"); diff --git a/vscode/src/test/suite/debugger.test.ts b/vscode/src/test/suite/debugger.test.ts index 6ab3ba49e7..af62d29126 100644 --- a/vscode/src/test/suite/debugger.test.ts +++ b/vscode/src/test/suite/debugger.test.ts @@ -10,7 +10,7 @@ import { Debugger } from "../../debugger"; import { Ruby, ManagerIdentifier } from "../../ruby"; import { Workspace } from "../../workspace"; import { WorkspaceChannel } from "../../workspaceChannel"; -import { LOG_CHANNEL, asyncExec } from "../../common"; +import { LOG_CHANNEL, asyncExec, pathToUri } from "../../common"; import { RUBY_VERSION } from "../rubyVersion"; import { FAKE_TELEMETRY } from "./fakeTelemetry"; @@ -156,7 +156,7 @@ suite("Debugger", () => { { parallel: "1", ...ruby.env, - BUNDLE_GEMFILE: vscode.Uri.joinPath(vscode.Uri.file(tmpPath), ".ruby-lsp", "Gemfile").fsPath, + BUNDLE_GEMFILE: pathToUri(tmpPath, ".ruby-lsp", "Gemfile").fsPath, }, configs.env, ); diff --git a/vscode/src/test/suite/ruby/asdf.test.ts b/vscode/src/test/suite/ruby/asdf.test.ts index aba133cbcf..068218713e 100644 --- a/vscode/src/test/suite/ruby/asdf.test.ts +++ b/vscode/src/test/suite/ruby/asdf.test.ts @@ -50,7 +50,7 @@ suite("Asdf", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); - sandbox.stub(asdf, "findAsdfInstallation").resolves(`${os.homedir()}/.asdf/asdf.sh`); + sandbox.stub(Asdf, "detect").resolves(vscode.Uri.file(`${os.homedir()}/.asdf/asdf.sh`)); sandbox.stub(vscode.env, "shell").get(() => "/bin/bash"); const { env, version, yjit } = await asdf.activate(); @@ -82,7 +82,7 @@ suite("Asdf", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); - sandbox.stub(asdf, "findAsdfInstallation").resolves(`${os.homedir()}/.asdf/asdf.fish`); + sandbox.stub(Asdf, "detect").resolves(vscode.Uri.file(`${os.homedir()}/.asdf/asdf.fish`)); sandbox.stub(vscode.env, "shell").get(() => "/opt/homebrew/bin/fish"); const { env, version, yjit } = await asdf.activate(); @@ -114,7 +114,7 @@ suite("Asdf", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); - sandbox.stub(asdf, "findAsdfInstallation").resolves(undefined); + sandbox.stub(Asdf, "detect").resolves(vscode.Uri.file("/opt/homebrew/bin/asdf")); sandbox.stub(vscode.workspace, "fs").value({ stat: () => Promise.resolve(undefined), @@ -136,32 +136,4 @@ suite("Asdf", () => { 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/vscode/src/test/suite/ruby/mise.test.ts b/vscode/src/test/suite/ruby/mise.test.ts index a37d5ee0ce..ed796974ac 100644 --- a/vscode/src/test/suite/ruby/mise.test.ts +++ b/vscode/src/test/suite/ruby/mise.test.ts @@ -53,7 +53,7 @@ suite("Mise", () => { }); const findStub = sandbox .stub(mise, "findMiseUri") - .resolves(vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".local", "bin", "mise")); + .resolves(common.pathToUri(os.homedir(), ".local", "bin", "mise")); const { env, version, yjit } = await mise.activate(); @@ -127,4 +127,82 @@ suite("Mise", () => { configStub.restore(); fs.rmSync(workspacePath, { recursive: true, force: true }); }); + + test("detect returns the first found mise 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 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const misePath = path.join(tempDir, "mise"); + fs.writeFileSync(misePath, "fakeMiseBinary"); + + const getPossiblePathsStub = sandbox + .stub(Mise as any, "getPossiblePaths") + .returns([vscode.Uri.file(misePath), vscode.Uri.file(path.join(tempDir, "other", "mise"))]); + + const result = await Mise.detect(workspaceFolder, outputChannel); + + assert.strictEqual(result?.fsPath, vscode.Uri.file(misePath).fsPath); + + getPossiblePathsStub.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("detect returns undefined when mise is not found", 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 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + + const getPossiblePathsStub = sandbox + .stub(Mise as any, "getPossiblePaths") + .returns([ + vscode.Uri.file(path.join(tempDir, "nonexistent1", "mise")), + vscode.Uri.file(path.join(tempDir, "nonexistent2", "mise")), + ]); + + const result = await Mise.detect(workspaceFolder, outputChannel); + + assert.strictEqual(result, undefined); + + getPossiblePathsStub.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test("detect checks multiple paths in order", 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 tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + const secondPath = path.join(tempDir, "second", "mise"); + fs.mkdirSync(path.dirname(secondPath), { recursive: true }); + fs.writeFileSync(secondPath, "fakeMiseBinary"); + + const getPossiblePathsStub = sandbox + .stub(Mise as any, "getPossiblePaths") + .returns([ + vscode.Uri.file(path.join(tempDir, "nonexistent", "mise")), + vscode.Uri.file(secondPath), + vscode.Uri.file(path.join(tempDir, "third", "mise")), + ]); + + const result = await Mise.detect(workspaceFolder, outputChannel); + + assert.strictEqual(result?.fsPath, vscode.Uri.file(secondPath).fsPath); + + getPossiblePathsStub.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); }); diff --git a/vscode/src/test/suite/ruby/rvm.test.ts b/vscode/src/test/suite/ruby/rvm.test.ts index b1d6c8d120..1d2904f2d2 100644 --- a/vscode/src/test/suite/ruby/rvm.test.ts +++ b/vscode/src/test/suite/ruby/rvm.test.ts @@ -46,7 +46,7 @@ suite("RVM", () => { const installationPathStub = sandbox .stub(rvm, "findRvmInstallation") - .resolves(vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".rvm", "bin", "rvm-auto-ruby")); + .resolves(common.pathToUri(os.homedir(), ".rvm", "bin", "rvm-auto-ruby")); const envStub = ["3.0.0", "/path/to/gems", "true", `ANY${VALUE_SEPARATOR}true`].join(FIELD_SEPARATOR);