diff --git a/exe/ruby-lsp b/exe/ruby-lsp index 7cd7d64bc..67222c729 100755 --- a/exe/ruby-lsp +++ b/exe/ruby-lsp @@ -37,6 +37,10 @@ parser = OptionParser.new do |opts| options[:launcher] = true end + opts.on("--local-fs-map [FS_MAP]", "Local fs map in format 'local:remote,local2:remote2,...'") do |map| + options[:local_fs_map] = map + end + opts.on("-h", "--help", "Print this help") do puts opts.help puts @@ -146,6 +150,13 @@ if options[:doctor] return end +if options[:local_fs_map] + ENV["RUBY_LSP_LOCAL_FS_MAP"] = [ + ENV["RUBY_LSP_LOCAL_FS_MAP"], + options[:local_fs_map], + ].reject(&:nil?).join(",") +end + # Ensure all output goes out stderr by default to allow puts/p/pp to work # without specifying output device. $> = $stderr diff --git a/lib/ruby_lsp/base_server.rb b/lib/ruby_lsp/base_server.rb index 8fac3592c..443fca78f 100644 --- a/lib/ruby_lsp/base_server.rb +++ b/lib/ruby_lsp/base_server.rb @@ -57,6 +57,7 @@ def start if uri begin parsed_uri = URI(uri) + parsed_uri = @global_state.to_internal_uri(parsed_uri) message[:params][:textDocument][:uri] = parsed_uri # We don't want to try to parse documents on text synchronization notifications diff --git a/lib/ruby_lsp/global_state.rb b/lib/ruby_lsp/global_state.rb index 004df40d9..9d480cacf 100644 --- a/lib/ruby_lsp/global_state.rb +++ b/lib/ruby_lsp/global_state.rb @@ -29,6 +29,9 @@ class GlobalState sig { returns(ClientCapabilities) } attr_reader :client_capabilities + sig { returns(T::Hash[String, String]) } + attr_accessor :local_fs_map + sig { void } def initialize @workspace_uri = T.let(URI::Generic.from_path(path: Dir.pwd), URI::Generic) @@ -53,6 +56,7 @@ def initialize ) @client_capabilities = T.let(ClientCapabilities.new, ClientCapabilities) @enabled_feature_flags = T.let({}, T::Hash[Symbol, T::Boolean]) + @local_fs_map = T.let(build_local_fs_map_from_env, T::Hash[String, String]) end sig { params(addon_name: String).returns(T.nilable(T::Hash[Symbol, T.untyped])) } @@ -81,8 +85,15 @@ def apply_options(options) notifications = [] direct_dependencies = gather_direct_dependencies all_dependencies = gather_direct_and_indirect_dependencies + + options.dig(:initializationOptions, :localFsMap)&.each do |local, remote| + local_fs_map[local.to_s] = remote + end + workspace_uri = options.dig(:workspaceFolders, 0, :uri) - @workspace_uri = URI(workspace_uri) if workspace_uri + if workspace_uri + @workspace_uri = to_internal_uri(URI(workspace_uri)) + end specified_formatter = options.dig(:initializationOptions, :formatter) @@ -171,6 +182,36 @@ def supports_watching_files @client_capabilities.supports_watching_files end + sig { params(uri: URI::Generic).returns(URI::Generic) } + def to_internal_uri(uri) + path = uri.path + return uri unless path + + local_fs_map.each do |external, internal| + next unless path.start_with?(external) + + uri.path = path.sub(external, internal) + return uri + end + + uri + end + + sig { params(uri: URI::Generic).returns(URI::Generic) } + def to_external_uri(uri) + path = uri.path + return uri unless path + + local_fs_map.each do |external, internal| + next unless path.start_with?(internal) + + uri.path = path.sub(internal, external) + return uri + end + + uri + end + private sig { params(direct_dependencies: T::Array[String], all_dependencies: T::Array[String]).returns(String) } @@ -263,5 +304,16 @@ def gather_direct_and_indirect_dependencies rescue Bundler::GemfileNotFound [] end + + sig { returns(T::Hash[String, String]) } + def build_local_fs_map_from_env + env = ENV["RUBY_LSP_LOCAL_FS_MAP"] + return {} unless env + + env.split(",").each_with_object({}) do |pair, map| + local, remote = pair.split(":", 2) + map[local] = remote + end + end end end diff --git a/lib/ruby_lsp/listeners/definition.rb b/lib/ruby_lsp/listeners/definition.rb index 9c37b6cf9..ba2c9edb2 100644 --- a/lib/ruby_lsp/listeners/definition.rb +++ b/lib/ruby_lsp/listeners/definition.rb @@ -222,8 +222,10 @@ def handle_global_variable_definition(name) entries.each do |entry| location = entry.location + external_uri = @global_state.to_external_uri(entry.uri) + @response_builder << Interface::Location.new( - uri: entry.uri.to_s, + uri: external_uri.to_s, range: Interface::Range.new( start: Interface::Position.new(line: location.start_line - 1, character: location.start_column), end: Interface::Position.new(line: location.end_line - 1, character: location.end_column), @@ -247,8 +249,10 @@ def handle_instance_variable_definition(name) entries.each do |entry| location = entry.location + external_uri = @global_state.to_external_uri(entry.uri) + @response_builder << Interface::Location.new( - uri: entry.uri.to_s, + uri: external_uri.to_s, range: Interface::Range.new( start: Interface::Position.new(line: location.start_line - 1, character: location.start_column), end: Interface::Position.new(line: location.end_line - 1, character: location.end_column), @@ -278,8 +282,10 @@ def handle_method_definition(message, receiver_type, inherited_only: false) uri = target_method.uri next if sorbet_level_true_or_higher?(@sorbet_level) && not_in_dependencies?(T.must(uri.full_path)) + external_uri = @global_state.to_external_uri(uri) + @response_builder << Interface::LocationLink.new( - target_uri: uri.to_s, + target_uri: external_uri.to_s, target_range: range_from_location(target_method.location), target_selection_range: range_from_location(target_method.name_location), ) @@ -298,8 +304,11 @@ def handle_require_definition(node, message) candidate = entry.full_path if candidate + uri = URI::Generic.from_path(path: candidate) + external_uri = @global_state.to_external_uri(uri) + @response_builder << Interface::Location.new( - uri: URI::Generic.from_path(path: candidate).to_s, + uri: external_uri.to_s, range: Interface::Range.new( start: Interface::Position.new(line: 0, character: 0), end: Interface::Position.new(line: 0, character: 0), @@ -313,8 +322,11 @@ def handle_require_definition(node, message) current_folder = path ? Pathname.new(CGI.unescape(path)).dirname : @global_state.workspace_path candidate = File.expand_path(File.join(current_folder, required_file)) + uri = URI::Generic.from_path(path: candidate) + external_uri = @global_state.to_external_uri(uri) + @response_builder << Interface::Location.new( - uri: URI::Generic.from_path(path: candidate).to_s, + uri: external_uri.to_s, range: Interface::Range.new( start: Interface::Position.new(line: 0, character: 0), end: Interface::Position.new(line: 0, character: 0), @@ -351,8 +363,10 @@ def find_in_index(value) uri = entry.uri next if @sorbet_level != RubyDocument::SorbetLevel::Ignore && not_in_dependencies?(T.must(uri.full_path)) + external_uri = @global_state.to_external_uri(uri) + @response_builder << Interface::LocationLink.new( - target_uri: uri.to_s, + target_uri: external_uri.to_s, target_range: range_from_location(entry.location), target_selection_range: range_from_location(entry.name_location), ) diff --git a/lib/ruby_lsp/requests/references.rb b/lib/ruby_lsp/requests/references.rb index bd7694f37..caca36a44 100644 --- a/lib/ruby_lsp/requests/references.rb +++ b/lib/ruby_lsp/requests/references.rb @@ -137,8 +137,10 @@ def collect_references(target, parse_result, uri) dispatcher.visit(parse_result.value) finder.references.each do |reference| + external_uri = @global_state.to_external_uri(uri) + @locations << Interface::Location.new( - uri: uri.to_s, + uri: external_uri.to_s, range: range_from_location(reference.location), ) end diff --git a/lib/ruby_lsp/requests/support/common.rb b/lib/ruby_lsp/requests/support/common.rb index de1821d49..812cb0fd8 100644 --- a/lib/ruby_lsp/requests/support/common.rb +++ b/lib/ruby_lsp/requests/support/common.rb @@ -89,11 +89,14 @@ def categorized_markdown_from_index_entries(title, entries, max_entries = nil) entries_to_format.each do |entry| loc = entry.location + @global_state ||= T.let(GlobalState.new, T.nilable(RubyLsp::GlobalState)) + external_uri = @global_state.to_external_uri(entry.uri) + # We always handle locations as zero based. However, for file links in Markdown we need them to be one # based, which is why instead of the usual subtraction of 1 to line numbers, we are actually adding 1 to # columns. The format for VS Code file URIs is # `file:///path/to/file.rb#Lstart_line,start_column-end_line,end_column` - uri = "#{entry.uri}#L#{loc.start_line},#{loc.start_column + 1}-#{loc.end_line},#{loc.end_column + 1}" + uri = "#{external_uri}#L#{loc.start_line},#{loc.start_column + 1}-#{loc.end_line},#{loc.end_column + 1}" definitions << "[#{entry.file_name}](#{uri})" content << "\n\n#{entry.comments}" unless entry.comments.empty? end diff --git a/lib/ruby_lsp/requests/workspace_symbol.rb b/lib/ruby_lsp/requests/workspace_symbol.rb index 9b6c238ee..384abbff9 100644 --- a/lib/ruby_lsp/requests/workspace_symbol.rb +++ b/lib/ruby_lsp/requests/workspace_symbol.rb @@ -39,12 +39,14 @@ def perform # short name `Bar`, then searching for `Foo::Bar` would not return any results *container, _short_name = entry.name.split("::") + external_uri = @global_state.to_external_uri(uri) + Interface::WorkspaceSymbol.new( name: entry.name, container_name: container.join("::"), kind: kind, location: Interface::Location.new( - uri: uri.to_s, + uri: external_uri.to_s, range: Interface::Range.new( start: Interface::Position.new(line: loc.start_line - 1, character: loc.start_column), end: Interface::Position.new(line: loc.end_line - 1, character: loc.end_column), diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index f975e065e..d2c4931c7 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -421,6 +421,8 @@ def text_document_did_close(message) uri = message.dig(:params, :textDocument, :uri) @store.delete(uri) + uri = global_state.to_external_uri(uri) + # Clear diagnostics for the closed file, so that they no longer appear in the problems tab send_message( Notification.new( @@ -1106,10 +1108,13 @@ def workspace_dependencies(message) dep_keys = definition.locked_deps.keys.to_set definition.specs.map do |spec| + uri = URI("file://#{spec.full_gem_path}") + uri = global_state.to_external_uri(uri) + { name: spec.name, version: spec.version, - path: spec.full_gem_path, + path: uri.path, dependency: dep_keys.include?(spec.name), } end diff --git a/vscode/package.json b/vscode/package.json index 2df1524f6..3a18ea028 100644 --- a/vscode/package.json +++ b/vscode/package.json @@ -338,6 +338,7 @@ "rvm", "shadowenv", "mise", + "compose", "custom" ], "default": "auto" @@ -357,6 +358,14 @@ "chrubyRubies": { "description": "An array of extra directories to search for Ruby installations when using chruby. Equivalent to the RUBIES environment variable", "type": "array" + }, + "composeService": { + "description": "The name of the service in the compose file to use to start the Ruby LSP server", + "type": "string" + }, + "composeCustomCommand": { + "description": "A shell command to start the ruby LSP server using compose", + "type": "string" } }, "default": { diff --git a/vscode/src/client.ts b/vscode/src/client.ts index 186370c74..802a5ee75 100644 --- a/vscode/src/client.ts +++ b/vscode/src/client.ts @@ -60,7 +60,7 @@ function enabledFeatureFlags(): Record { // Get the executables to start the server based on the user's configuration function getLspExecutables( workspaceFolder: vscode.WorkspaceFolder, - env: NodeJS.ProcessEnv, + ruby: Ruby, ): ServerOptions { let run: Executable; let debug: Executable; @@ -73,8 +73,8 @@ function getLspExecutables( const executableOptions: ExecutableOptions = { cwd: workspaceFolder.uri.fsPath, env: bypassTypechecker - ? { ...env, RUBY_LSP_BYPASS_TYPECHECKER: "true" } - : env, + ? { ...ruby.env, RUBY_LSP_BYPASS_TYPECHECKER: "true" } + : ruby.env, shell: true, }; @@ -128,6 +128,9 @@ function getLspExecutables( }; } + run = ruby.activateExecutable(run); + debug = ruby.activateExecutable(debug); + return { run, debug }; } @@ -152,6 +155,7 @@ function collectClientOptions( const supportedSchemes = ["file", "git"]; const fsPath = workspaceFolder.uri.fsPath.replace(/\/$/, ""); + const pathMapping = ruby.pathMapping; // For each workspace, the language client is responsible for handling requests for: // 1. Files inside of the workspace itself @@ -227,6 +231,7 @@ function collectClientOptions( indexing: configuration.get("indexing"), addonSettings: configuration.get("addonSettings"), enabledFeatureFlags: enabledFeatureFlags(), + localFsMap: pathMapping, }, }; } @@ -333,7 +338,7 @@ export default class Client extends LanguageClient implements ClientInterface { ) { super( LSP_NAME, - getLspExecutables(workspaceFolder, ruby.env), + getLspExecutables(workspaceFolder, ruby), collectClientOptions( vscode.workspace.getConfiguration("rubyLsp"), workspaceFolder, diff --git a/vscode/src/common.ts b/vscode/src/common.ts index 327ca2bae..012be2655 100644 --- a/vscode/src/common.ts +++ b/vscode/src/common.ts @@ -1,9 +1,10 @@ -import { exec } from "child_process"; +import { exec, spawn as originalSpawn } from "child_process"; import { createHash } from "crypto"; import { promisify } from "util"; import * as vscode from "vscode"; import { State } from "vscode-languageclient"; +import { Executable } from "vscode-languageclient/node"; export enum Command { Start = "rubyLsp.start", @@ -70,6 +71,8 @@ export const STATUS_EMITTER = new vscode.EventEmitter< WorkspaceInterface | undefined >(); +export const spawn = originalSpawn; + export const asyncExec = promisify(exec); export const LSP_NAME = "Ruby LSP"; export const LOG_CHANNEL = vscode.window.createOutputChannel(LSP_NAME, { @@ -146,3 +149,29 @@ 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; } + +export function parseCommand(commandString: string): Executable { + // Regular expression to split arguments while respecting quotes + const regex = /(?:[^\s"']+|"[^"]*"|'[^']*')+/g; + + const parts = + commandString.match(regex)?.map((arg) => { + // Remove surrounding quotes, if any + return arg.replace(/^['"]|['"]$/g, ""); + }) ?? []; + + // Extract environment variables + const env: Record = {}; + while (parts[0] && parts[0].includes("=")) { + const [key, value] = parts.shift()?.split("=") ?? []; + if (key) { + env[key] = value || ""; + } + } + + // The first part is the command, the rest are arguments + const command = parts.shift() || ""; + const args = parts; + + return { command, args, options: { env } }; +} diff --git a/vscode/src/docker.ts b/vscode/src/docker.ts new file mode 100644 index 000000000..e1e901021 --- /dev/null +++ b/vscode/src/docker.ts @@ -0,0 +1,116 @@ +import path from "path"; + +export interface ComposeConfig { + services: Record; + ["x-mutagen"]?: { sync: Record } | undefined; +} + +interface ComposeService { + volumes: ComposeVolume[]; +} + +interface ComposeVolume { + type: string; + source: string; + target: string; +} + +interface MutagenShare { + alpha: string; + beta: string; +} + +interface MutagenMount { + volume: string; + source: string; + target: string; +} + +type MutagenMountMapping = Record< + string, + { + source: string; + target: string; + } +>; + +export function fetchPathMapping( + config: ComposeConfig, + service: string, +): Record { + const mutagenMounts = fetchMutagenMounts(config["x-mutagen"]?.sync || {}); + + const bindings = fetchComposeBindings( + config.services[service]?.volumes || [], + mutagenMounts, + ); + + return bindings; +} + +function fetchComposeBindings( + volumes: ComposeVolume[], + mutagenMounts: MutagenMountMapping, +): Record { + return volumes.reduce( + (acc: Record, volume: ComposeVolume) => { + if (volume.type === "bind") { + acc[volume.source] = volume.target; + } else if (volume.type === "volume") { + Object.entries(mutagenMounts).forEach( + ([ + mutagenVolume, + { source: mutagenSource, target: mutagenTarget }, + ]) => { + if (mutagenVolume.startsWith(`volume://${volume.source}/`)) { + const remotePath = path.resolve(volume.target, mutagenSource); + acc[mutagenTarget] = remotePath; + } + }, + ); + } + + return acc; + }, + {}, + ); +} + +function transformMutagenMount(alpha: string, beta: string): MutagenMount { + const [, ...path] = alpha.replace("volume://", "").split("/"); + const [volume, source] = + path.length > 0 ? [alpha, `./${path.join("/")}`] : [`${alpha}/`, "."]; + + return { volume, source, target: beta }; +} + +function fetchMutagenMounts( + sync: Record = {}, +): MutagenMountMapping { + return Object.entries(sync).reduce( + ( + acc: Record, + [name, { alpha, beta }]: [string, MutagenShare], + ) => { + if (name === "defaults") return acc; + + let mount: MutagenMount | null = null; + + if (alpha.startsWith("volume://")) { + mount = transformMutagenMount(alpha, beta); + } else if (beta.startsWith("volume://")) { + mount = transformMutagenMount(beta, alpha); + } + + if (mount) { + acc[mount.volume] = { + source: mount.source, + target: mount.target, + }; + } + + return acc; + }, + {}, + ); +} diff --git a/vscode/src/ruby.ts b/vscode/src/ruby.ts index 5270bc9a3..efac1ba81 100644 --- a/vscode/src/ruby.ts +++ b/vscode/src/ruby.ts @@ -3,8 +3,9 @@ import path from "path"; import os from "os"; import * as vscode from "vscode"; +import { Executable, ExecutableOptions } from "vscode-languageclient/node"; -import { asyncExec, RubyInterface } from "./common"; +import { asyncExec, parseCommand, RubyInterface } from "./common"; import { WorkspaceChannel } from "./workspaceChannel"; import { Shadowenv } from "./ruby/shadowenv"; import { Chruby } from "./ruby/chruby"; @@ -14,6 +15,7 @@ import { RubyInstaller } from "./ruby/rubyInstaller"; import { Rbenv } from "./ruby/rbenv"; import { Rvm } from "./ruby/rvm"; import { None } from "./ruby/none"; +import { Compose } from "./ruby/compose"; import { Custom } from "./ruby/custom"; import { Asdf } from "./ruby/asdf"; @@ -44,6 +46,7 @@ export enum ManagerIdentifier { Shadowenv = "shadowenv", Mise = "mise", RubyInstaller = "rubyInstaller", + Compose = "compose", None = "none", Custom = "custom", } @@ -66,6 +69,8 @@ export class Ruby implements RubyInterface { private readonly shell = process.env.SHELL?.replace(/(\s+)/g, "\\$1"); private _env: NodeJS.ProcessEnv = {}; private _error = false; + private _pathMapping: Record = {}; + private _wrapCommand: (executable: Executable) => Executable; private readonly context: vscode.ExtensionContext; private readonly customBundleGemfile?: string; private readonly outputChannel: WorkspaceChannel; @@ -81,6 +86,7 @@ export class Ruby implements RubyInterface { this.workspaceFolder = workspaceFolder; this.outputChannel = outputChannel; this.telemetry = telemetry; + this._wrapCommand = (executable: Executable) => executable; const customBundleGemfile: string = vscode.workspace .getConfiguration("rubyLsp") @@ -109,6 +115,10 @@ export class Ruby implements RubyInterface { } } + get pathMapping() { + return this._pathMapping; + } + get env() { return this._env; } @@ -189,6 +199,24 @@ export class Ruby implements RubyInterface { } } + runActivatedScript(script: string, options: ExecutableOptions = {}) { + const parsedExecutable = parseCommand(script); + const executable = this.activateExecutable({ + ...parsedExecutable, + options, + }); + const command = [executable.command, ...(executable.args || [])].join(" "); + + return asyncExec(command, { + cwd: this.workspaceFolder.uri.fsPath || executable.options?.cwd, + env: { ...process.env, ...executable.options?.env }, + }); + } + + activateExecutable(executable: Executable) { + return this._wrapCommand(executable); + } + async manuallySelectRuby() { const manualSelection = await vscode.window.showInformationMessage( "Configure global or workspace specific fallback for the Ruby LSP?", @@ -241,7 +269,8 @@ export class Ruby implements RubyInterface { } private async runActivation(manager: VersionManager) { - const { env, version, yjit, gemPath } = await manager.activate(); + const { env, version, yjit, gemPath, pathMapping, wrapCommand } = + await manager.activate(); const [major, minor, _patch] = version.split(".").map(Number); this.sanitizeEnvironment(env); @@ -252,6 +281,13 @@ export class Ruby implements RubyInterface { this.rubyVersion = version; this.yjitEnabled = (yjit && major > 3) || (major === 3 && minor >= 2); this.gemPath.push(...gemPath); + + if (pathMapping) { + this._pathMapping = pathMapping; + } + if (wrapCommand) { + this._wrapCommand = wrapCommand; + } } // Fetch information related to the Ruby version. This can only be invoked after activation, so that `rubyVersion` is @@ -350,6 +386,15 @@ export class Ruby implements RubyInterface { ), ); break; + case ManagerIdentifier.Compose: + await this.runActivation( + new Compose( + this.workspaceFolder, + this.outputChannel, + this.manuallySelectRuby.bind(this), + ), + ); + break; case ManagerIdentifier.Custom: await this.runActivation( new Custom( diff --git a/vscode/src/ruby/compose.ts b/vscode/src/ruby/compose.ts new file mode 100644 index 000000000..57e7ace05 --- /dev/null +++ b/vscode/src/ruby/compose.ts @@ -0,0 +1,285 @@ +/* eslint-disable no-process-env */ +import path from "path"; +import os from "os"; +import { StringDecoder } from "string_decoder"; +import { ExecOptions } from "child_process"; + +import * as vscode from "vscode"; +import { Executable } from "vscode-languageclient/node"; + +import { ComposeConfig, fetchPathMapping } from "../docker"; +import { parseCommand, spawn } from "../common"; + +import { + VersionManager, + ActivationResult, + ACTIVATION_SEPARATOR, +} from "./versionManager"; + +// Compose +// +// Docker Compose is a tool for defining and running multi-container Docker applications. If your project uses Docker +// Compose, you can run Ruby LSP in one of the services defined in your `docker-compose.yml` file. It also supports +// mutagen file synchronization and can be customized to use a different Docker Compose wrapper command. +export class Compose extends VersionManager { + protected composeConfig: ComposeConfig = { services: {} } as ComposeConfig; + + async activate(): Promise { + await this.ensureConfigured(); + + const rubyCommand = `${this.composeRunCommand()} ${this.composeServiceName()} ruby -W0 -rjson`; + const { stderr: output } = await this.runRubyCode( + rubyCommand, + this.activationScript, + ); + + this.outputChannel.debug(`Activation output: ${output}`); + + const activationContent = new RegExp( + `${ACTIVATION_SEPARATOR}(.*)${ACTIVATION_SEPARATOR}`, + ).exec(output); + + const parsedResult = this.parseWithErrorHandling(activationContent![1]); + const pathMapping = await this.buildPathMapping(); + + if (parsedResult.gemPath) { + const convertedPaths = (parsedResult.gemPath as string[]).map((path) => { + for (const [local, remote] of Object.entries(pathMapping)) { + if (path.startsWith(remote)) { + return path.replace(remote, local); + } + } + + return path; + }); + + parsedResult.gemPath = convertedPaths; + } + + const wrapCommand = (executable: Executable) => { + const composeCommad = parseCommand( + `${this.composeRunCommand()} ${this.composeServiceName()}`, + ); + + const command = { + command: composeCommad.command, + args: [ + ...(composeCommad.args ?? []), + executable.command, + ...(executable.args ?? []), + ], + options: { + ...executable.options, + env: { + ...executable.options?.env, + ...composeCommad.options?.env, + }, + }, + }; + + return command; + }; + + return { + env: { ...process.env }, + yjit: parsedResult.yjit, + version: parsedResult.version, + gemPath: parsedResult.gemPath, + pathMapping, + wrapCommand, + }; + } + + protected async buildPathMapping() { + const pathMapping = fetchPathMapping( + this.composeConfig, + this.composeServiceName(), + ); + + const stats = Object.entries(pathMapping).map(([local, remote]) => { + const absolute = path.resolve(this.workspaceFolder.uri.fsPath, local); + return vscode.workspace.fs.stat(vscode.Uri.file(absolute)).then( + (stat) => ({ stat, local, remote, absolute }), + () => ({ stat: undefined, local, remote, absolute }), + ); + }); + + const filteredMapping = (await Promise.all(stats)).reduce( + (acc, { stat, local, remote, absolute }) => { + if (stat?.type === vscode.FileType.Directory) { + this.outputChannel.info(`Path ${absolute} mapped to ${remote}`); + acc[absolute] = remote; + } else { + this.outputChannel.debug( + `Skipping path ${local} because it does not exist`, + ); + } + + return acc; + }, + {} as Record, + ); + + return filteredMapping; + } + + protected composeRunCommand(): string { + return `${this.composeCommand()} run --rm -i`; + } + + protected composeServiceName(): string { + const service: string | undefined = vscode.workspace + .getConfiguration("rubyLsp.rubyVersionManager") + .get("composeService"); + + if (service === undefined) { + throw new Error( + "The composeService configuration must be set when 'compose' is selected as the version manager. \ + See the [README](https://shopify.github.io/ruby-lsp/version-managers.html) for instructions.", + ); + } + + return service; + } + + protected composeCommand(): string { + const composeCustomCommand: string | undefined = vscode.workspace + .getConfiguration("rubyLsp.rubyVersionManager") + .get("composeCustomCommand"); + + return ( + composeCustomCommand || + "docker --log-level=error compose --progress=quiet" + ); + } + + protected async ensureConfigured() { + this.composeConfig = await this.getComposeConfig(); + const services: vscode.QuickPickItem[] = []; + + const config = vscode.workspace.getConfiguration("rubyLsp"); + const currentService = config.get("rubyVersionManager.composeService") as + | string + | undefined; + + if (currentService && this.composeConfig.services[currentService]) { + return; + } + + for (const [name, _service] of Object.entries( + this.composeConfig.services, + )) { + services.push({ label: name }); + } + + const answer = await vscode.window.showQuickPick(services, { + title: "Select Docker Compose service where to run ruby-lsp", + ignoreFocusOut: true, + }); + + if (!answer) { + throw new Error("No compose service selected"); + } + + const managerConfig = config.inspect("rubyVersionManager"); + const workspaceConfig = managerConfig?.workspaceValue || {}; + + await config.update("rubyVersionManager", { + ...workspaceConfig, + ...{ composeService: answer.label }, + }); + } + + protected runRubyCode( + rubyCommand: string, + code: string, + ): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + this.outputChannel.info( + `Ruby \`${rubyCommand}\` running Ruby code: \`${code}\``, + ); + + const { + command, + args, + options: { env } = { env: {} }, + } = parseCommand(rubyCommand); + const ruby = spawn(command, args, this.execOptions({ env })); + + let stdout = ""; + let stderr = ""; + + const stdoutDecoder = new StringDecoder("utf-8"); + const stderrDecoder = new StringDecoder("utf-8"); + + ruby.stdout.on("data", (data) => { + stdout += stdoutDecoder.write(data); + + if (stdout.includes("END_OF_RUBY_CODE_OUTPUT")) { + stdout = stdout.replace(/END_OF_RUBY_CODE_OUTPUT.*/s, ""); + resolve({ stdout, stderr }); + } + }); + ruby.stderr.on("data", (data) => { + stderr += stderrDecoder.write(data); + }); + ruby.on("error", (error) => { + reject(error); + }); + ruby.on("close", (status) => { + if (status) { + reject(new Error(`Process exited with status ${status}: ${stderr}`)); + } else { + resolve({ stdout, stderr }); + } + }); + + const script = [ + "begin", + ...code.split("\n").map((line) => ` ${line}`), + "ensure", + ' puts "END_OF_RUBY_CODE_OUTPUT"', + "end", + ].join("\n"); + + this.outputChannel.info(`Running Ruby code:\n${script}`); + + ruby.stdin.write(script); + ruby.stdin.end(); + }); + } + + protected execOptions(options: ExecOptions = {}): ExecOptions { + let shell: string | undefined; + + // If the user has configured a default shell, we use that one since they are probably sourcing their version + // manager scripts in that shell's configuration files. On Windows, we never set the shell no matter what to ensure + // that activation runs on `cmd.exe` and not PowerShell, which avoids complex quoting and escaping issues. + if (vscode.env.shell.length > 0 && os.platform() !== "win32") { + shell = vscode.env.shell; + } + + return { + cwd: this.bundleUri.fsPath, + shell, + ...options, + env: { ...process.env, ...options.env }, + }; + } + + private async getComposeConfig(): Promise { + try { + const { stdout, stderr: _stderr } = await this.runScript( + `${this.composeCommand()} config --format=json`, + ); + + const config = JSON.parse(stdout) as ComposeConfig; + + return config; + } catch (error: any) { + throw new Error( + `Failed to read docker-compose configuration: ${error.message}`, + ); + } + } +} diff --git a/vscode/src/ruby/versionManager.ts b/vscode/src/ruby/versionManager.ts index f24837490..86184dd01 100644 --- a/vscode/src/ruby/versionManager.ts +++ b/vscode/src/ruby/versionManager.ts @@ -3,6 +3,7 @@ import path from "path"; import os from "os"; import * as vscode from "vscode"; +import { Executable } from "vscode-languageclient/node"; import { WorkspaceChannel } from "../workspaceChannel"; import { asyncExec } from "../common"; @@ -12,6 +13,8 @@ export interface ActivationResult { yjit: boolean; version: string; gemPath: string[]; + pathMapping?: Record; + wrapCommand?: (executable: Executable) => Executable; } export const ACTIVATION_SEPARATOR = "RUBY_LSP_ACTIVATION_SEPARATOR"; diff --git a/vscode/src/test/suite/ruby/compose.test.ts b/vscode/src/test/suite/ruby/compose.test.ts new file mode 100644 index 000000000..2aff78c8c --- /dev/null +++ b/vscode/src/test/suite/ruby/compose.test.ts @@ -0,0 +1,126 @@ +import assert from "assert"; +import path from "path"; +import fs from "fs"; +import os from "os"; + +import * as vscode from "vscode"; +import sinon from "sinon"; + +import { WorkspaceChannel } from "../../../workspaceChannel"; +import { Compose } from "../../../ruby/compose"; +import * as common from "../../../common"; +import { ACTIVATION_SEPARATOR } from "../../../ruby/versionManager"; +import { createSpawnStub } from "../testHelpers"; +import { ComposeConfig } from "../../../docker"; + +suite("Compose", () => { + let spawnStub: sinon.SinonStub; + let execStub: sinon.SinonStub; + let configStub: sinon.SinonStub; + let stdinData: string[]; + + let workspacePath: string; + let workspaceFolder: vscode.WorkspaceFolder; + let outputChannel: WorkspaceChannel; + + const composeService = "develop"; + const composeConfig: ComposeConfig = { + services: { [composeService]: { volumes: [] } }, + }; + + setup(() => { + workspacePath = fs.mkdtempSync(path.join(os.tmpdir(), "ruby-lsp-test-")); + workspaceFolder = { + uri: vscode.Uri.file(workspacePath), + name: path.basename(workspacePath), + index: 0, + }; + outputChannel = new WorkspaceChannel("fake", common.LOG_CHANNEL); + }); + + teardown(() => { + spawnStub?.restore(); + execStub?.restore(); + configStub?.restore(); + + fs.rmSync(workspacePath, { recursive: true, force: true }); + }); + + test("Activates Ruby environment using Docker Compose", async () => { + const compose = new Compose(workspaceFolder, outputChannel, async () => {}); + + const envStub = { + env: { ANY: "true" }, + yjit: true, + version: "3.0.0", + }; + + ({ spawnStub, stdinData } = createSpawnStub({ + stderr: `${ACTIVATION_SEPARATOR}${JSON.stringify(envStub)}${ACTIVATION_SEPARATOR}`, + })); + + execStub = sinon + .stub(common, "asyncExec") + .resolves({ stdout: JSON.stringify(composeConfig), stderr: "" }); + + configStub = sinon.stub(vscode.workspace, "getConfiguration").returns({ + get: (name: string) => { + if ( + name === "composeService" || + name === "rubyVersionManager.composeService" + ) { + return composeService; + } else if (name === "rubyVersionManager") { + return { composeService }; + } + return undefined; + }, + } as any); + + const { version, yjit } = await compose.activate(); + + // We must not set the shell on Windows + const shell = os.platform() === "win32" ? undefined : vscode.env.shell; + + assert.ok( + spawnStub.calledOnceWithExactly( + "docker", + [ + "--log-level=error", + "compose", + "--progress=quiet", + "run", + "--rm", + "-i", + composeService, + "ruby", + "-W0", + "-rjson", + ], + { + cwd: workspaceFolder.uri.fsPath, + shell, + // eslint-disable-next-line no-process-env + env: process.env, + }, + ), + ); + + assert.ok( + execStub.calledOnceWithExactly( + "docker --log-level=error compose --progress=quiet config --format=json", + { + cwd: workspaceFolder.uri.fsPath, + shell, + // eslint-disable-next-line no-process-env + env: process.env, + }, + ), + ); + + assert.ok(stdinData.join("\n").includes(compose.activationScript)); + + assert.strictEqual(version, "3.0.0"); + assert.strictEqual(yjit, true); + }); +}); diff --git a/vscode/src/test/suite/testHelpers.ts b/vscode/src/test/suite/testHelpers.ts new file mode 100644 index 000000000..65d7cf4f1 --- /dev/null +++ b/vscode/src/test/suite/testHelpers.ts @@ -0,0 +1,59 @@ +import { EventEmitter, Readable, Writable } from "stream"; +import { SpawnOptions } from "child_process"; + +import sinon from "sinon"; + +import * as common from "../../common"; + +interface SpawnStubOptions { + stdout?: string; + stderr?: string; + exitCode?: number; +} + +export function createSpawnStub({ + stdout = "", + stderr = "", + exitCode = 0, +}: SpawnStubOptions = {}): { spawnStub: sinon.SinonStub; stdinData: string[] } { + const stdinData: string[] = []; + + const spawnStub = sinon + .stub(common, "spawn") + .callsFake( + ( + _command: string, + _args: ReadonlyArray, + _options: SpawnOptions, + ) => { + const childProcess = new EventEmitter() as any; + + childProcess.stdout = new Readable({ + read() {}, + }); + childProcess.stderr = new Readable({ + read() {}, + }); + childProcess.stdin = new Writable({ + write(chunk, _encoding, callback) { + stdinData.push(chunk.toString()); + callback(); + }, + final(callback) { + process.nextTick(() => { + childProcess.stdout.emit("data", stdout); + childProcess.stderr.emit("data", stderr); + + childProcess.emit("close", exitCode); + }); + + callback(); + }, + }); + + return childProcess; + }, + ); + + return { spawnStub, stdinData }; +} diff --git a/vscode/src/workspace.ts b/vscode/src/workspace.ts index 5fcf55220..e6efdbaa5 100644 --- a/vscode/src/workspace.ts +++ b/vscode/src/workspace.ts @@ -6,7 +6,6 @@ import { CodeLens, State } from "vscode-languageclient/node"; import { Ruby } from "./ruby"; import Client from "./client"; import { - asyncExec, LOG_CHANNEL, WorkspaceInterface, STATUS_EMITTER, @@ -268,16 +267,19 @@ export class Workspace implements WorkspaceInterface { "sorbet-runtime", ]; - const { stdout } = await asyncExec(`gem list ${dependencies.join(" ")}`, { - cwd: this.workspaceFolder.uri.fsPath, - env: this.ruby.env, - }); + const { stdout } = await this.ruby.runActivatedScript( + `gem list ${dependencies.join(" ")}`, + { + cwd: this.workspaceFolder.uri.fsPath, + env: this.ruby.env, + }, + ); // If any of the Ruby LSP's dependencies are missing, we need to install them. For example, if the user runs `gem // uninstall prism`, then we must ensure it's installed or else rubygems will fail when trying to launch the // executable if (!dependencies.every((dep) => new RegExp(`${dep}\\s`).exec(stdout))) { - await asyncExec("gem install ruby-lsp", { + await this.ruby.runActivatedScript("gem install ruby-lsp", { cwd: this.workspaceFolder.uri.fsPath, env: this.ruby.env, }); @@ -311,7 +313,7 @@ export class Workspace implements WorkspaceInterface { Date.now() - lastUpdatedAt > oneDayInMs ) { try { - await asyncExec("gem update ruby-lsp", { + await this.ruby.runActivatedScript("gem update ruby-lsp", { cwd: this.workspaceFolder.uri.fsPath, env: this.ruby.env, }); @@ -347,7 +349,7 @@ export class Workspace implements WorkspaceInterface { this.outputChannel.info(`Running "${command}"`); } - const result = await asyncExec(command, { + const result = await this.ruby.runActivatedScript(command, { env: this.ruby.env, cwd: this.workspaceFolder.uri.fsPath, });