Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions vscode/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
159 changes: 41 additions & 118 deletions vscode/src/ruby.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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",
Expand All @@ -52,6 +32,30 @@ export interface ManagerConfiguration {
identifier: ManagerIdentifier;
}

interface ManagerClass {
new (
workspaceFolder: vscode.WorkspaceFolder,
outputChannel: WorkspaceChannel,
context: vscode.ExtensionContext,
manuallySelectRuby: () => Promise<void>,
...args: any[]
): VersionManager;
detect: (workspaceFolder: vscode.WorkspaceFolder, outputChannel: WorkspaceChannel) => Promise<vscode.Uri | undefined>;
}

const VERSION_MANAGERS: Record<ManagerIdentifier, ManagerClass> = {
[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
Expand All @@ -63,7 +67,6 @@ export class Ruby implements RubyInterface {
.getConfiguration("rubyLsp")
.get<ManagerConfiguration>("rubyVersionManager")!;

private readonly shell = process.env.SHELL?.replace(/(\s+)/g, "\\$1");
private _env: NodeJS.ProcessEnv = {};
private _error = false;
private readonly context: vscode.ExtensionContext;
Expand Down Expand Up @@ -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() {
Expand All @@ -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;
}
}

Expand Down
93 changes: 53 additions & 40 deletions vscode/src/ruby/asdf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ActivationResult> {
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<vscode.Uri | undefined> {
// 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<ActivationResult> {
// 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`;
Expand All @@ -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<string | undefined> {
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<string | undefined> {
const config = vscode.workspace.getConfiguration("rubyLsp");
const asdfPath = config.get<string | undefined>("rubyVersionManager.asdfExecutablePath");
Expand Down
14 changes: 10 additions & 4 deletions vscode/src/ruby/chruby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<vscode.Uri | undefined> {
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,
Expand Down
8 changes: 8 additions & 0 deletions vscode/src/ruby/custom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<vscode.Uri | undefined> {
return undefined;
}

async activate(): Promise<ActivationResult> {
const parsedResult = await this.runEnvActivationScript(`${this.customCommand()} && ruby`);

Expand Down
Loading