diff --git a/exe/ruby-lsp-launcher b/exe/ruby-lsp-launcher index e9649b455..dd2bab562 100755 --- a/exe/ruby-lsp-launcher +++ b/exe/ruby-lsp-launcher @@ -35,7 +35,7 @@ pid = fork do workspace_path = workspace_uri && URI(workspace_uri).to_standardized_path workspace_path ||= Dir.pwd - env = RubyLsp::SetupBundler.new(workspace_path).setup! + env = RubyLsp::SetupBundler.new(workspace_path, invoke_cli: true).setup! File.write(bundle_gemfile_file, env["BUNDLE_GEMFILE"]) File.write(locked_bundler_version_file, env["BUNDLER_VERSION"]) if env["BUNDLER_VERSION"] rescue RubyLsp::SetupBundler::BundleNotLocked @@ -75,6 +75,12 @@ rescue StandardError => e Gem::Specification.find_by_name("ruby-lsp").activate end +error_path = File.join(".ruby-lsp", "install_error") + +install_error = if File.exist?(error_path) + Marshal.load(File.read(error_path)) +end + # Now that the bundle is set up, we can begin actually launching the server $LOAD_PATH.unshift(File.expand_path("../lib", __dir__)) @@ -103,6 +109,7 @@ $> = $stderr initialize_request = JSON.parse(raw_initialize, symbolize_names: true) if raw_initialize RubyLsp::Server.new( + install_error: install_error, setup_error: setup_error, initialize_request: initialize_request, ).start diff --git a/lib/ruby_lsp/base_server.rb b/lib/ruby_lsp/base_server.rb index d487d0fbd..64ac34519 100644 --- a/lib/ruby_lsp/base_server.rb +++ b/lib/ruby_lsp/base_server.rb @@ -12,6 +12,7 @@ class BaseServer def initialize(**options) @test_mode = T.let(options[:test_mode], T.nilable(T::Boolean)) @setup_error = T.let(options[:setup_error], T.nilable(StandardError)) + @install_error = T.let(options[:install_error], T.nilable(StandardError)) @writer = T.let(Transport::Stdio::Writer.new, Transport::Stdio::Writer) @reader = T.let(Transport::Stdio::Reader.new, Transport::Stdio::Reader) @incoming_queue = T.let(Thread::Queue.new, Thread::Queue) diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index 9fe2172ff..56144dbca 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -292,15 +292,21 @@ def run_initialize(message) global_state_notifications.each { |notification| send_message(notification) } if @setup_error - message = <<~MESSAGE - An error occurred while setting up Bundler. This may be due to a failure when installing dependencies. - The Ruby LSP will continue to run, but features related to the missing dependencies will be limited. - - Error: - #{@setup_error.full_message} - MESSAGE + send_message(Notification.telemetry( + type: "error", + errorMessage: @setup_error.message, + errorClass: @setup_error.class, + stack: @setup_error.backtrace&.join("\n"), + )) + end - send_message(Notification.window_log_message(message, type: Constant::MessageType::ERROR)) + if @install_error + send_message(Notification.telemetry( + type: "error", + errorMessage: @install_error.message, + errorClass: @install_error.class, + stack: @install_error.backtrace&.join("\n"), + )) end end diff --git a/lib/ruby_lsp/setup_bundler.rb b/lib/ruby_lsp/setup_bundler.rb index eb049c95d..ea6adc8c8 100644 --- a/lib/ruby_lsp/setup_bundler.rb +++ b/lib/ruby_lsp/setup_bundler.rb @@ -27,6 +27,7 @@ class BundleInstallFailure < StandardError; end def initialize(project_path, **options) @project_path = project_path @branch = T.let(options[:branch], T.nilable(String)) + @invoke_cli = T.let(options[:invoke_cli], T.nilable(T::Boolean)) # Regular bundle paths @gemfile = T.let( @@ -47,6 +48,7 @@ def initialize(project_path, **options) @custom_lockfile = T.let(@custom_dir + (@lockfile&.basename || "Gemfile.lock"), Pathname) @lockfile_hash_path = T.let(@custom_dir + "main_lockfile_hash", Pathname) @last_updated_path = T.let(@custom_dir + "last_updated", Pathname) + @error_path = T.let(@custom_dir + "install_error", Pathname) dependencies, bundler_version = load_dependencies @dependencies = T.let(dependencies, T::Hash[String, T.untyped]) @@ -186,6 +188,47 @@ def run_bundle_install(bundle_gemfile = @gemfile) env["BUNDLE_PATH"] = File.expand_path(env["BUNDLE_PATH"], @project_path) end + return run_bundle_install_through_command(env) unless @invoke_cli + + begin + run_bundle_install_directly(env) + rescue => e + # Write the error object to a file so that we can read it from the parent process + @error_path.write(Marshal.dump(e)) + end + + env + end + + sig { params(env: T::Hash[String, String]).returns(T::Hash[String, String]) } + def run_bundle_install_directly(env) + ENV.merge!(env) + + unless should_bundle_update? + require "bundler/cli/install" + RubyVM::YJIT.enable if defined?(RubyVM::YJIT) + Bundler::CLI::Install.new({}).run + + return env + end + + # If any of `ruby-lsp`, `ruby-lsp-rails` or `debug` are not in the Gemfile, try to update them to the latest + # version + gems = [] + gems << "ruby-lsp" unless @dependencies["ruby-lsp"] + gems << "debug" unless @dependencies["debug"] + gems << "ruby-lsp-rails" if @rails_app && !@dependencies["ruby-lsp-rails"] + + require "bundler/cli/update" + RubyVM::YJIT.enable if defined?(RubyVM::YJIT) + Bundler::CLI::Update.new({}, gems).run + + @last_updated_path.write(Time.now.iso8601) + env + end + + sig { params(env: T::Hash[String, String]).returns(T::Hash[String, String]) } + def run_bundle_install_through_command(env) # If there's a Bundler version locked, then we need to use that one to run bundle commands, so that the composed # lockfile is also locked to the same version. This avoids Bundler restarts on version mismatches base_bundle = if @bundler_version diff --git a/lib/ruby_lsp/utils.rb b/lib/ruby_lsp/utils.rb index 630c8b168..b73e637be 100644 --- a/lib/ruby_lsp/utils.rb +++ b/lib/ruby_lsp/utils.rb @@ -79,6 +79,14 @@ def window_log_message(message, type: Constant::MessageType::LOG) params: Interface::LogMessageParams.new(type: type, message: message), ) end + + sig { params(data: T::Hash[Symbol, T.untyped]).returns(Notification) } + def telemetry(data) + new( + method: "telemetry/event", + params: data, + ) + end end extend T::Sig diff --git a/test/setup_bundler_test.rb b/test/setup_bundler_test.rb index 4c51d493a..28f1fbac5 100644 --- a/test/setup_bundler_test.rb +++ b/test/setup_bundler_test.rb @@ -610,6 +610,59 @@ def test_sets_bundler_version_to_avoid_reloads end end + def test_invoke_cli_calls_bundler_directly_for_install + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + File.write(File.join(dir, "gems.rb"), <<~GEMFILE) + source "https://rubygems.org" + gem "irb" + GEMFILE + + Bundler.with_unbundled_env do + capture_subprocess_io do + system("bundle install") + + mock_object = mock("install") + mock_object.expects(:run) + Bundler::CLI::Install.expects(:new).with({}).returns(mock_object) + RubyLsp::SetupBundler.new(dir, invoke_cli: true).setup! + end + end + end + end + end + + def test_invoke_cli_calls_bundler_directly_for_update + Dir.mktmpdir do |dir| + Dir.chdir(dir) do + File.write(File.join(dir, "Gemfile"), <<~GEMFILE) + source "https://rubygems.org" + gem "rdoc" + GEMFILE + + capture_subprocess_io do + Bundler.with_unbundled_env do + # Run bundle install to generate the lockfile + system("bundle install") + + # Run the script once to generate a custom bundle + run_script(dir) + end + end + + capture_subprocess_io do + Bundler.with_unbundled_env do + mock_object = mock("update") + mock_object.expects(:run) + require "bundler/cli/update" + Bundler::CLI::Update.expects(:new).with({}, ["ruby-lsp", "debug"]).returns(mock_object) + RubyLsp::SetupBundler.new(dir, invoke_cli: true).setup! + end + end + end + end + end + private def with_default_external_encoding(encoding, &block)