diff --git a/lib/ruby_lsp/addon.rb b/lib/ruby_lsp/addon.rb index 575e93fc9..5038b2d45 100644 --- a/lib/ruby_lsp/addon.rb +++ b/lib/ruby_lsp/addon.rb @@ -49,15 +49,18 @@ def inherited(child_class) super end - # Discovers and loads all addons. Returns the list of activated addons - sig { params(global_state: GlobalState, outgoing_queue: Thread::Queue).returns(T::Array[Addon]) } + # Discovers and loads all addons. Returns a list of errors when trying to require addons + sig do + params(global_state: GlobalState, outgoing_queue: Thread::Queue).returns(T::Array[StandardError]) + end def load_addons(global_state, outgoing_queue) # Require all addons entry points, which should be placed under # `some_gem/lib/ruby_lsp/your_gem_name/addon.rb` - Gem.find_files("ruby_lsp/**/addon.rb").each do |addon| + errors = Gem.find_files("ruby_lsp/**/addon.rb").filter_map do |addon| require File.expand_path(addon) + nil rescue => e - $stderr.puts(e.full_message) + e end # Instantiate all discovered addon classes @@ -71,6 +74,8 @@ def load_addons(global_state, outgoing_queue) rescue => e addon.add_error(e) end + + errors end # Intended for use by tests for addons diff --git a/lib/ruby_lsp/base_server.rb b/lib/ruby_lsp/base_server.rb index eb73b1373..d23ac643d 100644 --- a/lib/ruby_lsp/base_server.rb +++ b/lib/ruby_lsp/base_server.rb @@ -65,7 +65,7 @@ def start when "initialize", "initialized", "textDocument/didOpen", "textDocument/didClose", "textDocument/didChange" process_message(message) when "shutdown" - $stderr.puts("Shutting down Ruby LSP...") + send_log_message("Shutting down Ruby LSP...") shutdown @@ -76,7 +76,7 @@ def start when "exit" @mutex.synchronize do status = @incoming_queue.closed? ? 0 : 1 - $stderr.puts("Shutdown complete with status #{status}") + send_log_message("Shutdown complete with status #{status}") exit(status) end else @@ -145,5 +145,10 @@ def send_message(message) def send_empty_response(id) send_message(Result.new(id: id, response: nil)) end + + sig { params(message: String, type: Integer).void } + def send_log_message(message, type: Constant::MessageType::LOG) + send_message(Notification.window_log_message(message, type: Constant::MessageType::LOG)) + end end end diff --git a/lib/ruby_lsp/global_state.rb b/lib/ruby_lsp/global_state.rb index 42db2d6e9..6a96a9a2d 100644 --- a/lib/ruby_lsp/global_state.rb +++ b/lib/ruby_lsp/global_state.rb @@ -57,21 +57,48 @@ def active_linters @linters.filter_map { |name| @supported_formatters[name] } end - sig { params(options: T::Hash[Symbol, T.untyped]).void } + # Applies the options provided by the editor and returns an array of notifications to send back to the client + sig { params(options: T::Hash[Symbol, T.untyped]).returns(T::Array[Notification]) } def apply_options(options) + notifications = [] direct_dependencies = gather_direct_dependencies all_dependencies = gather_direct_and_indirect_dependencies workspace_uri = options.dig(:workspaceFolders, 0, :uri) @workspace_uri = URI(workspace_uri) if workspace_uri specified_formatter = options.dig(:initializationOptions, :formatter) - @formatter = specified_formatter if specified_formatter - @formatter = detect_formatter(direct_dependencies, all_dependencies) if @formatter == "auto" + + if specified_formatter + @formatter = specified_formatter + + if specified_formatter != "auto" + notifications << Notification.window_log_message("Using formatter specified by user: #{@formatter}") + end + end + + if @formatter == "auto" + @formatter = detect_formatter(direct_dependencies, all_dependencies) + notifications << Notification.window_log_message("Auto detected formatter: #{@formatter}") + end specified_linters = options.dig(:initializationOptions, :linters) @linters = specified_linters || detect_linters(direct_dependencies, all_dependencies) + + notifications << if specified_linters + Notification.window_log_message("Using linters specified by user: #{@linters.join(", ")}") + else + Notification.window_log_message("Auto detected linters: #{@linters.join(", ")}") + end + @test_library = detect_test_library(direct_dependencies) + notifications << Notification.window_log_message("Detected test library: #{@test_library}") + @has_type_checker = detect_typechecker(direct_dependencies) + if @has_type_checker + notifications << Notification.window_log_message( + "Ruby LSP detected this is a Sorbet project and will defer to the Sorbet LSP for some functionality", + ) + end encodings = options.dig(:capabilities, :general, :positionEncodings) @encoding = if !encodings || encodings.empty? @@ -91,6 +118,8 @@ def apply_options(options) @experimental_features = options.dig(:initializationOptions, :experimentalFeaturesEnabled) || false @type_inferrer.experimental_features = @experimental_features + + notifications end sig { returns(String) } @@ -163,16 +192,7 @@ def detect_test_library(dependencies) def detect_typechecker(dependencies) return false if ENV["RUBY_LSP_BYPASS_TYPECHECKER"] - # We can't read the env from within `Bundle.with_original_env` so we need to set it here. - ruby_lsp_env_is_test = (ENV["RUBY_LSP_ENV"] == "test") - Bundler.with_original_env do - sorbet_static_detected = dependencies.any?(/^sorbet-static/) - # Don't show message while running tests, since it's noisy - if sorbet_static_detected && !ruby_lsp_env_is_test - $stderr.puts("Ruby LSP detected this is a Sorbet project so will defer to Sorbet LSP for some functionality") - end - sorbet_static_detected - end + dependencies.any?(/^sorbet-static/) rescue Bundler::GemfileNotFound false end diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index 3cf51754b..7e1bfd125 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -19,10 +19,10 @@ def initialize(test_mode: false) def process_message(message) case message[:method] when "initialize" - $stderr.puts("Initializing Ruby LSP v#{VERSION}...") + send_log_message("Initializing Ruby LSP v#{VERSION}...") run_initialize(message) when "initialized" - $stderr.puts("Finished initializing Ruby LSP!") unless @test_mode + send_log_message("Finished initializing Ruby LSP!") unless @test_mode run_initialized when "textDocument/didOpen" text_document_did_open(message) @@ -121,12 +121,20 @@ def process_message(message) end end - $stderr.puts("Error processing #{message[:method]}: #{e.full_message}") + send_log_message("Error processing #{message[:method]}: #{e.full_message}", type: Constant::MessageType::ERROR) end sig { void } def load_addons - Addon.load_addons(@global_state, @outgoing_queue) + errors = Addon.load_addons(@global_state, @outgoing_queue) + + if errors.any? + send_log_message( + "Error loading addons:\n\n#{errors.map(&:full_message).join("\n\n")}", + type: Constant::MessageType::WARNING, + ) + end + errored_addons = Addon.addons.select(&:error?) if errored_addons.any? @@ -140,7 +148,12 @@ def load_addons ), ) - $stderr.puts(errored_addons.map(&:errors_details).join("\n\n")) unless @test_mode + unless @test_mode + send_log_message( + errored_addons.map(&:errors_details).join("\n\n"), + type: Constant::MessageType::WARNING, + ) + end end end @@ -149,7 +162,7 @@ def load_addons sig { params(message: T::Hash[Symbol, T.untyped]).void } def run_initialize(message) options = message[:params] - @global_state.apply_options(options) + global_state_notifications = @global_state.apply_options(options) client_name = options.dig(:clientInfo, :name) @store.client_name = client_name if client_name @@ -258,6 +271,8 @@ def run_initialize(message) process_indexing_configuration(options.dig(:initializationOptions, :indexing)) begin_progress("indexing-progress", "Ruby LSP: indexing files") + + global_state_notifications.each { |notification| send_message(notification) } end sig { void } diff --git a/lib/ruby_lsp/utils.rb b/lib/ruby_lsp/utils.rb index ecdeea048..e55050937 100644 --- a/lib/ruby_lsp/utils.rb +++ b/lib/ruby_lsp/utils.rb @@ -53,6 +53,7 @@ def to_hash; end class Notification < Message class << self extend T::Sig + sig { params(message: String).returns(Notification) } def window_show_error(message) new( @@ -63,6 +64,14 @@ def window_show_error(message) ), ) end + + sig { params(message: String, type: Integer).returns(Notification) } + def window_log_message(message, type: Constant::MessageType::LOG) + new( + method: "window/logMessage", + params: Interface::LogMessageParams.new(type: type, message: message), + ) + end end extend T::Sig @@ -122,6 +131,9 @@ class Result sig { returns(T.untyped) } attr_reader :response + sig { returns(Integer) } + attr_reader :id + sig { params(id: Integer, response: T.untyped).void } def initialize(id:, response:) @id = id diff --git a/test/server_test.rb b/test/server_test.rb index 1c64fff32..9fcf5d54b 100644 --- a/test/server_test.rb +++ b/test/server_test.rb @@ -24,7 +24,8 @@ def test_initialize_enabled_features_with_array }) end - hash = JSON.parse(@server.pop_response.response.to_json) + result = find_message(RubyLsp::Result, id: 1) + hash = JSON.parse(result.response.to_json) capabilities = hash["capabilities"] # TextSynchronization + encodings + semanticHighlighting + experimental @@ -44,7 +45,8 @@ def test_initialize_enabled_features_with_hash }) end - hash = JSON.parse(@server.pop_response.response.to_json) + result = find_message(RubyLsp::Result, id: 1) + hash = JSON.parse(result.response.to_json) capabilities = hash["capabilities"] # Only semantic highlighting is turned off because all others default to true when configuring with a hash @@ -63,7 +65,8 @@ def test_initialize_enabled_features_with_no_configuration }) end - hash = JSON.parse(@server.pop_response.response.to_json) + result = find_message(RubyLsp::Result, id: 1) + hash = JSON.parse(result.response.to_json) capabilities = hash["capabilities"] # All features are enabled by default @@ -82,7 +85,8 @@ def test_initialize_defaults_to_utf_8_if_present }) end - hash = JSON.parse(@server.pop_response.response.to_json) + result = find_message(RubyLsp::Result, id: 1) + hash = JSON.parse(result.response.to_json) # All features are enabled by default assert_includes("utf-8", hash.dig("capabilities", "positionEncoding")) @@ -100,7 +104,8 @@ def test_initialize_uses_utf_16_if_utf_8_is_not_present }) end - hash = JSON.parse(@server.pop_response.response.to_json) + result = find_message(RubyLsp::Result, id: 1) + hash = JSON.parse(result.response.to_json) # All features are enabled by default assert_includes("utf-16", hash.dig("capabilities", "positionEncoding")) @@ -118,7 +123,8 @@ def test_initialize_uses_utf_16_if_no_encodings_are_specified }) end - hash = JSON.parse(@server.pop_response.response.to_json) + result = find_message(RubyLsp::Result, id: 1) + hash = JSON.parse(result.response.to_json) # All features are enabled by default assert_includes("utf-16", hash.dig("capabilities", "positionEncoding")) @@ -136,7 +142,8 @@ def test_server_info_includes_version }) end - hash = JSON.parse(@server.pop_response.response.to_json) + result = find_message(RubyLsp::Result, id: 1) + hash = JSON.parse(result.response.to_json) assert_equal(RubyLsp::VERSION, hash.dig("serverInfo", "version")) end @@ -153,7 +160,8 @@ def test_server_info_includes_formatter }) end - hash = JSON.parse(@server.pop_response.response.to_json) + result = find_message(RubyLsp::Result, id: 1) + hash = JSON.parse(result.response.to_json) assert_equal("rubocop", hash.dig("formatter")) end @@ -218,11 +226,6 @@ def test_returns_nil_diagnostics_and_formatting_for_files_outside_workspace }) end - # File watching, progress notifications and initialize response - @server.pop_response - @server.pop_response - @server.pop_response - @server.process_message({ id: 2, method: "textDocument/formatting", @@ -231,7 +234,8 @@ def test_returns_nil_diagnostics_and_formatting_for_files_outside_workspace }, }) - assert_nil(@server.pop_response.response) + result = find_message(RubyLsp::Result, id: 2) + assert_nil(result.response) @server.process_message({ id: 3, @@ -241,7 +245,8 @@ def test_returns_nil_diagnostics_and_formatting_for_files_outside_workspace }, }) - assert_nil(@server.pop_response.response) + result = find_message(RubyLsp::Result, id: 3) + assert_nil(result.response) end def test_did_close_clears_diagnostics @@ -331,9 +336,7 @@ def test_handles_invalid_configuration @server.process_message(id: 1, method: "initialize", params: {}) end - @server.pop_response - notification = @server.pop_response - assert_equal("window/showMessage", notification.method) + notification = find_message(RubyLsp::Notification, "window/showMessage") assert_match( /Syntax error while loading configuration/, T.cast(notification.params, RubyLsp::Interface::ShowMessageParams).message, @@ -355,14 +358,8 @@ def test_shows_error_if_formatter_set_to_rubocop_but_rubocop_not_available assert_equal("none", @server.global_state.formatter) - # Remove the initialization notifications - @server.pop_response - @server.pop_response - @server.pop_response - - notification = @server.pop_response + notification = find_message(RubyLsp::Notification, "window/showMessage") - assert_equal("window/showMessage", notification.method) assert_equal( "Ruby LSP formatter is set to `rubocop` but RuboCop was not found in the Gemfile or gemspec.", T.cast(notification.params, RubyLsp::Interface::ShowMessageParams).message, @@ -395,7 +392,7 @@ def test_workspace_dependencies def test_backtrace_is_printed_to_stderr_on_exceptions @server.expects(:workspace_dependencies).raises(StandardError, "boom") - _stdout, stderr = capture_io do + capture_io do @server.process_message({ id: 1, method: "rubyLsp/workspace/dependencies", @@ -403,8 +400,11 @@ def test_backtrace_is_printed_to_stderr_on_exceptions }) end - assert_match(/boom/, stderr) - assert_match(%r{ruby-lsp/lib/ruby_lsp/server\.rb:\d+:in `process_message'}, stderr) + log = find_message(RubyLsp::Notification, "window/logMessage") + content = log.params.message + + assert_match(/boom/, content) + assert_match(%r{ruby-lsp/lib/ruby_lsp/server\.rb:\d+:in `process_message'}, content) end def test_changed_file_only_indexes_ruby @@ -595,4 +595,28 @@ def name def deactivate; end end end + + sig do + params( + desired_class: Class, + desired_method: T.nilable(String), + id: T.nilable(Integer), + ).returns(T.untyped) + end + def find_message(desired_class, desired_method = nil, id: nil) + message = T.let( + @server.pop_response, T.any( + RubyLsp::Result, + RubyLsp::Message, + RubyLsp::Error, + ) + ) + + until message.is_a?(desired_class) && (!desired_method || T.unsafe(message).method == desired_method) && + (!id || T.unsafe(message).id == id) + message = @server.pop_response + end + + message + end end