From 6976d2b8c9344cd51959251414ac99507f77a2b1 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Tue, 22 Oct 2024 16:27:29 -0400 Subject: [PATCH] Use Bundler CLI directly and send errors to telemetry --- exe/ruby-lsp-launcher | 7 +++++ lib/ruby_lsp/base_server.rb | 1 + lib/ruby_lsp/server.rb | 22 +++++++++------ lib/ruby_lsp/setup_bundler.rb | 49 ++++++++++++++++++++++++++++++++ lib/ruby_lsp/utils.rb | 8 ++++++ sorbet/rbi/shims/bundler.rbi | 26 +++++++++++++++-- test/setup_bundler_test.rb | 53 +++++++++++++++++++++++++++++++++++ 7 files changed, 155 insertions(+), 11 deletions(-) diff --git a/exe/ruby-lsp-launcher b/exe/ruby-lsp-launcher index 3e8f49d9f..eb5c0cc0c 100755 --- a/exe/ruby-lsp-launcher +++ b/exe/ruby-lsp-launcher @@ -64,6 +64,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__)) @@ -92,6 +98,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 11e5a6e47..a428ceaf9 100644 --- a/lib/ruby_lsp/setup_bundler.rb +++ b/lib/ruby_lsp/setup_bundler.rb @@ -3,6 +3,8 @@ require "sorbet-runtime" require "bundler" +require "bundler/cli/install" +require "bundler/cli/update" require "fileutils" require "pathname" require "digest" @@ -48,6 +50,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]) @@ -187,6 +190,52 @@ 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 @launcher + + # This same check happens conditionally when running through the command. For invoking the CLI directly, it's + # important that we ensure the Bundler version is set to avoid restarts + if @bundler_version + env["BUNDLER_VERSION"] = @bundler_version.to_s + install_bundler_if_needed + end + + 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) + T.unsafe(ENV).merge!(env) + + unless should_bundle_update? + 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"] + + 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) base_bundle = base_bundle_command(env) # If `ruby-lsp` and `debug` (and potentially `ruby-lsp-rails`) are already in the Gemfile, then we shouldn't try 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/sorbet/rbi/shims/bundler.rbi b/sorbet/rbi/shims/bundler.rbi index 16b184e08..f48604ba0 100644 --- a/sorbet/rbi/shims/bundler.rbi +++ b/sorbet/rbi/shims/bundler.rbi @@ -1,6 +1,26 @@ # typed: true -class Bundler::Settings - sig { params(name: String).returns(String) } - def self.key_for(name); end +module Bundler + class Settings + sig { params(name: String).returns(String) } + def self.key_for(name); end + end + + module CLI + class Install + sig { params(options: T::Hash[String, T.untyped]).void } + def initialize(options); end + + sig { void } + def run; end + end + + class Update + sig { params(options: T::Hash[String, T.untyped], gems: T::Array[String]).void } + def initialize(options, gems); end + + sig { void } + def run; end + end + end end diff --git a/test/setup_bundler_test.rb b/test/setup_bundler_test.rb index 4c51d493a..36b97d383 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, launcher: 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, launcher: true).setup! + end + end + end + end + end + private def with_default_external_encoding(encoding, &block)