diff --git a/vscode/src/common.ts b/vscode/src/common.ts index d7f38cbd5..e85f376db 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 d0a6d8371..c7658c7e2 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -1,13 +1,12 @@ 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"; -import { VersionManager } from "./ruby/versionManager"; +import { VersionManager, DetectionResult } from "./ruby/versionManager"; import { Mise } from "./ruby/mise"; import { RubyInstaller } from "./ruby/rubyInstaller"; import { Rbenv } from "./ruby/rbenv"; @@ -17,25 +16,6 @@ import { Custom } from "./ruby/custom"; import { Asdf } from "./ruby/asdf"; import { Rv } from "./ruby/rv"; -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", @@ -54,6 +34,31 @@ 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.Rv]: Rv, + [ManagerIdentifier.Mise]: Mise, + [ManagerIdentifier.RubyInstaller]: RubyInstaller, + [ManagerIdentifier.Custom]: Custom, + [ManagerIdentifier.None]: None, // None is last as the fallback + [ManagerIdentifier.Auto]: undefined, // Auto is handled specially +}; + 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 @@ -288,58 +293,23 @@ 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.Rv: - await this.runActivation( - new Rv(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]; + + if (!ManagerClass) { + throw new Error( + `BUG: No ManagerClass found for identifier '${this.versionManager.identifier}'. ` + + `This indicates either: (1) discoverVersionManager() failed to detect any version manager and left identifier as 'auto', ` + + `or (2) a new ManagerIdentifier enum value was added without updating VERSION_MANAGERS.`, + ); } + + const manager = new ManagerClass( + this.workspaceFolder, + this.outputChannel, + this.context, + this.manuallySelectRuby.bind(this), + ); + await this.runActivation(manager); } private async setupBundlePath() { @@ -358,62 +328,19 @@ 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 entries = Object.entries(VERSION_MANAGERS) as [ManagerIdentifier, ManagerClass][]; - const managers = [ - ManagerIdentifier.Chruby, - ManagerIdentifier.Rbenv, - ManagerIdentifier.Rvm, - ManagerIdentifier.Asdf, - ManagerIdentifier.Rv, - ]; - - for (const tool of managers) { - const exists = await this.toolExists(tool); + for (const [identifier, ManagerClass] of entries) { + if (identifier === ManagerIdentifier.Auto) { + continue; + } - if (exists) { - this.versionManager = tool; + const result = await ManagerClass.detect(this.workspaceFolder, this.outputChannel); + if (result.type !== "none") { + this.versionManager = identifier; return; } } - - 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 { - const shell = vscode.env.shell.replace(/(\s+)/g, "\\$1"); - const command = `${shell} -i -c '${tool} --version'`; - - 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; - } } private async handleRubyError(message: string) { diff --git a/vscode/src/ruby/asdf.ts b/vscode/src/ruby/asdf.ts index e06ff5a34..f8b970bb9 100644 --- a/vscode/src/ruby/asdf.ts +++ b/vscode/src/ruby/asdf.ts @@ -3,42 +3,20 @@ import path from "path"; import * as vscode from "vscode"; -import { VersionManager, ActivationResult } from "./versionManager"; +import { VersionManager, ActivationResult, DetectionResult } 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"), - ]; - - // 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, - }; + return [pathToUri("/", "opt", "homebrew", "bin"), pathToUri("/", "usr", "local", "bin")]; } - // Only public for testing. Finds the ASDF installation URI based on what's advertised in the ASDF documentation - async findAsdfInstallation(): Promise { + 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. @@ -47,24 +25,84 @@ export class Asdf extends VersionManager { // 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), + 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 { type: "path", uri: execResult }; + } + + // Check for < v0.16 scripts + const scriptResult = await VersionManager.findFirst(Asdf.getPossibleScriptPaths()); + if (scriptResult) { + return { type: "path", uri: scriptResult }; + } - for (const possiblePath of possiblePaths) { - try { - await vscode.workspace.fs.stat(possiblePath); - return possiblePath.fsPath; - } catch (_error: any) { - // Continue looking + // check on PATH + const toolExists = await VersionManager.toolExists("asdf", workspaceFolder, outputChannel); + if (toolExists) { + return { type: "semantic", marker: "asdf" }; + } + + return { type: "none" }; + } + + async activate(): Promise { + // Prefer the path configured by the user, then use detect() to find ASDF + const configuredPath = await this.getConfiguredAsdfPath(); + let asdfUri: vscode.Uri | undefined; + + if (configuredPath) { + asdfUri = vscode.Uri.file(configuredPath); + } else { + const result = await Asdf.detect(this.workspaceFolder, this.outputChannel); + + if (result.type === "path") { + asdfUri = result.uri; + } else if (result.type === "semantic") { + // Use ASDF from PATH + } else { + throw new Error( + `Could not find ASDF installation. Searched in ${[ + ...Asdf.getPossibleExecutablePaths(), + ...Asdf.getPossibleScriptPaths(), + ].join(", ")}`, + ); } } - this.outputChannel.info(`Could not find installation for ASDF < v0.16. Searched in ${possiblePaths.join(", ")}`); - return undefined; + let baseCommand: string; + + if (asdfUri) { + 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 + baseCommand = path.extname(asdfPath) === "" ? asdfPath : `. ${asdfPath} && asdf`; + } else { + baseCommand = "asdf"; + } + + const parsedResult = await this.runEnvActivationScript(`${baseCommand} exec ruby`); + + return { + env: { ...process.env, ...parsedResult.env }, + yjit: parsedResult.yjit, + version: parsedResult.version, + gemPath: parsedResult.gemPath, + }; } private async getConfiguredAsdfPath(): Promise { diff --git a/vscode/src/ruby/chruby.ts b/vscode/src/ruby/chruby.ts index 0036efb67..c0f2daa21 100644 --- a/vscode/src/ruby/chruby.ts +++ b/vscode/src/ruby/chruby.ts @@ -4,8 +4,9 @@ import path from "path"; import * as vscode from "vscode"; import { WorkspaceChannel } from "../workspaceChannel"; +import { pathToUri } from "../common"; -import { ActivationResult, VersionManager, ACTIVATION_SEPARATOR } from "./versionManager"; +import { ActivationResult, VersionManager, ACTIVATION_SEPARATOR, DetectionResult } from "./versionManager"; interface RubyVersion { engine?: string; @@ -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 ? { type: "semantic", marker: "chruby" } : { type: "none" }; + } + // 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 c4564c794..df86d9426 100644 --- a/vscode/src/ruby/custom.ts +++ b/vscode/src/ruby/custom.ts @@ -1,6 +1,6 @@ import * as vscode from "vscode"; -import { VersionManager, ActivationResult } from "./versionManager"; +import { VersionManager, ActivationResult, DetectionResult } from "./versionManager"; // Custom // @@ -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 { type: "none" }; + } + 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 2c647ed4d..90a7d902f 100644 --- a/vscode/src/ruby/mise.ts +++ b/vscode/src/ruby/mise.ts @@ -2,17 +2,26 @@ import os from "os"; import * as vscode from "vscode"; -import { VersionManager, ActivationResult } from "./versionManager"; +import { VersionManager, ActivationResult, DetectionResult } 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 { + static async detect( + _workspaceFolder: vscode.WorkspaceFolder, + _outputChannel: WorkspaceChannel, + ): Promise { + const result = await this.findFirst(this.getPossiblePaths()); + return result ? { type: "path", uri: result } : { type: "none" }; + } + async activate(): Promise { - const miseUri = await this.findMiseUri(); + const execUri = await this.findVersionManagerUri(); - // The exec command in Mise is called `x` - const parsedResult = await this.runEnvActivationScript(`${miseUri.fsPath} x -- ruby`); + const parsedResult = await this.runEnvActivationScript(this.getExecutionCommand(execUri.fsPath)); return { env: { ...process.env, ...parsedResult.env }, @@ -22,44 +31,61 @@ export class Mise extends VersionManager { }; } - async findMiseUri(): Promise { + // 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` + protected static getPossiblePaths(): vscode.Uri[] { + return [ + pathToUri(os.homedir(), ".local", "bin", "mise"), + pathToUri("/", "opt", "homebrew", "bin", "mise"), + pathToUri("/", "usr", "bin", "mise"), + ]; + } + + protected getVersionManagerName(): string { + return "Mise"; + } + + protected getConfigKey(): string { + return "rubyVersionManager.miseExecutablePath"; + } + + protected getExecutionCommand(executablePath: string): string { + // The exec command in Mise is called `x` + return `${executablePath} x -- ruby`; + } + + private async findVersionManagerUri(): Promise { + const constructor = this.constructor as typeof Mise; + const managerName = this.getVersionManagerName(); + const configKey = this.getConfigKey(); + const config = vscode.workspace.getConfiguration("rubyLsp"); - const misePath = config.get("rubyVersionManager.miseExecutablePath"); + const configuredPath = config.get(configKey); - if (misePath) { - const configuredPath = vscode.Uri.file(misePath); + if (configuredPath) { + const uri = vscode.Uri.file(configuredPath); try { - await vscode.workspace.fs.stat(configuredPath); - return configuredPath; + await vscode.workspace.fs.stat(uri); + return uri; } catch (_error: any) { - throw new Error(`Mise executable configured as ${configuredPath.fsPath}, but that file doesn't exist`); + throw new Error(`${managerName} executable configured as ${uri.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"), - ]; + const result = await constructor.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 (result.type === "path") { + return result.uri; } + const possiblePaths = constructor.getPossiblePaths(); throw new Error( - `The Ruby LSP version manager is configured to be Mise, but could not find Mise installation. Searched in - ${possiblePaths.join(", ")}`, + `The Ruby LSP version manager is configured to be ${managerName}, but could not find ${managerName} installation. Searched in + ${possiblePaths.map((p) => p.fsPath).join(", ")}`, ); } } diff --git a/vscode/src/ruby/none.ts b/vscode/src/ruby/none.ts index 0c6a8c7e5..44f6821e6 100644 --- a/vscode/src/ruby/none.ts +++ b/vscode/src/ruby/none.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import { WorkspaceChannel } from "../workspaceChannel"; -import { VersionManager, ActivationResult } from "./versionManager"; +import { VersionManager, ActivationResult, DetectionResult } from "./versionManager"; // None // @@ -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 { type: "semantic", marker: "none" }; + } + private readonly rubyPath: string; constructor( diff --git a/vscode/src/ruby/rbenv.ts b/vscode/src/ruby/rbenv.ts index 4c7308ca9..d145d4800 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 { VersionManager, ActivationResult, DetectionResult } 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 ? { type: "semantic", marker: "rbenv" } : { type: "none" }; + } + async activate(): Promise { const rbenvExec = await this.findRbenv(); diff --git a/vscode/src/ruby/rubyInstaller.ts b/vscode/src/ruby/rubyInstaller.ts index 3adc0503b..ec538f982 100644 --- a/vscode/src/ruby/rubyInstaller.ts +++ b/vscode/src/ruby/rubyInstaller.ts @@ -3,6 +3,8 @@ import os from "os"; import * as vscode from "vscode"; import { Chruby } from "./chruby"; +import { pathToUri } from "../common"; +import { DetectionResult } from "./versionManager"; interface RubyVersion { engine?: string; @@ -16,6 +18,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" ? { type: "semantic", marker: "RubyInstaller" } : { type: "none" }; + } + // 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 +37,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/rv.ts b/vscode/src/ruby/rv.ts index 41f5828ab..abdae10cd 100644 --- a/vscode/src/ruby/rv.ts +++ b/vscode/src/ruby/rv.ts @@ -1,46 +1,30 @@ import * as vscode from "vscode"; -import { VersionManager, ActivationResult } from "./versionManager"; +import { pathToUri } from "../common"; +import { Mise } from "./mise"; // Manage your Ruby environment with rv // // Learn more: https://github.com/spinel-coop/rv -export class Rv extends VersionManager { - async activate(): Promise { - const rvExec = await this.findRv(); - const parsedResult = await this.runEnvActivationScript(`${rvExec} ruby run --`); - - return { - env: { ...process.env, ...parsedResult.env }, - yjit: parsedResult.yjit, - version: parsedResult.version, - gemPath: parsedResult.gemPath, - }; +export class Rv extends Mise { + protected static getPossiblePaths(): vscode.Uri[] { + return [ + pathToUri("/", "home", "linuxbrew", ".linuxbrew", "bin", "rv"), + pathToUri("/", "usr", "local", "bin", "rv"), + pathToUri("/", "opt", "homebrew", "bin", "rv"), + pathToUri("/", "usr", "bin", "rv"), + ]; } - private async findRv(): Promise { - const config = vscode.workspace.getConfiguration("rubyLsp"); - const configuredRvPath = config.get("rubyVersionManager.rvExecutablePath"); + protected getVersionManagerName(): string { + return "Rv"; + } - if (configuredRvPath) { - return this.ensureRvExistsAt(configuredRvPath); - } else { - const possiblePaths = [ - vscode.Uri.joinPath(vscode.Uri.file("/"), "home", "linuxbrew", ".linuxbrew", "bin"), - vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "local", "bin"), - vscode.Uri.joinPath(vscode.Uri.file("/"), "opt", "homebrew", "bin"), - vscode.Uri.joinPath(vscode.Uri.file("/"), "usr", "bin"), - ]; - return this.findExec(possiblePaths, "rv"); - } + protected getConfigKey(): string { + return "rubyVersionManager.rvExecutablePath"; } - private async ensureRvExistsAt(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 rv, but ${path} does not exist`); - } + protected getExecutionCommand(executablePath: string): string { + return `${executablePath} ruby run --`; } } diff --git a/vscode/src/ruby/rvm.ts b/vscode/src/ruby/rvm.ts index 66f508db0..b8bd24709 100644 --- a/vscode/src/ruby/rvm.ts +++ b/vscode/src/ruby/rvm.ts @@ -2,13 +2,23 @@ import os from "os"; import * as vscode from "vscode"; -import { ActivationResult, VersionManager } from "./versionManager"; +import { ActivationResult, VersionManager, DetectionResult } 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 ? { type: "semantic", marker: "rvm" } : { type: "none" }; + } + 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 a683ad8e2..6a33ac3ec 100644 --- a/vscode/src/ruby/shadowenv.ts +++ b/vscode/src/ruby/shadowenv.ts @@ -2,7 +2,7 @@ import * as vscode from "vscode"; import { asyncExec } from "../common"; -import { VersionManager, ActivationResult } from "./versionManager"; +import { VersionManager, ActivationResult, DetectionResult } 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. @@ -11,10 +11,29 @@ 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); + if (exists) { + return { type: "path", uri: vscode.Uri.joinPath(workspaceFolder.uri, ".shadowenv.d") }; + } + return { type: "none" }; + } + + 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 66c591ce7..353db6dac 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -13,6 +13,12 @@ export interface ActivationResult { gemPath: string[]; } +// Detection result types for version managers +export type DetectionResult = + | { type: "semantic"; marker: string } // Detected by semantic markers (e.g., "chruby", "rbenv") + | { type: "path"; uri: vscode.Uri } // Detected with actual file/directory path + | { type: "none" }; // No detection (not found or not applicable) + // 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"; @@ -53,6 +59,42 @@ 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 { + const shell = vscode.env.shell.replace(/(\s+)/g, "\\$1"); + const command = `${shell} -i -c '${tool} --version'`; + + 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 6ab3ba49e..af62d2912 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 aba133cbc..99936bf13 100644 --- a/vscode/src/test/suite/ruby/asdf.test.ts +++ b/vscode/src/test/suite/ruby/asdf.test.ts @@ -9,7 +9,7 @@ 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 { ACTIVATION_SEPARATOR, FIELD_SEPARATOR, VALUE_SEPARATOR, VersionManager } from "../../../ruby/versionManager"; import { createContext, FakeContext } from "../helpers"; suite("Asdf", () => { @@ -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({ type: "path", uri: 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({ type: "path", uri: 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({ type: "path", uri: vscode.Uri.file("/opt/homebrew/bin/asdf") }); sandbox.stub(vscode.workspace, "fs").value({ stat: () => Promise.resolve(undefined), @@ -146,7 +146,7 @@ suite("Asdf", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); - sandbox.stub(asdf, "findAsdfInstallation").resolves(undefined); + sandbox.stub(VersionManager, "toolExists").resolves(true); const { env, version, yjit } = await asdf.activate(); diff --git a/vscode/src/test/suite/ruby/mise.test.ts b/vscode/src/test/suite/ruby/mise.test.ts index a37d5ee0c..beaa1a26d 100644 --- a/vscode/src/test/suite/ruby/mise.test.ts +++ b/vscode/src/test/suite/ruby/mise.test.ts @@ -52,8 +52,8 @@ suite("Mise", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); const findStub = sandbox - .stub(mise, "findMiseUri") - .resolves(vscode.Uri.joinPath(vscode.Uri.file(os.homedir()), ".local", "bin", "mise")); + .stub(mise, "findVersionManagerUri" as any) + .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.type === "path" ? result.uri.fsPath : undefined, 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.type, "none"); + + 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.type === "path" ? result.uri.fsPath : undefined, vscode.Uri.file(secondPath).fsPath); + + getPossiblePathsStub.restore(); + fs.rmSync(tempDir, { recursive: true, force: true }); + }); }); diff --git a/vscode/src/test/suite/ruby/rv.test.ts b/vscode/src/test/suite/ruby/rv.test.ts index 9eb661b17..15469aa1b 100644 --- a/vscode/src/test/suite/ruby/rv.test.ts +++ b/vscode/src/test/suite/ruby/rv.test.ts @@ -53,13 +53,14 @@ suite("Rv", () => { stderr: `${ACTIVATION_SEPARATOR}${envStub}${ACTIVATION_SEPARATOR}`, }); - // Stub findRv to return the executable path - sandbox.stub(rv, "findRv" as any).resolves("rv"); + // Stub findVersionManagerUri to return the executable path as a Uri + const rvPath = vscode.Uri.file("rv"); + sandbox.stub(rv, "findVersionManagerUri" as any).resolves(rvPath); const { env, version, yjit } = await rv.activate(); assert.ok( - execStub.calledOnceWithExactly(`rv ruby run -- -EUTF-8:UTF-8 '${activationPath.fsPath}'`, { + execStub.calledOnceWithExactly(`${rvPath.fsPath} ruby run -- -EUTF-8:UTF-8 '${activationPath.fsPath}'`, { cwd: workspacePath, shell: vscode.env.shell, @@ -94,8 +95,8 @@ suite("Rv", () => { const rvPath = path.join(workspacePath, "rv"); fs.writeFileSync(rvPath, "fakeRvBinary"); - // Stub findRv to return the configured executable path - sandbox.stub(rv, "findRv" as any).resolves(rvPath); + // Stub findVersionManagerUri to return the configured executable path as a Uri + sandbox.stub(rv, "findVersionManagerUri" as any).resolves(vscode.Uri.file(rvPath)); const configStub = sinon.stub(vscode.workspace, "getConfiguration").returns({ get: (name: string) => { diff --git a/vscode/src/test/suite/ruby/rvm.test.ts b/vscode/src/test/suite/ruby/rvm.test.ts index b1d6c8d12..1d2904f2d 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);