Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow omitting patch in .ruby-version #2004

Merged
merged 1 commit into from May 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
100 changes: 72 additions & 28 deletions vscode/src/ruby/chruby.ts
Expand Up @@ -43,7 +43,7 @@ export class Chruby extends VersionManager {
async activate(): Promise<ActivationResult> {
const versionInfo = await this.discoverRubyVersion();
const rubyUri = await this.findRubyUri(versionInfo);
const { defaultGems, gemHome, yjit } =
const { defaultGems, gemHome, yjit, version } =
await this.runActivationScript(rubyUri);

this.outputChannel.info(
Expand All @@ -62,12 +62,51 @@ export class Chruby extends VersionManager {
return {
env: { ...process.env, ...rubyEnv },
yjit,
version: versionInfo.version,
version,
};
}

// Returns the full URI to the Ruby executable
protected async findRubyUri(rubyVersion: RubyVersion): Promise<vscode.Uri> {
if (/\d+\.\d+\.\d+/.exec(rubyVersion.version)) {
return this.findRubyUriForCompleteVersion(rubyVersion);
}

return this.findRubyUriWithOmittedPatch(rubyVersion);
}

private async findRubyUriWithOmittedPatch(
rubyVersion: RubyVersion,
): Promise<vscode.Uri> {
const possibleVersionNames = rubyVersion.engine
? [`${rubyVersion.engine}-${rubyVersion.version}`, rubyVersion.version]
: [rubyVersion.version, `ruby-${rubyVersion.version}`];

for (const uri of this.rubyInstallationUris) {
const directories = (await vscode.workspace.fs.readDirectory(uri)).sort(
(left, right) => right[0].localeCompare(left[0]),
);

for (const versionName of possibleVersionNames) {
const targetDirectory = directories.find(([name]) =>
name.startsWith(versionName),
);

if (targetDirectory) {
return vscode.Uri.joinPath(uri, targetDirectory[0], "bin", "ruby");
}
}
}

throw new Error(
`Cannot find installation directory for Ruby version ${possibleVersionNames.join(" or ")}.
Searched in ${this.rubyInstallationUris.map((uri) => uri.fsPath).join(", ")}`,
);
}

private async findRubyUriForCompleteVersion(
rubyVersion: RubyVersion,
): Promise<vscode.Uri> {
// If an engine was specified in the .ruby-version file, we favor looking for that first and also try just the
// version number. If no engine was specified, we first try just the version number and then we try using `ruby` as
// the default engine
Expand Down Expand Up @@ -99,46 +138,51 @@ export class Chruby extends VersionManager {
private async discoverRubyVersion(): Promise<RubyVersion> {
let uri = this.bundleUri;
const root = path.parse(uri.fsPath).root;
let version: string;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed that we were unintentionally catching our custom error for invalid .ruby-version files on the same catch as the one intended for non-existing files.

That's incorrect. When we find a .ruby-version file, but the contents are invalid, we want to raise and not continue going up the directories.

let rubyVersionUri: vscode.Uri;

while (uri.fsPath !== root) {
try {
const rubyVersionUri = vscode.Uri.joinPath(uri, ".ruby-version");
rubyVersionUri = vscode.Uri.joinPath(uri, ".ruby-version");
const content = await vscode.workspace.fs.readFile(rubyVersionUri);
const version = content.toString().trim();

if (version === "") {
throw new Error(`Ruby version file ${rubyVersionUri} is empty`);
}

const match =
/((?<engine>[A-Za-z]+)-)?(?<version>\d\.\d\.\d(-[A-Za-z0-9]+)?)/.exec(
version,
);

if (!match?.groups) {
throw new Error(
`Ruby version file ${rubyVersionUri} contains invalid format. Expected (engine-)?version, got ${version}`,
);
}

this.outputChannel.info(
`Discovered Ruby version ${version} from ${rubyVersionUri.toString()}`,
);
return { engine: match.groups.engine, version: match.groups.version };
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} is empty`);
}

const match =
/((?<engine>[A-Za-z]+)-)?(?<version>\d+\.\d+(\.\d+)?(-[A-Za-z0-9]+)?)/.exec(
version,
);

if (!match?.groups) {
throw new Error(
`Ruby version file ${rubyVersionUri} contains invalid format. Expected (engine-)?version, got ${version}`,
);
}

this.outputChannel.info(
`Discovered Ruby version ${version} from ${rubyVersionUri.toString()}`,
);
return { engine: match.groups.engine, version: match.groups.version };
}

throw new Error("No .ruby-version file was found");
}

// Run the activation script using the Ruby installation we found so that we can discover gem paths
private async runActivationScript(
rubyExecutableUri: vscode.Uri,
): Promise<{ defaultGems: string; gemHome: string; yjit: boolean }> {
private async runActivationScript(rubyExecutableUri: vscode.Uri): Promise<{
defaultGems: string;
gemHome: string;
yjit: boolean;
version: string;
}> {
// 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
Expand All @@ -155,7 +199,7 @@ export class Chruby extends VersionManager {
"end",
"newer_gem_home = File.join(File.dirname(user_dir), RUBY_VERSION)",
"gems = (Dir.exist?(newer_gem_home) ? newer_gem_home : user_dir)",
"data = { defaultGems: Gem.default_dir, gemHome: gems, yjit: !!defined?(RubyVM::YJIT) }",
"data = { defaultGems: Gem.default_dir, gemHome: gems, yjit: !!defined?(RubyVM::YJIT), version: RUBY_VERSION }",
"STDERR.print(JSON.dump(data))",
].join(";");

Expand Down
35 changes: 35 additions & 0 deletions vscode/src/test/suite/ruby/chruby.test.ts
Expand Up @@ -135,6 +135,10 @@ suite("Chruby", () => {
assert.match(env.GEM_PATH!, new RegExp(`lib/ruby/gems/${VERSION_REGEX}`));
assert.strictEqual(version, RUBY_VERSION);
assert.notStrictEqual(yjit, undefined);
fs.rmSync(path.join(rootPath, "opt", "rubies", `${RUBY_VERSION}-custom`), {
recursive: true,
force: true,
});
});

test("Considers Ruby as the default engine if missing", async () => {
Expand Down Expand Up @@ -181,4 +185,35 @@ suite("Chruby", () => {
assert.strictEqual(version, RUBY_VERSION);
assert.notStrictEqual(yjit, undefined);
});

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}`,
vinistock marked this conversation as resolved.
Show resolved Hide resolved
);

const chruby = new Chruby(workspaceFolder, outputChannel);
chruby.rubyInstallationUris = [
vscode.Uri.file(path.join(rootPath, "opt", "rubies")),
];

const { env, version, yjit } = await chruby.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);

fs.rmSync(path.join(rootPath, "opt", "rubies", `${major}.${minor}.0`), {
recursive: true,
force: true,
});
});
});