diff --git a/src/client.ts b/src/client.ts index 96d6930f..5de2226e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -22,6 +22,7 @@ import { Telemetry, RequestEvent } from "./telemetry"; import { Ruby } from "./ruby"; import { StatusItems, Command, ServerState, ClientInterface } from "./status"; import { TestController } from "./testController"; +import { LOG_CHANNEL } from "./common"; const LSP_NAME = "Ruby LSP"; const asyncExec = promisify(exec); @@ -37,7 +38,6 @@ export default class Client implements ClientInterface { private readonly workingFolder: string; private readonly telemetry: Telemetry; private readonly statusItems: StatusItems; - private readonly outputChannel: vscode.OutputChannel; private readonly testController: TestController; private readonly customBundleGemfile: string = vscode.workspace .getConfiguration("rubyLsp") @@ -56,7 +56,6 @@ export default class Client implements ClientInterface { telemetry: Telemetry, ruby: Ruby, testController: TestController, - outputChannel: vscode.OutputChannel, workingFolder = vscode.workspace.workspaceFolders![0].uri.fsPath, ) { this.workingFolder = workingFolder; @@ -65,7 +64,6 @@ export default class Client implements ClientInterface { this.testController = testController; this.#context = context; this.#ruby = ruby; - this.outputChannel = outputChannel; this.#formatter = ""; this.statusItems = new StatusItems(this); this.registerCommands(); @@ -111,7 +109,7 @@ export default class Client implements ClientInterface { const clientOptions: LanguageClientOptions = { documentSelector: [{ language: "ruby" }], diagnosticCollectionName: LSP_NAME, - outputChannel: this.outputChannel, + outputChannel: LOG_CHANNEL, revealOutputChannelOn: RevealOutputChannelOn.Never, diagnosticPullOptions: this.diagnosticPullOptions(), initializationOptions: { @@ -231,9 +229,7 @@ export default class Client implements ClientInterface { await this.client.start(); } catch (error: any) { this.state = ServerState.Error; - this.outputChannel.appendLine( - `Error restarting the server: ${error.message}`, - ); + LOG_CHANNEL.error(`Error restarting the server: ${error.message}`); return; } @@ -274,16 +270,12 @@ export default class Client implements ClientInterface { } } catch (error: any) { this.state = ServerState.Error; - - this.outputChannel.appendLine( - `Error restarting the server: ${error.message}`, - ); + LOG_CHANNEL.error(`Error restarting the server: ${error.message}`); } } dispose() { this.client?.dispose(); - this.outputChannel.dispose(); } get ruby(): Ruby { @@ -461,9 +453,7 @@ export default class Client implements ClientInterface { this.context.workspaceState.update("rubyLsp.lastGemUpdate", Date.now()); } catch (error) { // If we fail to update the global installation of `ruby-lsp`, we don't want to prevent the server from starting - this.outputChannel.appendLine( - `Failed to update global ruby-lsp gem: ${error}`, - ); + LOG_CHANNEL.error(`Failed to update global ruby-lsp gem: ${error}`); } } } diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 00000000..c0e4507d --- /dev/null +++ b/src/common.ts @@ -0,0 +1,22 @@ +import fs from "fs/promises"; +import { exec } from "child_process"; +import { promisify } from "util"; + +import * as vscode from "vscode"; + +export const asyncExec = promisify(exec); +export const LOG_CHANNEL = vscode.window.createOutputChannel("Ruby LSP", { + log: true, +}); + +export async function pathExists( + path: string, + mode = fs.constants.R_OK, +): Promise { + try { + await fs.access(path, mode); + return true; + } catch (error: any) { + return false; + } +} diff --git a/src/debugger.ts b/src/debugger.ts index 72f64fc6..89c045a4 100644 --- a/src/debugger.ts +++ b/src/debugger.ts @@ -5,6 +5,7 @@ import { ChildProcessWithoutNullStreams, spawn } from "child_process"; import * as vscode from "vscode"; import { Ruby } from "./ruby"; +import { LOG_CHANNEL } from "./common"; export class Debugger implements @@ -16,16 +17,13 @@ export class Debugger private debugProcess?: ChildProcessWithoutNullStreams; private readonly console = vscode.debug.activeDebugConsole; private readonly subscriptions: vscode.Disposable[]; - private readonly outputChannel: vscode.OutputChannel; constructor( context: vscode.ExtensionContext, ruby: Ruby, - outputChannel: vscode.OutputChannel, workingFolder = vscode.workspace.workspaceFolders![0].uri.fsPath, ) { this.ruby = ruby; - this.outputChannel = outputChannel; this.subscriptions = [ vscode.debug.registerDebugConfigurationProvider("ruby_lsp", this), vscode.debug.registerDebugAdapterDescriptorFactory("ruby_lsp", this), @@ -183,15 +181,9 @@ export class Debugger configuration.program, ]; - this.outputChannel.appendLine( - `Ruby LSP> Spawning debugger in directory ${this.workingFolder}`, - ); - this.outputChannel.appendLine( - `Ruby LSP> Command bundle ${args.join(" ")}`, - ); - this.outputChannel.appendLine( - `Ruby LSP> Environment ${JSON.stringify(configuration.env)}`, - ); + LOG_CHANNEL.info(`Spawning debugger in directory ${this.workingFolder}`); + LOG_CHANNEL.info(` Command bundle ${args.join(" ")}`); + LOG_CHANNEL.info(` Environment ${JSON.stringify(configuration.env)}`); this.debugProcess = spawn("bundle", args, { shell: true, @@ -236,7 +228,7 @@ export class Debugger if (code) { const message = `Debugger exited with status ${code}. Check the output channel for more information.`; this.console.append(message); - this.outputChannel.show(); + LOG_CHANNEL.show(); reject(new Error(message)); } }); diff --git a/src/extension.ts b/src/extension.ts index a3c97c3f..2ff8af0d 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -12,8 +12,7 @@ let debug: Debugger | undefined; let testController: TestController | undefined; export async function activate(context: vscode.ExtensionContext) { - const outputChannel = vscode.window.createOutputChannel("Ruby LSP"); - const ruby = new Ruby(context, outputChannel); + const ruby = new Ruby(context, vscode.workspace.workspaceFolders![0]); await ruby.activateRuby(); const telemetry = new Telemetry(context); @@ -26,10 +25,10 @@ export async function activate(context: vscode.ExtensionContext) { telemetry, ); - client = new Client(context, telemetry, ruby, testController, outputChannel); + client = new Client(context, telemetry, ruby, testController); await client.start(); - debug = new Debugger(context, ruby, outputChannel); + debug = new Debugger(context, ruby); vscode.workspace.registerTextDocumentContentProvider( "ruby-lsp", diff --git a/src/ruby.ts b/src/ruby.ts index be3ff2ee..94683791 100644 --- a/src/ruby.ts +++ b/src/ruby.ts @@ -1,11 +1,9 @@ -import { exec } from "child_process"; -import { promisify } from "util"; import path from "path"; -import fs from "fs"; +import fs from "fs/promises"; import * as vscode from "vscode"; -const asyncExec = promisify(exec); +import { asyncExec, pathExists, LOG_CHANNEL } from "./common"; export enum VersionManager { Asdf = "asdf", @@ -22,7 +20,7 @@ export class Ruby { public rubyVersion?: string; public yjitEnabled?: boolean; public supportsYjit?: boolean; - private readonly workingFolder: string; + private readonly workingFolderPath: string; #versionManager?: VersionManager; // eslint-disable-next-line no-process-env private readonly shell = process.env.SHELL; @@ -31,16 +29,13 @@ export class Ruby { private readonly context: vscode.ExtensionContext; private readonly customBundleGemfile?: string; private readonly cwd: string; - private readonly outputChannel: vscode.OutputChannel; constructor( context: vscode.ExtensionContext, - outputChannel: vscode.OutputChannel, - workingFolder = vscode.workspace.workspaceFolders![0].uri.fsPath, + workingFolder: vscode.WorkspaceFolder, ) { this.context = context; - this.workingFolder = workingFolder; - this.outputChannel = outputChannel; + this.workingFolderPath = workingFolder.uri.fsPath; const customBundleGemfile: string = vscode.workspace .getConfiguration("rubyLsp") @@ -49,12 +44,12 @@ export class Ruby { if (customBundleGemfile.length > 0) { this.customBundleGemfile = path.isAbsolute(customBundleGemfile) ? customBundleGemfile - : path.resolve(path.join(this.workingFolder, customBundleGemfile)); + : path.resolve(path.join(this.workingFolderPath, customBundleGemfile)); } this.cwd = this.customBundleGemfile ? path.dirname(this.customBundleGemfile) - : this.workingFolder; + : this.workingFolderPath; } get versionManager() { @@ -83,9 +78,7 @@ export class Ruby { // If the version manager is auto, discover the actual manager before trying to activate anything if (this.versionManager === VersionManager.Auto) { await this.discoverVersionManager(); - this.outputChannel.appendLine( - `Ruby LSP> Discovered version manager ${this.versionManager}`, - ); + LOG_CHANNEL.info(`Discovered version manager ${this.versionManager}`); } try { @@ -113,9 +106,9 @@ export class Ruby { break; } - await this.fetchRubyInfo(); + this.fetchRubyVersionInfo(); this.deleteGcEnvironmentVariables(); - this.setupBundlePath(); + await this.setupBundlePath(); this._error = false; } catch (error: any) { this._error = true; @@ -133,7 +126,9 @@ export class Ruby { } private async activateShadowenv() { - if (!fs.existsSync(path.join(this.workingFolder, ".shadowenv.d"))) { + if ( + !(await pathExists(path.join(this.workingFolderPath, ".shadowenv.d"))) + ) { throw new Error( "The Ruby LSP version manager is configured to be shadowenv, \ but no .shadowenv.d directory was found in the workspace", @@ -152,8 +147,8 @@ export class Ruby { // If the configurations under `.shadowenv.d/` point to a Ruby version that is not installed, shadowenv will still // return the complete environment without throwing any errors. Here, we check to see if the RUBY_ROOT returned by - // shadowenv exists. If it doens't, then it's likely that the Ruby version configured is not installed - if (!fs.existsSync(env.RUBY_ROOT)) { + // shadowenv exists. If it doesn't, then it's likely that the Ruby version configured is not installed + if (!(await pathExists(env.RUBY_ROOT))) { throw new Error( `The Ruby version configured in .shadowenv.d is ${env.RUBY_VERSION}, \ but the Ruby installation at ${env.RUBY_ROOT} does not exist`, @@ -165,46 +160,52 @@ export class Ruby { // eslint-disable-next-line no-process-env process.env = env; this._env = env; + + // Get the Ruby version and YJIT support. Shadowenv is the only manager where this is separate from activation + const rubyInfo = await asyncExec( + "ruby -e 'STDERR.print(\"#{RUBY_VERSION},#{defined?(RubyVM::YJIT)}\")'", + { env: this._env, cwd: this.cwd }, + ); + + const [rubyVersion, yjitIsDefined] = rubyInfo.stderr.trim().split(","); + this.rubyVersion = rubyVersion; + this.yjitEnabled = yjitIsDefined === "constant"; } private async activateChruby() { - const rubyVersion = this.readRubyVersion(); + const rubyVersion = await this.readRubyVersion(); await this.activate(`chruby "${rubyVersion}" && ruby`); } private async activate(ruby: string) { let command = this.shell ? `${this.shell} -ic '` : ""; - command += `${ruby} -rjson -e "STDERR.printf(%{RUBY_ENV_ACTIVATE%sRUBY_ENV_ACTIVATE}, JSON.dump(ENV.to_h))"`; + command += `${ruby} -rjson -e "STDERR.printf(%{RUBY_ENV_ACTIVATE%sRUBY_ENV_ACTIVATE}, + JSON.dump({ env: ENV.to_h, ruby_version: RUBY_VERSION, yjit: defined?(RubyVM::YJIT) }))"`; if (this.shell) { command += "'"; } - this.outputChannel.appendLine( - `Ruby LSP> Trying to activate Ruby environment with command: ${command} inside directory: ${this.cwd}`, + LOG_CHANNEL.info( + `Trying to activate Ruby environment with command: ${command} inside directory: ${this.cwd}`, ); const result = await asyncExec(command, { cwd: this.cwd }); - - const envJson = /RUBY_ENV_ACTIVATE(.*)RUBY_ENV_ACTIVATE/.exec( + const rubyInfoJson = /RUBY_ENV_ACTIVATE(.*)RUBY_ENV_ACTIVATE/.exec( result.stderr, )![1]; - this._env = JSON.parse(envJson); - } - - private async fetchRubyInfo() { - const rubyInfo = await asyncExec( - "ruby -e 'STDERR.print(\"#{RUBY_VERSION},#{defined?(RubyVM::YJIT)}\")'", - { env: this._env, cwd: this.cwd }, - ); + const rubyInfo = JSON.parse(rubyInfoJson); - const [rubyVersion, yjitIsDefined] = rubyInfo.stderr.trim().split(","); - - this.rubyVersion = rubyVersion; - this.yjitEnabled = yjitIsDefined === "constant"; + this._env = rubyInfo.env; + this.rubyVersion = rubyInfo.ruby_version; + this.yjitEnabled = rubyInfo.yjit === "constant"; + } - const [major, minor, _patch] = this.rubyVersion.split(".").map(Number); + // Fetch information related to the Ruby version. This can only be invoked after activation, so that `rubyVersion` is + // set + private fetchRubyVersionInfo() { + const [major, minor, _patch] = this.rubyVersion!.split(".").map(Number); if (major < 3) { throw new Error( @@ -237,14 +238,14 @@ export class Ruby { }); } - private setupBundlePath() { + private async setupBundlePath() { // Some users like to define a completely separate Gemfile for development tools. We allow them to use // `rubyLsp.bundleGemfile` to configure that and need to inject it into the environment if (!this.customBundleGemfile) { return; } - if (!fs.existsSync(this.customBundleGemfile)) { + if (!(await pathExists(this.customBundleGemfile))) { throw new Error( `The configured bundle gemfile ${this.customBundleGemfile} does not exist`, ); @@ -253,14 +254,14 @@ export class Ruby { this._env.BUNDLE_GEMFILE = this.customBundleGemfile; } - private readRubyVersion() { + private async readRubyVersion() { let dir = this.cwd; - while (fs.existsSync(dir)) { + while (await pathExists(dir)) { const versionFile = path.join(dir, ".ruby-version"); - if (fs.existsSync(versionFile)) { - const version = fs.readFileSync(versionFile, "utf8"); + if (await pathExists(versionFile)) { + const version = await fs.readFile(versionFile, "utf8"); const trimmedVersion = version.trim(); if (trimmedVersion !== "") { @@ -285,7 +286,7 @@ export class Ruby { 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 - if (fs.existsSync(path.join(this.workingFolder, ".shadowenv.d"))) { + if (await pathExists(path.join(this.workingFolderPath, ".shadowenv.d"))) { this.versionManager = VersionManager.Shadowenv; return; } @@ -319,11 +320,11 @@ export class Ruby { command += "'"; } - this.outputChannel.appendLine( - `Ruby LSP> Checking if ${tool} is available on the path with command: ${command}`, + LOG_CHANNEL.info( + `Checking if ${tool} is available on the path with command: ${command}`, ); - await asyncExec(command, { cwd: this.workingFolder }); + await asyncExec(command, { cwd: this.workingFolderPath, timeout: 1000 }); return true; } catch { return false; diff --git a/src/test/suite/client.test.ts b/src/test/suite/client.test.ts index 9a75a488..2ab38f85 100644 --- a/src/test/suite/client.test.ts +++ b/src/test/suite/client.test.ts @@ -28,7 +28,6 @@ class FakeApi implements TelemetryApi { suite("Client", () => { let client: Client | undefined; let testController: TestController | undefined; - const outputChannel = vscode.window.createOutputChannel("Ruby LSP"); const managerConfig = vscode.workspace.getConfiguration("rubyLsp"); const currentManager = managerConfig.get("rubyVersionManager"); const tmpPath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); @@ -67,7 +66,9 @@ suite("Client", () => { }, } as unknown as vscode.ExtensionContext; - const ruby = new Ruby(context, outputChannel, tmpPath); + const ruby = new Ruby(context, { + uri: { fsPath: tmpPath }, + } as vscode.WorkspaceFolder); await ruby.activateRuby(); const telemetry = new Telemetry(context, new FakeApi()); @@ -84,7 +85,6 @@ suite("Client", () => { telemetry, ruby, testController, - outputChannel, tmpPath, ); await client.start(); diff --git a/src/test/suite/debugger.test.ts b/src/test/suite/debugger.test.ts index e7e391bf..626b06a7 100644 --- a/src/test/suite/debugger.test.ts +++ b/src/test/suite/debugger.test.ts @@ -9,12 +9,10 @@ import { Debugger } from "../../debugger"; import { Ruby } from "../../ruby"; suite("Debugger", () => { - const outputChannel = vscode.window.createOutputChannel("Ruby LSP"); - test("Provide debug configurations returns the default configs", () => { const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; const ruby = { env: {} } as Ruby; - const debug = new Debugger(context, ruby, outputChannel, "fake"); + const debug = new Debugger(context, ruby, "fake"); const configs = debug.provideDebugConfigurations!(undefined); assert.deepEqual( [ @@ -47,7 +45,7 @@ suite("Debugger", () => { test("Resolve configuration injects Ruby environment", () => { const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; const ruby = { env: { bogus: "hello!" } } as unknown as Ruby; - const debug = new Debugger(context, ruby, outputChannel, "fake"); + const debug = new Debugger(context, ruby, "fake"); const configs: any = debug.resolveDebugConfiguration!(undefined, { type: "ruby_lsp", name: "Debug", @@ -63,7 +61,7 @@ suite("Debugger", () => { test("Resolve configuration injects Ruby environment and allows users custom environment", () => { const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; const ruby = { env: { bogus: "hello!" } } as unknown as Ruby; - const debug = new Debugger(context, ruby, outputChannel, "fake"); + const debug = new Debugger(context, ruby, "fake"); const configs: any = debug.resolveDebugConfiguration!(undefined, { type: "ruby_lsp", name: "Debug", @@ -84,7 +82,7 @@ suite("Debugger", () => { const context = { subscriptions: [] } as unknown as vscode.ExtensionContext; const ruby = { env: { bogus: "hello!" } } as unknown as Ruby; - const debug = new Debugger(context, ruby, outputChannel, tmpPath); + const debug = new Debugger(context, ruby, tmpPath); const configs: any = debug.resolveDebugConfiguration!(undefined, { type: "ruby_lsp", name: "Debug", diff --git a/src/test/suite/ruby.test.ts b/src/test/suite/ruby.test.ts index a84982a9..22778a9f 100644 --- a/src/test/suite/ruby.test.ts +++ b/src/test/suite/ruby.test.ts @@ -21,11 +21,9 @@ suite("Ruby environment activation", () => { extensionMode: vscode.ExtensionMode.Test, } as vscode.ExtensionContext; - ruby = new Ruby( - context, - vscode.window.createOutputChannel("Ruby LSP"), - tmpPath, - ); + ruby = new Ruby(context, { + uri: { fsPath: tmpPath }, + } as vscode.WorkspaceFolder); await ruby.activateRuby( // eslint-disable-next-line no-process-env process.env.CI ? VersionManager.None : VersionManager.Chruby,