diff --git a/SERVER_EXTENSIONS.md b/SERVER_EXTENSIONS.md index ec5d34bd4..a99c1f2d5 100644 --- a/SERVER_EXTENSIONS.md +++ b/SERVER_EXTENSIONS.md @@ -134,7 +134,7 @@ module RubyLsp ResponseType = type_member { { fixed: T.nilable(::RubyLsp::Interface::Hover) } } sig { override.returns(ResponseType) } - attr_reader :response + attr_reader :_response # Listeners are initialized with the EventEmitter. This object is used by the Ruby LSP to emit the events when it # finds nodes during AST analysis. Listeners must register which nodes they want to handle with the emitter (see @@ -145,7 +145,7 @@ module RubyLsp def initialize(config, emitter, message_queue) super - @response = T.let(nil, ResponseType) + @_response = T.let(nil, ResponseType) @config = config # Register that this listener will handle `on_const` events (i.e.: whenever a constant is found in the code) @@ -159,7 +159,7 @@ module RubyLsp # Certain helpers are made available to listeners to build LSP responses. The classes under `RubyLsp::Interface` # are generally used to build responses and they match exactly what the specification requests. contents = RubyLsp::Interface::MarkupContent.new(kind: "markdown", value: "Hello!") - @response = RubyLsp::Interface::Hover.new(range: range_from_syntax_tree_node(node), contents: contents) + @_response = RubyLsp::Interface::Hover.new(range: range_from_syntax_tree_node(node), contents: contents) end end end diff --git a/lib/ruby_lsp/check_docs.rb b/lib/ruby_lsp/check_docs.rb index 12a13f2e1..d18e62aec 100644 --- a/lib/ruby_lsp/check_docs.rb +++ b/lib/ruby_lsp/check_docs.rb @@ -53,7 +53,8 @@ def run_task # documented features = ObjectSpace.each_object(Class).filter_map do |k| klass = T.unsafe(k) - klass if klass < RubyLsp::Requests::BaseRequest || klass < RubyLsp::Listener + klass if klass < RubyLsp::Requests::BaseRequest || + (klass < RubyLsp::Listener && klass != RubyLsp::ExtensibleListener) end missing_docs = T.let(Hash.new { |h, k| h[k] = [] }, T::Hash[String, T::Array[String]]) diff --git a/lib/ruby_lsp/executor.rb b/lib/ruby_lsp/executor.rb index 3d68f3298..d787bf9fd 100644 --- a/lib/ruby_lsp/executor.rb +++ b/lib/ruby_lsp/executor.rb @@ -103,9 +103,6 @@ def run(request) semantic_highlighting = Requests::SemanticHighlighting.new(emitter, @message_queue) emitter.visit(document.tree) if document.parsed? - code_lens.merge_external_listeners_responses! - document_symbol.merge_external_listeners_responses! - # Store all responses retrieve in this round of visits in the cache and then return the response for the request # we actually received document.cache_set("textDocument/documentSymbol", document_symbol.response) @@ -299,7 +296,6 @@ def hover(uri, position) # Emit events for all listeners emitter.emit_for_target(target) - hover.merge_external_listeners_responses! hover.response end diff --git a/lib/ruby_lsp/listener.rb b/lib/ruby_lsp/listener.rb index ccf713d43..0f73a4327 100644 --- a/lib/ruby_lsp/listener.rb +++ b/lib/ruby_lsp/listener.rb @@ -18,13 +18,42 @@ class Listener def initialize(emitter, message_queue) @emitter = emitter @message_queue = message_queue - @external_listeners = T.let([], T::Array[RubyLsp::Listener[ResponseType]]) + end + + sig { returns(ResponseType) } + def response + _response end # Override this method with an attr_reader that returns the response of your listener. The listener should # accumulate results in a @response variable and then provide the reader so that it is accessible sig { abstract.returns(ResponseType) } - def response; end + def _response; end + end + + # ExtensibleListener is an abstract class to be used by requests that accept extensions. + class ExtensibleListener < Listener + extend T::Sig + extend T::Generic + + ResponseType = type_member + + abstract! + + # When inheriting from ExtensibleListener, the `super` of constructor must be called **after** the subclass's own + # ivars have been initialized. This is because the constructor of ExtensibleListener calls + # `initialize_external_listener` which may depend on the subclass's ivars. + sig { params(emitter: EventEmitter, message_queue: Thread::Queue).void } + def initialize(emitter, message_queue) + super + @response_merged = T.let(false, T::Boolean) + @external_listeners = T.let( + Extension.extensions.filter_map do |ext| + initialize_external_listener(ext) + end, + T::Array[RubyLsp::Listener[ResponseType]], + ) + end # Merge responses from all external listeners into the base listener's response. We do this to return a single # response to the editor including the results of all extensions @@ -33,11 +62,21 @@ def merge_external_listeners_responses! @external_listeners.each { |l| merge_response!(l) } end + sig { returns(ResponseType) } + def response + merge_external_listeners_responses! unless @response_merged + super + end + + sig do + abstract.params(extension: RubyLsp::Extension).returns(T.nilable(RubyLsp::Listener[ResponseType])) + end + def initialize_external_listener(extension); end + # Does nothing by default. Requests that accept extensions should override this method to define how to merge # responses coming from external listeners - sig { overridable.params(other: Listener[T.untyped]).returns(T.self_type) } + sig { abstract.params(other: Listener[T.untyped]).returns(T.self_type) } def merge_response!(other) - self end end end diff --git a/lib/ruby_lsp/requests/code_lens.rb b/lib/ruby_lsp/requests/code_lens.rb index 5dc87cae0..c8ec78f2f 100644 --- a/lib/ruby_lsp/requests/code_lens.rb +++ b/lib/ruby_lsp/requests/code_lens.rb @@ -18,7 +18,7 @@ module Requests # class Test < Minitest::Test # end # ``` - class CodeLens < Listener + class CodeLens < ExtensibleListener extend T::Sig extend T::Generic @@ -29,23 +29,20 @@ class CodeLens < Listener SUPPORTED_TEST_LIBRARIES = T.let(["minitest", "test-unit"], T::Array[String]) sig { override.returns(ResponseType) } - attr_reader :response + attr_reader :_response sig { params(uri: URI::Generic, emitter: EventEmitter, message_queue: Thread::Queue, test_library: String).void } def initialize(uri, emitter, message_queue, test_library) - super(emitter, message_queue) - @uri = T.let(uri, URI::Generic) - @external_listeners.concat( - Extension.extensions.filter_map { |ext| ext.create_code_lens_listener(uri, emitter, message_queue) }, - ) @test_library = T.let(test_library, String) - @response = T.let([], ResponseType) + @_response = T.let([], ResponseType) @path = T.let(uri.to_standardized_path, T.nilable(String)) # visibility_stack is a stack of [current_visibility, previous_visibility] @visibility_stack = T.let([["public", "public"]], T::Array[T::Array[T.nilable(String)]]) @class_stack = T.let([], T::Array[String]) + super(emitter, message_queue) + emitter.register( self, :on_class, @@ -149,9 +146,14 @@ def on_vcall(node) end end + sig { override.params(extension: RubyLsp::Extension).returns(T.nilable(Listener[ResponseType])) } + def initialize_external_listener(extension) + extension.create_code_lens_listener(@uri, @emitter, @message_queue) + end + sig { override.params(other: Listener[ResponseType]).returns(T.self_type) } def merge_response!(other) - @response.concat(other.response) + @_response.concat(other.response) self end @@ -174,7 +176,7 @@ def add_test_code_lens(node, name:, command:, kind:) }, ] - @response << create_code_lens( + @_response << create_code_lens( node, title: "Run", command_name: "rubyLsp.runTest", @@ -182,7 +184,7 @@ def add_test_code_lens(node, name:, command:, kind:) data: { type: "test", kind: kind }, ) - @response << create_code_lens( + @_response << create_code_lens( node, title: "Run In Terminal", command_name: "rubyLsp.runTestInTerminal", @@ -190,7 +192,7 @@ def add_test_code_lens(node, name:, command:, kind:) data: { type: "test_in_terminal", kind: kind }, ) - @response << create_code_lens( + @_response << create_code_lens( node, title: "Debug", command_name: "rubyLsp.debugTest", @@ -239,7 +241,7 @@ def generate_test_command(class_name:, method_name: nil) sig { params(node: SyntaxTree::Command, remote: String).void } def add_open_gem_remote_code_lens(node, remote) - @response << create_code_lens( + @_response << create_code_lens( node, title: "Open remote", command_name: "rubyLsp.openLink", diff --git a/lib/ruby_lsp/requests/definition.rb b/lib/ruby_lsp/requests/definition.rb index a2488bdd8..80841f098 100644 --- a/lib/ruby_lsp/requests/definition.rb +++ b/lib/ruby_lsp/requests/definition.rb @@ -24,7 +24,7 @@ class Definition < Listener ResponseType = type_member { { fixed: T.nilable(T.any(T::Array[Interface::Location], Interface::Location)) } } sig { override.returns(ResponseType) } - attr_reader :response + attr_reader :_response sig do params( @@ -41,7 +41,7 @@ def initialize(uri, nesting, index, emitter, message_queue) @uri = uri @nesting = nesting @index = index - @response = T.let(nil, ResponseType) + @_response = T.let(nil, ResponseType) emitter.register(self, :on_command, :on_const, :on_const_path_ref) end @@ -76,7 +76,7 @@ def on_command(node) if entry candidate = entry.full_path - @response = Interface::Location.new( + @_response = Interface::Location.new( uri: URI::Generic.from_path(path: candidate).to_s, range: Interface::Range.new( start: Interface::Position.new(line: 0, character: 0), @@ -90,7 +90,7 @@ def on_command(node) current_folder = path ? Pathname.new(CGI.unescape(path)).dirname : Dir.pwd candidate = File.expand_path(File.join(current_folder, required_file)) - @response = Interface::Location.new( + @_response = Interface::Location.new( uri: URI::Generic.from_path(path: candidate).to_s, range: Interface::Range.new( start: Interface::Position.new(line: 0, character: 0), @@ -113,7 +113,7 @@ def find_in_index(value) nil end - @response = entries.filter_map do |entry| + @_response = entries.filter_map do |entry| location = entry.location # If the project has Sorbet, then we only want to handle go to definition for constants defined in gems, as an # additional behavior on top of jumping to RBIs. Sorbet can already handle go to definition for all constants diff --git a/lib/ruby_lsp/requests/document_highlight.rb b/lib/ruby_lsp/requests/document_highlight.rb index bb0f1dd27..da25948f2 100644 --- a/lib/ruby_lsp/requests/document_highlight.rb +++ b/lib/ruby_lsp/requests/document_highlight.rb @@ -28,7 +28,7 @@ class DocumentHighlight < Listener ResponseType = type_member { { fixed: T::Array[Interface::DocumentHighlight] } } sig { override.returns(ResponseType) } - attr_reader :response + attr_reader :_response sig do params( @@ -41,7 +41,7 @@ class DocumentHighlight < Listener def initialize(target, parent, emitter, message_queue) super(emitter, message_queue) - @response = T.let([], T::Array[Interface::DocumentHighlight]) + @_response = T.let([], T::Array[Interface::DocumentHighlight]) return unless target && parent @@ -83,7 +83,7 @@ def on_node(node) sig { params(match: Support::HighlightTarget::HighlightMatch).void } def add_highlight(match) range = range_from_syntax_tree_node(match.node) - @response << Interface::DocumentHighlight.new(range: range, kind: match.type) + @_response << Interface::DocumentHighlight.new(range: range, kind: match.type) end end end diff --git a/lib/ruby_lsp/requests/document_link.rb b/lib/ruby_lsp/requests/document_link.rb index 69f69b55e..9b7be56a7 100644 --- a/lib/ruby_lsp/requests/document_link.rb +++ b/lib/ruby_lsp/requests/document_link.rb @@ -73,7 +73,7 @@ def gem_paths end sig { override.returns(ResponseType) } - attr_reader :response + attr_reader :_response sig { params(uri: URI::Generic, emitter: EventEmitter, message_queue: Thread::Queue).void } def initialize(uri, emitter, message_queue) @@ -84,7 +84,7 @@ def initialize(uri, emitter, message_queue) path = uri.to_standardized_path version_match = path ? /(?<=%40)[\d.]+(?=\.rbi$)/.match(path) : nil @gem_version = T.let(version_match && version_match[0], T.nilable(String)) - @response = T.let([], T::Array[Interface::DocumentLink]) + @_response = T.let([], T::Array[Interface::DocumentLink]) emitter.register(self, :on_comment) end @@ -99,7 +99,7 @@ def on_comment(node) file_path = self.class.gem_paths.dig(uri.gem_name, gem_version, CGI.unescape(uri.path)) return if file_path.nil? - @response << Interface::DocumentLink.new( + @_response << Interface::DocumentLink.new( range: range_from_syntax_tree_node(node), target: "file://#{file_path}##{uri.line_number}", tooltip: "Jump to #{file_path}##{uri.line_number}", diff --git a/lib/ruby_lsp/requests/document_symbol.rb b/lib/ruby_lsp/requests/document_symbol.rb index e64e73ecf..e0cf36e3f 100644 --- a/lib/ruby_lsp/requests/document_symbol.rb +++ b/lib/ruby_lsp/requests/document_symbol.rb @@ -26,7 +26,7 @@ module Requests # end # end # ``` - class DocumentSymbol < Listener + class DocumentSymbol < ExtensibleListener extend T::Sig extend T::Generic @@ -47,22 +47,18 @@ def initialize end sig { override.returns(T::Array[Interface::DocumentSymbol]) } - attr_reader :response + attr_reader :_response sig { params(emitter: EventEmitter, message_queue: Thread::Queue).void } def initialize(emitter, message_queue) - super - @root = T.let(SymbolHierarchyRoot.new, SymbolHierarchyRoot) - @response = T.let(@root.children, T::Array[Interface::DocumentSymbol]) + @_response = T.let(@root.children, T::Array[Interface::DocumentSymbol]) @stack = T.let( [@root], T::Array[T.any(SymbolHierarchyRoot, Interface::DocumentSymbol)], ) - @external_listeners.concat( - Extension.extensions.filter_map { |ext| ext.create_document_symbol_listener(emitter, message_queue) }, - ) + super emitter.register( self, @@ -79,10 +75,15 @@ def initialize(emitter, message_queue) ) end + sig { override.params(extension: RubyLsp::Extension).returns(T.nilable(Listener[ResponseType])) } + def initialize_external_listener(extension) + extension.create_document_symbol_listener(@emitter, @message_queue) + end + # Merges responses from other listeners sig { override.params(other: Listener[ResponseType]).returns(T.self_type) } def merge_response!(other) - @response.concat(other.response) + @_response.concat(other.response) self end diff --git a/lib/ruby_lsp/requests/hover.rb b/lib/ruby_lsp/requests/hover.rb index d3d6e3a41..d7d5c6a84 100644 --- a/lib/ruby_lsp/requests/hover.rb +++ b/lib/ruby_lsp/requests/hover.rb @@ -13,7 +13,7 @@ module Requests # ```ruby # String # -> Hovering over the class reference will show all declaration locations and the documentation # ``` - class Hover < Listener + class Hover < ExtensibleListener extend T::Sig extend T::Generic @@ -30,7 +30,7 @@ class Hover < Listener ) sig { override.returns(ResponseType) } - attr_reader :response + attr_reader :_response sig do params( @@ -41,27 +41,29 @@ class Hover < Listener ).void end def initialize(index, nesting, emitter, message_queue) - super(emitter, message_queue) - @nesting = nesting @index = index - @external_listeners.concat( - Extension.extensions.filter_map { |ext| ext.create_hover_listener(emitter, message_queue) }, - ) - @response = T.let(nil, ResponseType) + @_response = T.let(nil, ResponseType) + + super(emitter, message_queue) emitter.register(self, :on_const_path_ref, :on_const) end + sig { override.params(extension: RubyLsp::Extension).returns(T.nilable(Listener[ResponseType])) } + def initialize_external_listener(extension) + extension.create_hover_listener(@emitter, @message_queue) + end + # Merges responses from other hover listeners sig { override.params(other: Listener[ResponseType]).returns(T.self_type) } def merge_response!(other) other_response = other.response return self unless other_response - if @response.nil? - @response = other.response + if @_response.nil? + @_response = other.response else - @response.contents.value << "\n\n" << other_response.contents.value + @_response.contents.value << "\n\n" << other_response.contents.value end self @@ -111,7 +113,7 @@ def generate_hover(name, node) kind: "markdown", value: "#{title}\n\n**Definitions**: #{definitions.join(" | ")}\n\n#{content}", ) - @response = Interface::Hover.new(range: range_from_syntax_tree_node(node), contents: contents) + @_response = Interface::Hover.new(range: range_from_syntax_tree_node(node), contents: contents) end end end diff --git a/lib/ruby_lsp/requests/inlay_hints.rb b/lib/ruby_lsp/requests/inlay_hints.rb index 752580899..cbe654983 100644 --- a/lib/ruby_lsp/requests/inlay_hints.rb +++ b/lib/ruby_lsp/requests/inlay_hints.rb @@ -27,13 +27,13 @@ class InlayHints < Listener RESCUE_STRING_LENGTH = T.let("rescue".length, Integer) sig { override.returns(ResponseType) } - attr_reader :response + attr_reader :_response sig { params(range: T::Range[Integer], emitter: EventEmitter, message_queue: Thread::Queue).void } def initialize(range, emitter, message_queue) super(emitter, message_queue) - @response = T.let([], ResponseType) + @_response = T.let([], ResponseType) @range = range emitter.register(self, :on_rescue) @@ -47,7 +47,7 @@ def on_rescue(node) loc = node.location return unless visible?(node, @range) - @response << Interface::InlayHint.new( + @_response << Interface::InlayHint.new( position: { line: loc.start_line - 1, character: loc.start_column + RESCUE_STRING_LENGTH }, label: "StandardError", padding_left: true, diff --git a/lib/ruby_lsp/requests/path_completion.rb b/lib/ruby_lsp/requests/path_completion.rb index 3adcffaef..246695e2d 100644 --- a/lib/ruby_lsp/requests/path_completion.rb +++ b/lib/ruby_lsp/requests/path_completion.rb @@ -20,12 +20,12 @@ class PathCompletion < Listener ResponseType = type_member { { fixed: T::Array[Interface::CompletionItem] } } sig { override.returns(ResponseType) } - attr_reader :response + attr_reader :_response sig { params(index: RubyIndexer::Index, emitter: EventEmitter, message_queue: Thread::Queue).void } def initialize(index, emitter, message_queue) super(emitter, message_queue) - @response = T.let([], ResponseType) + @_response = T.let([], ResponseType) @index = index emitter.register(self, :on_tstring_content) @@ -34,7 +34,7 @@ def initialize(index, emitter, message_queue) sig { params(node: SyntaxTree::TStringContent).void } def on_tstring_content(node) @index.search_require_paths(node.value).map!(&:require_path).sort!.each do |path| - @response << build_completion(T.must(path), node) + @_response << build_completion(T.must(path), node) end end diff --git a/lib/ruby_lsp/requests/semantic_highlighting.rb b/lib/ruby_lsp/requests/semantic_highlighting.rb index a08512d34..d7f840e92 100644 --- a/lib/ruby_lsp/requests/semantic_highlighting.rb +++ b/lib/ruby_lsp/requests/semantic_highlighting.rb @@ -105,7 +105,7 @@ def initialize(location:, length:, type:, modifier:) end sig { override.returns(ResponseType) } - attr_reader :response + attr_reader :_response sig do params( @@ -117,7 +117,7 @@ def initialize(location:, length:, type:, modifier:) def initialize(emitter, message_queue, range: nil) super(emitter, message_queue) - @response = T.let([], ResponseType) + @_response = T.let([], ResponseType) @range = range @special_methods = T.let(nil, T.nilable(T::Array[String])) @@ -174,7 +174,7 @@ def on_const(node) # When finding a module or class definition, we will have already pushed a token related to this constant. We # need to look at the previous two tokens and if they match this locatione exactly, avoid pushing another token # on top of the previous one - return if @response.last(2).any? { |token| token.location == node.location } + return if @_response.last(2).any? { |token| token.location == node.location } add_token(node.location, :namespace) end @@ -327,7 +327,7 @@ def on_module(node) def add_token(location, type, modifiers = []) length = location.end_char - location.start_char modifiers_indices = modifiers.filter_map { |modifier| TOKEN_MODIFIERS[modifier] } - @response.push( + @_response.push( SemanticToken.new( location: location, length: length, diff --git a/test/requests/code_lens_expectations_test.rb b/test/requests/code_lens_expectations_test.rb index 4e895420d..c8145ada5 100644 --- a/test/requests/code_lens_expectations_test.rb +++ b/test/requests/code_lens_expectations_test.rb @@ -127,8 +127,10 @@ def name end def create_code_lens_listener(uri, emitter, message_queue) + raise "uri can't be nil" unless uri + klass = Class.new(RubyLsp::Listener) do - attr_reader :response + attr_reader :_response def initialize(uri, emitter, message_queue) super(emitter, message_queue) @@ -138,7 +140,7 @@ def initialize(uri, emitter, message_queue) def on_class(node) T.bind(self, RubyLsp::Listener[T.untyped]) - @response = [RubyLsp::Interface::CodeLens.new( + @_response = [RubyLsp::Interface::CodeLens.new( range: range_from_syntax_tree_node(node), command: RubyLsp::Interface::Command.new( title: "Run #{node.constant.constant.value}", diff --git a/test/requests/document_symbol_expectations_test.rb b/test/requests/document_symbol_expectations_test.rb index a225fef33..473ebf69d 100644 --- a/test/requests/document_symbol_expectations_test.rb +++ b/test/requests/document_symbol_expectations_test.rb @@ -36,7 +36,7 @@ def name def create_document_symbol_listener(emitter, message_queue) klass = Class.new(RubyLsp::Listener) do - attr_reader :response + attr_reader :_response def initialize(emitter, message_queue) super @@ -51,7 +51,7 @@ def on_command(node) first_argument = node.arguments.parts.first test_name = first_argument.parts.map(&:value).join - @response = [RubyLsp::Interface::DocumentSymbol.new( + @_response = [RubyLsp::Interface::DocumentSymbol.new( name: test_name, kind: LanguageServer::Protocol::Constant::SymbolKind::METHOD, selection_range: range_from_syntax_tree_node(node), diff --git a/test/requests/hover_expectations_test.rb b/test/requests/hover_expectations_test.rb index f0601f1f8..4d17f8c57 100644 --- a/test/requests/hover_expectations_test.rb +++ b/test/requests/hover_expectations_test.rb @@ -63,7 +63,7 @@ def name def create_hover_listener(emitter, message_queue) klass = Class.new(RubyLsp::Listener) do - attr_reader :response + attr_reader :_response def initialize(emitter, message_queue) super @@ -76,7 +76,7 @@ def on_const(node) kind: "markdown", value: "Hello from middleware: #{node.value}", ) - @response = RubyLsp::Interface::Hover.new(range: range_from_syntax_tree_node(node), contents: contents) + @_response = RubyLsp::Interface::Hover.new(range: range_from_syntax_tree_node(node), contents: contents) end end