diff --git a/lib/ruby_lsp/check_docs.rb b/lib/ruby_lsp/check_docs.rb index d7b490921..6cff3d50d 100644 --- a/lib/ruby_lsp/check_docs.rb +++ b/lib/ruby_lsp/check_docs.rb @@ -53,7 +53,7 @@ def run_task # documented features = ObjectSpace.each_object(Class).select do |k| klass = T.unsafe(k) - klass < Requests::Request && klass != Listener && klass != ExtensibleListener + klass < Requests::Request 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 74c35448b..f82e580c2 100644 --- a/lib/ruby_lsp/executor.rb +++ b/lib/ruby_lsp/executor.rb @@ -143,13 +143,30 @@ def run(request) nil end when "textDocument/documentHighlight" - document_highlight(uri, request.dig(:params, :position)) + dispatcher = Prism::Dispatcher.new + document = @store.get(uri) + request = Requests::DocumentHighlight.new(document, request.dig(:params, :position), dispatcher) + dispatcher.dispatch(document.tree) + request.response when "textDocument/onTypeFormatting" on_type_formatting(uri, request.dig(:params, :position), request.dig(:params, :ch)) when "textDocument/hover" - hover(uri, request.dig(:params, :position)) + dispatcher = Prism::Dispatcher.new + document = @store.get(uri) + Requests::Hover.new( + document, + @index, + request.dig(:params, :position), + dispatcher, + document.typechecker_enabled?, + ).response when "textDocument/inlayHint" - inlay_hint(uri, request.dig(:params, :range)) + hints_configurations = T.must(@store.features_configuration.dig(:inlayHint)) + dispatcher = Prism::Dispatcher.new + document = @store.get(uri) + request = Requests::InlayHints.new(document, request.dig(:params, :range), hints_configurations, dispatcher) + dispatcher.visit(document.tree) + request.response when "textDocument/codeAction" code_action(uri, request.dig(:params, :range), request.dig(:params, :context)) when "codeAction/resolve" @@ -169,11 +186,36 @@ def run(request) nil end when "textDocument/completion" - completion(uri, request.dig(:params, :position)) - when "textDocument/definition" - definition(uri, request.dig(:params, :position)) + dispatcher = Prism::Dispatcher.new + document = @store.get(uri) + Requests::Completion.new( + document, + @index, + request.dig(:params, :position), + document.typechecker_enabled?, + dispatcher, + ).response when "textDocument/signatureHelp" - signature_help(uri, request.dig(:params, :position), request.dig(:params, :context)) + dispatcher = Prism::Dispatcher.new + document = @store.get(uri) + + Requests::SignatureHelp.new( + document, + @index, + request.dig(:params, :position), + request.dig(:params, :context), + dispatcher, + ).response + when "textDocument/definition" + dispatcher = Prism::Dispatcher.new + document = @store.get(uri) + Requests::Definition.new( + document, + @index, + request.dig(:params, :position), + dispatcher, + document.typechecker_enabled?, + ).response when "workspace/didChangeWatchedFiles" did_change_watched_files(request.dig(:params, :changes)) when "workspace/symbol" @@ -243,38 +285,6 @@ def perform_initial_indexing end end - sig do - params( - uri: URI::Generic, - position: T::Hash[Symbol, T.untyped], - context: T.nilable(T::Hash[Symbol, T.untyped]), - ).returns(T.any(T.nilable(Interface::SignatureHelp), T::Hash[Symbol, T.untyped])) - end - def signature_help(uri, position, context) - current_signature = context && context[:activeSignatureHelp] - document = @store.get(uri) - target, parent, nesting = document.locate_node( - { line: position[:line], character: position[:character] - 2 }, - node_types: [Prism::CallNode], - ) - - # If we're typing a nested method call (e.g.: `foo(bar)`), then we may end up locating `bar` as the target method - # call incorrectly. To correct that, we check if there's an active signature with the same name as the parent node - # and then replace the target - if current_signature && parent.is_a?(Prism::CallNode) - active_signature = current_signature[:activeSignature] || 0 - - if current_signature.dig(:signatures, active_signature, :label)&.start_with?(parent.message) - target = parent - end - end - - dispatcher = Prism::Dispatcher.new - listener = Requests::SignatureHelp.new(nesting, @index, dispatcher) - dispatcher.dispatch_once(target) - listener.response - end - sig { params(query: T.nilable(String)).returns(T::Array[Interface::WorkspaceSymbol]) } def workspace_symbol(query) Requests::WorkspaceSymbol.new(query, @index).response @@ -285,56 +295,6 @@ def show_syntax_tree(uri, range) { ast: Requests::ShowSyntaxTree.new(@store.get(uri), range).response } end - sig do - params( - uri: URI::Generic, - position: T::Hash[Symbol, T.untyped], - ).returns(T.nilable(T.any(T::Array[Interface::Location], Interface::Location))) - end - def definition(uri, position) - document = @store.get(uri) - target, parent, nesting = document.locate_node( - position, - node_types: [Prism::CallNode, Prism::ConstantReadNode, Prism::ConstantPathNode], - ) - - target = parent if target.is_a?(Prism::ConstantReadNode) && parent.is_a?(Prism::ConstantPathNode) - - dispatcher = Prism::Dispatcher.new - base_listener = Requests::Definition.new(uri, nesting, @index, dispatcher, document.typechecker_enabled?) - dispatcher.dispatch_once(target) - base_listener.response - end - - sig do - params( - uri: URI::Generic, - position: T::Hash[Symbol, T.untyped], - ).returns(T.nilable(Interface::Hover)) - end - def hover(uri, position) - document = @store.get(uri) - target, parent, nesting = document.locate_node( - position, - node_types: Requests::Hover::ALLOWED_TARGETS, - ) - - if (Requests::Hover::ALLOWED_TARGETS.include?(parent.class) && - !Requests::Hover::ALLOWED_TARGETS.include?(target.class)) || - (parent.is_a?(Prism::ConstantPathNode) && target.is_a?(Prism::ConstantReadNode)) - target = parent - end - - # Instantiate all listeners - dispatcher = Prism::Dispatcher.new - hover = Requests::Hover.new(uri, @index, nesting, dispatcher, document.typechecker_enabled?) - - # Emit events for all listeners - dispatcher.dispatch_once(target) - - hover.response - end - sig do params(uri: URI::Generic, content_changes: T::Array[T::Hash[Symbol, T.untyped]], version: Integer).returns(Object) end @@ -404,41 +364,6 @@ def on_type_formatting(uri, position, character) Requests::OnTypeFormatting.new(@store.get(uri), position, character).response end - sig do - params( - uri: URI::Generic, - position: T::Hash[Symbol, T.untyped], - ).returns(T.nilable(T::Array[Interface::DocumentHighlight])) - end - def document_highlight(uri, position) - document = @store.get(uri) - - target, parent = document.locate_node(position) - dispatcher = Prism::Dispatcher.new - listener = Requests::DocumentHighlight.new(target, parent, dispatcher) - dispatcher.visit(document.tree) - listener.response - end - - sig do - params( - uri: URI::Generic, - range: T::Hash[Symbol, T.untyped], - ).returns(T.nilable(T::Array[Interface::InlayHint])) - end - def inlay_hint(uri, range) - document = @store.get(uri) - - start_line = range.dig(:start, :line) - end_line = range.dig(:end, :line) - - dispatcher = Prism::Dispatcher.new - hints_configurations = T.must(@store.features_configuration.dig(:inlayHint)) - listener = Requests::InlayHints.new(start_line..end_line, hints_configurations, dispatcher) - dispatcher.visit(document.tree) - listener.response - end - sig do params( uri: URI::Generic, @@ -509,57 +434,6 @@ def semantic_tokens_range(uri, range) Requests::Support::SemanticTokenEncoder.new.encode(listener.response) end - sig do - params( - uri: URI::Generic, - position: T::Hash[Symbol, T.untyped], - ).returns(T.nilable(T::Array[Interface::CompletionItem])) - end - def completion(uri, position) - document = @store.get(uri) - - # Completion always receives the position immediately after the character that was just typed. Here we adjust it - # back by 1, so that we find the right node - char_position = document.create_scanner.find_char_position(position) - 1 - matched, parent, nesting = document.locate( - document.tree, - char_position, - node_types: [Prism::CallNode, Prism::ConstantReadNode, Prism::ConstantPathNode], - ) - return unless matched && parent - - target = case matched - when Prism::CallNode - message = matched.message - - if message == "require" - args = matched.arguments&.arguments - return if args.nil? || args.is_a?(Prism::ForwardingArgumentsNode) - - argument = args.first - return unless argument.is_a?(Prism::StringNode) - return unless (argument.location.start_offset..argument.location.end_offset).cover?(char_position) - - argument - else - matched - end - when Prism::ConstantReadNode, Prism::ConstantPathNode - if parent.is_a?(Prism::ConstantPathNode) && matched.is_a?(Prism::ConstantReadNode) - parent - else - matched - end - end - - return unless target - - dispatcher = Prism::Dispatcher.new - listener = Requests::Completion.new(@index, nesting, dispatcher, document.typechecker_enabled?) - dispatcher.dispatch_once(target) - listener.response - end - sig { params(id: String, title: String, percentage: Integer).void } def begin_progress(id, title, percentage: 0) return unless @store.supports_progress diff --git a/lib/ruby_lsp/listener.rb b/lib/ruby_lsp/listener.rb index d77960eeb..7b856456f 100644 --- a/lib/ruby_lsp/listener.rb +++ b/lib/ruby_lsp/listener.rb @@ -4,7 +4,7 @@ module RubyLsp # Listener is an abstract class to be used by requests for listening to events emitted when visiting an AST using the # Prism::Dispatcher. - class Listener < Requests::Request + class Listener extend T::Sig extend T::Helpers extend T::Generic @@ -20,7 +20,7 @@ def initialize(dispatcher) @dispatcher = dispatcher end - sig { override.returns(ResponseType) } + sig { returns(ResponseType) } def response _response end @@ -30,54 +30,4 @@ def response sig { abstract.returns(ResponseType) } def _response; end end - - # ExtensibleListener is an abstract class to be used by requests that accept addons. - 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(dispatcher: Prism::Dispatcher).void } - def initialize(dispatcher) - super - @response_merged = T.let(false, T::Boolean) - @external_listeners = T.let( - Addon.addons.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 addons - sig { void } - def merge_external_listeners_responses! - @external_listeners.each { |l| merge_response!(l) } - end - - sig { override.returns(ResponseType) } - def response - merge_external_listeners_responses! unless @response_merged - super - end - - sig do - abstract.params(addon: RubyLsp::Addon).returns(T.nilable(RubyLsp::Listener[ResponseType])) - end - def initialize_external_listener(addon); end - - # Does nothing by default. Requests that accept addons should override this method to define how to merge responses - # coming from external listeners - sig { abstract.params(other: Listener[T.untyped]).returns(T.self_type) } - def merge_response!(other) - end - end - private_constant(:ExtensibleListener) end diff --git a/lib/ruby_lsp/listeners/code_lens.rb b/lib/ruby_lsp/listeners/code_lens.rb new file mode 100644 index 000000000..744d3e597 --- /dev/null +++ b/lib/ruby_lsp/listeners/code_lens.rb @@ -0,0 +1,233 @@ +# typed: strict +# frozen_string_literal: true + +require "shellwords" +require_relative "../listener" + +module RubyLsp + module Listeners + class CodeLens < Listener + extend T::Sig + extend T::Generic + + BASE_COMMAND = T.let( + begin + Bundler.with_original_env { Bundler.default_lockfile } + "bundle exec ruby" + rescue Bundler::GemfileNotFound + "ruby" + end + " -Itest ", + String, + ) + ACCESS_MODIFIERS = T.let([:public, :private, :protected], T::Array[Symbol]) + SUPPORTED_TEST_LIBRARIES = T.let(["minitest", "test-unit"], T::Array[String]) + + ResponseType = type_member { { fixed: T::Array[Interface::CodeLens] } } + + sig { override.returns(ResponseType) } + attr_reader :_response + + sig do + params( + uri: URI::Generic, + lenses_configuration: RequestConfig, + dispatcher: Prism::Dispatcher, + ).void + end + def initialize(uri, lenses_configuration, dispatcher) + @uri = T.let(uri, URI::Generic) + @_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(Symbol)]]) + @class_stack = T.let([], T::Array[String]) + @group_id = T.let(1, Integer) + @group_id_stack = T.let([], T::Array[Integer]) + @lenses_configuration = lenses_configuration + + super(dispatcher) + + dispatcher.register( + self, + :on_class_node_enter, + :on_class_node_leave, + :on_def_node_enter, + :on_call_node_enter, + :on_call_node_leave, + ) + end + + sig { params(node: Prism::ClassNode).void } + def on_class_node_enter(node) + @visibility_stack.push([:public, :public]) + class_name = node.constant_path.slice + @class_stack.push(class_name) + + if @path && class_name.end_with?("Test") + add_test_code_lens( + node, + name: class_name, + command: generate_test_command(class_name: class_name), + kind: :group, + ) + end + + @group_id_stack.push(@group_id) + @group_id += 1 + end + + sig { params(node: Prism::ClassNode).void } + def on_class_node_leave(node) + @visibility_stack.pop + @class_stack.pop + @group_id_stack.pop + end + + sig { params(node: Prism::DefNode).void } + def on_def_node_enter(node) + class_name = @class_stack.last + return unless class_name&.end_with?("Test") + + visibility, _ = @visibility_stack.last + if visibility == :public + method_name = node.name.to_s + if @path && method_name.start_with?("test_") + add_test_code_lens( + node, + name: method_name, + command: generate_test_command(method_name: method_name, class_name: class_name), + kind: :example, + ) + end + end + end + + sig { params(node: Prism::CallNode).void } + def on_call_node_enter(node) + name = node.name + arguments = node.arguments + + # If we found `private` by itself or `private def foo` + if ACCESS_MODIFIERS.include?(name) + if arguments.nil? + @visibility_stack.pop + @visibility_stack.push([name, name]) + elsif arguments.arguments.first.is_a?(Prism::DefNode) + visibility, _ = @visibility_stack.pop + @visibility_stack.push([name, visibility]) + end + + return + end + + if @path&.include?(GEMFILE_NAME) && name == :gem && arguments + return unless @lenses_configuration.enabled?(:gemfileLinks) + + first_argument = arguments.arguments.first + return unless first_argument.is_a?(Prism::StringNode) + + remote = resolve_gem_remote(first_argument) + return unless remote + + add_open_gem_remote_code_lens(node, remote) + end + end + + sig { params(node: Prism::CallNode).void } + def on_call_node_leave(node) + _, prev_visibility = @visibility_stack.pop + @visibility_stack.push([prev_visibility, prev_visibility]) + end + + private + + sig { params(node: Prism::Node, name: String, command: String, kind: Symbol).void } + def add_test_code_lens(node, name:, command:, kind:) + # don't add code lenses if the test library is not supported or unknown + return unless SUPPORTED_TEST_LIBRARIES.include?(DependencyDetector.instance.detected_test_library) && @path + + arguments = [ + @path, + name, + command, + { + start_line: node.location.start_line - 1, + start_column: node.location.start_column, + end_line: node.location.end_line - 1, + end_column: node.location.end_column, + }, + ] + + grouping_data = { group_id: @group_id_stack.last, kind: kind } + grouping_data[:id] = @group_id if kind == :group + + @_response << create_code_lens( + node, + title: "Run", + command_name: "rubyLsp.runTest", + arguments: arguments, + data: { type: "test", **grouping_data }, + ) + + @_response << create_code_lens( + node, + title: "Run In Terminal", + command_name: "rubyLsp.runTestInTerminal", + arguments: arguments, + data: { type: "test_in_terminal", **grouping_data }, + ) + + @_response << create_code_lens( + node, + title: "Debug", + command_name: "rubyLsp.debugTest", + arguments: arguments, + data: { type: "debug", **grouping_data }, + ) + end + + sig { params(gem_name: Prism::StringNode).returns(T.nilable(String)) } + def resolve_gem_remote(gem_name) + spec = Gem::Specification.stubs.find { |gem| gem.name == gem_name.content }&.to_spec + return if spec.nil? + + [spec.homepage, spec.metadata["source_code_uri"]].compact.find do |page| + page.start_with?("https://github.com", "https://gitlab.com") + end + end + + sig { params(class_name: String, method_name: T.nilable(String)).returns(String) } + def generate_test_command(class_name:, method_name: nil) + command = BASE_COMMAND + T.must(@path) + + case DependencyDetector.instance.detected_test_library + when "minitest" + command += if method_name + " --name " + "/#{Shellwords.escape(class_name + "#" + method_name)}/" + else + " --name " + "/#{Shellwords.escape(class_name)}/" + end + when "test-unit" + command += " --testcase " + "/#{Shellwords.escape(class_name)}/" + + if method_name + command += " --name " + Shellwords.escape(method_name) + end + end + + command + end + + sig { params(node: Prism::CallNode, remote: String).void } + def add_open_gem_remote_code_lens(node, remote) + @_response << create_code_lens( + node, + title: "Open remote", + command_name: "rubyLsp.openLink", + arguments: [remote], + data: { type: "link" }, + ) + end + end + end +end diff --git a/lib/ruby_lsp/listeners/completion.rb b/lib/ruby_lsp/listeners/completion.rb new file mode 100644 index 000000000..34ef0d349 --- /dev/null +++ b/lib/ruby_lsp/listeners/completion.rb @@ -0,0 +1,275 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + module Listeners + class Completion < Listener + extend T::Sig + extend T::Generic + + ResponseType = type_member { { fixed: T::Array[Interface::CompletionItem] } } + + sig { override.returns(ResponseType) } + attr_reader :_response + + sig do + params( + index: RubyIndexer::Index, + nesting: T::Array[String], + typechecker_enabled: T::Boolean, + dispatcher: Prism::Dispatcher, + ).void + end + def initialize(index, nesting, typechecker_enabled, dispatcher) + super(dispatcher) + @_response = T.let([], ResponseType) + @index = index + @nesting = nesting + @typechecker_enabled = typechecker_enabled + + dispatcher.register( + self, + :on_string_node_enter, + :on_constant_path_node_enter, + :on_constant_read_node_enter, + :on_call_node_enter, + ) + end + + sig { params(node: Prism::StringNode).void } + def on_string_node_enter(node) + @index.search_require_paths(node.content).map!(&:require_path).sort!.each do |path| + @_response << build_completion(T.must(path), node) + end + end + + # Handle completion on regular constant references (e.g. `Bar`) + sig { params(node: Prism::ConstantReadNode).void } + def on_constant_read_node_enter(node) + return if DependencyDetector.instance.typechecker + + name = node.slice + candidates = @index.prefix_search(name, @nesting) + candidates.each do |entries| + complete_name = T.must(entries.first).name + @_response << build_entry_completion( + complete_name, + name, + node, + entries, + top_level?(complete_name), + ) + end + end + + # Handle completion on namespaced constant references (e.g. `Foo::Bar`) + sig { params(node: Prism::ConstantPathNode).void } + def on_constant_path_node_enter(node) + return if DependencyDetector.instance.typechecker + + name = node.slice + + top_level_reference = if name.start_with?("::") + name = name.delete_prefix("::") + true + else + false + end + + # If we're trying to provide completion for an aliased namespace, we need to first discover it's real name in + # order to find which possible constants match the desired search + *namespace, incomplete_name = name.split("::") + aliased_namespace = T.must(namespace).join("::") + namespace_entries = @index.resolve(aliased_namespace, @nesting) + return unless namespace_entries + + real_namespace = @index.follow_aliased_namespace(T.must(namespace_entries.first).name) + + candidates = @index.prefix_search("#{real_namespace}::#{incomplete_name}", top_level_reference ? [] : @nesting) + candidates.each do |entries| + # The only time we may have a private constant reference from outside of the namespace is if we're dealing + # with ConstantPath and the entry name doesn't start with the current nesting + first_entry = T.must(entries.first) + next if first_entry.visibility == :private && !first_entry.name.start_with?("#{@nesting}::") + + constant_name = T.must(first_entry.name.split("::").last) + + full_name = aliased_namespace.empty? ? constant_name : "#{aliased_namespace}::#{constant_name}" + + @_response << build_entry_completion( + full_name, + name, + node, + entries, + top_level_reference || top_level?(T.must(entries.first).name), + ) + end + end + + sig { params(node: Prism::CallNode).void } + def on_call_node_enter(node) + return if @typechecker_enabled + return unless self_receiver?(node) + + name = node.message + return unless name + + receiver_entries = @index[@nesting.join("::")] + return unless receiver_entries + + receiver = T.must(receiver_entries.first) + + @index.prefix_search(name).each do |entries| + entry = entries.find { |e| e.is_a?(RubyIndexer::Entry::Member) && e.owner&.name == receiver.name } + next unless entry + + @_response << build_method_completion(T.cast(entry, RubyIndexer::Entry::Member), node) + end + end + + private + + sig do + params( + entry: RubyIndexer::Entry::Member, + node: Prism::CallNode, + ).returns(Interface::CompletionItem) + end + def build_method_completion(entry, node) + name = entry.name + + Interface::CompletionItem.new( + label: name, + filter_text: name, + text_edit: Interface::TextEdit.new(range: range_from_node(node), new_text: name), + kind: Constant::CompletionItemKind::METHOD, + label_details: Interface::CompletionItemLabelDetails.new( + detail: "(#{entry.parameters.map(&:decorated_name).join(", ")})", + description: entry.file_name, + ), + documentation: markdown_from_index_entries(name, entry), + ) + end + + sig { params(label: String, node: Prism::StringNode).returns(Interface::CompletionItem) } + def build_completion(label, node) + # We should use the content location as we only replace the content and not the delimiters of the string + loc = node.content_loc + + Interface::CompletionItem.new( + label: label, + text_edit: Interface::TextEdit.new( + range: range_from_location(loc), + new_text: label, + ), + kind: Constant::CompletionItemKind::FILE, + ) + end + + sig do + params( + real_name: String, + incomplete_name: String, + node: Prism::Node, + entries: T::Array[RubyIndexer::Entry], + top_level: T::Boolean, + ).returns(Interface::CompletionItem) + end + def build_entry_completion(real_name, incomplete_name, node, entries, top_level) + first_entry = T.must(entries.first) + kind = case first_entry + when RubyIndexer::Entry::Class + Constant::CompletionItemKind::CLASS + when RubyIndexer::Entry::Module + Constant::CompletionItemKind::MODULE + when RubyIndexer::Entry::Constant + Constant::CompletionItemKind::CONSTANT + else + Constant::CompletionItemKind::REFERENCE + end + + insertion_text = real_name.dup + filter_text = real_name.dup + + # If we have two entries with the same name inside the current namespace and the user selects the top level + # option, we have to ensure it's prefixed with `::` or else we're completing the wrong constant. For example: + # If we have the index with ["Foo::Bar", "Bar"], and we're providing suggestions for `B` inside a `Foo` module, + # then selecting the `Foo::Bar` option needs to complete to `Bar` and selecting the top level `Bar` option needs + # to complete to `::Bar`. + if top_level + insertion_text.prepend("::") + filter_text.prepend("::") + end + + # If the user is searching for a constant inside the current namespace, then we prefer completing the short name + # of that constant. E.g.: + # + # module Foo + # class Bar + # end + # + # Foo::B # --> completion inserts `Bar` instead of `Foo::Bar` + # end + @nesting.each do |namespace| + prefix = "#{namespace}::" + shortened_name = insertion_text.delete_prefix(prefix) + + # If a different entry exists for the shortened name, then there's a conflict and we should not shorten it + conflict_name = "#{@nesting.join("::")}::#{shortened_name}" + break if real_name != conflict_name && @index[conflict_name] + + insertion_text = shortened_name + + # If the user is typing a fully qualified name `Foo::Bar::Baz`, then we should not use the short name (e.g.: + # `Baz`) as filtering. So we only shorten the filter text if the user is not including the namespaces in their + # typing + filter_text.delete_prefix!(prefix) unless incomplete_name.start_with?(prefix) + end + + # When using a top level constant reference (e.g.: `::Bar`), the editor includes the `::` as part of the filter. + # For these top level references, we need to include the `::` as part of the filter text or else it won't match + # the right entries in the index + Interface::CompletionItem.new( + label: real_name, + filter_text: filter_text, + text_edit: Interface::TextEdit.new( + range: range_from_node(node), + new_text: insertion_text, + ), + kind: kind, + label_details: Interface::CompletionItemLabelDetails.new( + description: entries.map(&:file_name).join(","), + ), + documentation: markdown_from_index_entries(real_name, entries), + ) + end + + # Check if there are any conflicting names for `entry_name`, which would require us to use a top level reference. + # For example: + # + # ```ruby + # class Bar; end + # + # module Foo + # class Bar; end + # + # # in this case, the completion for `Bar` conflicts with `Foo::Bar`, so we can't suggest `Bar` as the + # # completion, but instead need to suggest `::Bar` + # B + # end + # ``` + sig { params(entry_name: String).returns(T::Boolean) } + def top_level?(entry_name) + @nesting.length.downto(0).each do |i| + prefix = T.must(@nesting[0...i]).join("::") + full_name = prefix.empty? ? entry_name : "#{prefix}::#{entry_name}" + next if full_name == entry_name + + return true if @index[full_name] + end + + false + end + end + end +end diff --git a/lib/ruby_lsp/listeners/definition.rb b/lib/ruby_lsp/listeners/definition.rb new file mode 100644 index 000000000..7e6e92ad9 --- /dev/null +++ b/lib/ruby_lsp/listeners/definition.rb @@ -0,0 +1,158 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + module Listeners + class Definition < Listener + extend T::Sig + extend T::Generic + + ResponseType = type_member { { fixed: T.nilable(T.any(T::Array[Interface::Location], Interface::Location)) } } + + sig { override.returns(ResponseType) } + attr_reader :_response + + sig do + params( + uri: URI::Generic, + nesting: T::Array[String], + index: RubyIndexer::Index, + dispatcher: Prism::Dispatcher, + typechecker_enabled: T::Boolean, + ).void + end + def initialize(uri, nesting, index, dispatcher, typechecker_enabled) + @uri = uri + @nesting = nesting + @index = index + @typechecker_enabled = typechecker_enabled + @_response = T.let(nil, ResponseType) + + super(dispatcher) + + dispatcher.register( + self, + :on_call_node_enter, + :on_constant_read_node_enter, + :on_constant_path_node_enter, + ) + end + + sig { params(node: Prism::CallNode).void } + def on_call_node_enter(node) + message = node.name + + if message == :require || message == :require_relative + handle_require_definition(node) + else + handle_method_definition(node) + end + end + + sig { params(node: Prism::ConstantPathNode).void } + def on_constant_path_node_enter(node) + find_in_index(node.slice) + end + + sig { params(node: Prism::ConstantReadNode).void } + def on_constant_read_node_enter(node) + find_in_index(node.slice) + end + + private + + sig { params(node: Prism::CallNode).void } + def handle_method_definition(node) + return unless self_receiver?(node) + + message = node.message + return unless message + + target_method = @index.resolve_method(message, @nesting.join("::")) + return unless target_method + + location = target_method.location + file_path = target_method.file_path + return if @typechecker_enabled && not_in_dependencies?(file_path) + + @_response = Interface::Location.new( + uri: URI::Generic.from_path(path: file_path).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), + ), + ) + end + + sig { params(node: Prism::CallNode).void } + def handle_require_definition(node) + message = node.name + arguments = node.arguments + return unless arguments + + argument = arguments.arguments.first + return unless argument.is_a?(Prism::StringNode) + + case message + when :require + entry = @index.search_require_paths(argument.content).find do |indexable_path| + indexable_path.require_path == argument.content + end + + if entry + candidate = entry.full_path + + @_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), + end: Interface::Position.new(line: 0, character: 0), + ), + ) + end + when :require_relative + required_file = "#{argument.content}.rb" + path = @uri.to_standardized_path + 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( + uri: URI::Generic.from_path(path: candidate).to_s, + range: Interface::Range.new( + start: Interface::Position.new(line: 0, character: 0), + end: Interface::Position.new(line: 0, character: 0), + ), + ) + end + end + + sig { params(value: String).void } + def find_in_index(value) + entries = @index.resolve(value, @nesting) + return unless entries + + # We should only allow jumping to the definition of private constants if the constant is defined in the same + # namespace as the reference + first_entry = T.must(entries.first) + return if first_entry.visibility == :private && first_entry.name != "#{@nesting.join("::")}::#{value}" + + @_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 + # in the project, even if the files are typed false + file_path = entry.file_path + next if @typechecker_enabled && not_in_dependencies?(file_path) + + Interface::Location.new( + uri: URI::Generic.from_path(path: file_path).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), + ), + ) + end + end + end + end +end diff --git a/lib/ruby_lsp/listeners/document_highlight.rb b/lib/ruby_lsp/listeners/document_highlight.rb new file mode 100644 index 000000000..91bf723af --- /dev/null +++ b/lib/ruby_lsp/listeners/document_highlight.rb @@ -0,0 +1,556 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + module Listeners + class DocumentHighlight < Listener + extend T::Sig + + ResponseType = type_member { { fixed: T::Array[Interface::DocumentHighlight] } } + + GLOBAL_VARIABLE_NODES = T.let( + [ + Prism::GlobalVariableAndWriteNode, + Prism::GlobalVariableOperatorWriteNode, + Prism::GlobalVariableOrWriteNode, + Prism::GlobalVariableReadNode, + Prism::GlobalVariableTargetNode, + Prism::GlobalVariableWriteNode, + ], + T::Array[T.class_of(Prism::Node)], + ) + + INSTANCE_VARIABLE_NODES = T.let( + [ + Prism::InstanceVariableAndWriteNode, + Prism::InstanceVariableOperatorWriteNode, + Prism::InstanceVariableOrWriteNode, + Prism::InstanceVariableReadNode, + Prism::InstanceVariableTargetNode, + Prism::InstanceVariableWriteNode, + ], + T::Array[T.class_of(Prism::Node)], + ) + + CONSTANT_NODES = T.let( + [ + Prism::ConstantAndWriteNode, + Prism::ConstantOperatorWriteNode, + Prism::ConstantOrWriteNode, + Prism::ConstantReadNode, + Prism::ConstantTargetNode, + Prism::ConstantWriteNode, + ], + T::Array[T.class_of(Prism::Node)], + ) + + CONSTANT_PATH_NODES = T.let( + [ + Prism::ConstantPathAndWriteNode, + Prism::ConstantPathNode, + Prism::ConstantPathOperatorWriteNode, + Prism::ConstantPathOrWriteNode, + Prism::ConstantPathTargetNode, + Prism::ConstantPathWriteNode, + ], + T::Array[T.class_of(Prism::Node)], + ) + + CLASS_VARIABLE_NODES = T.let( + [ + Prism::ClassVariableAndWriteNode, + Prism::ClassVariableOperatorWriteNode, + Prism::ClassVariableOrWriteNode, + Prism::ClassVariableReadNode, + Prism::ClassVariableTargetNode, + Prism::ClassVariableWriteNode, + ], + T::Array[T.class_of(Prism::Node)], + ) + + LOCAL_NODES = T.let( + [ + Prism::LocalVariableAndWriteNode, + Prism::LocalVariableOperatorWriteNode, + Prism::LocalVariableOrWriteNode, + Prism::LocalVariableReadNode, + Prism::LocalVariableTargetNode, + Prism::LocalVariableWriteNode, + Prism::BlockParameterNode, + Prism::RequiredParameterNode, + Prism::RequiredKeywordParameterNode, + Prism::OptionalKeywordParameterNode, + Prism::RestParameterNode, + Prism::OptionalParameterNode, + Prism::KeywordRestParameterNode, + ], + T::Array[T.class_of(Prism::Node)], + ) + + sig { override.returns(ResponseType) } + attr_reader :_response + + sig do + params( + target: T.nilable(Prism::Node), + parent: T.nilable(Prism::Node), + dispatcher: Prism::Dispatcher, + ).void + end + def initialize(target, parent, dispatcher) + super(dispatcher) + + @_response = T.let([], T::Array[Interface::DocumentHighlight]) + + return unless target && parent + + highlight_target = + case target + when Prism::GlobalVariableReadNode, Prism::GlobalVariableAndWriteNode, Prism::GlobalVariableOperatorWriteNode, + Prism::GlobalVariableOrWriteNode, Prism::GlobalVariableTargetNode, Prism::GlobalVariableWriteNode, + Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableOperatorWriteNode, + Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableReadNode, Prism::InstanceVariableTargetNode, + Prism::InstanceVariableWriteNode, Prism::ConstantAndWriteNode, Prism::ConstantOperatorWriteNode, + Prism::ConstantOrWriteNode, Prism::ConstantPathAndWriteNode, Prism::ConstantPathNode, + Prism::ConstantPathOperatorWriteNode, Prism::ConstantPathOrWriteNode, Prism::ConstantPathTargetNode, + Prism::ConstantPathWriteNode, Prism::ConstantReadNode, Prism::ConstantTargetNode, Prism::ConstantWriteNode, + Prism::ClassVariableAndWriteNode, Prism::ClassVariableOperatorWriteNode, Prism::ClassVariableOrWriteNode, + Prism::ClassVariableReadNode, Prism::ClassVariableTargetNode, Prism::ClassVariableWriteNode, + Prism::LocalVariableAndWriteNode, Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode, + Prism::LocalVariableReadNode, Prism::LocalVariableTargetNode, Prism::LocalVariableWriteNode, + Prism::CallNode, Prism::BlockParameterNode, Prism::RequiredKeywordParameterNode, + Prism::RequiredKeywordParameterNode, Prism::KeywordRestParameterNode, Prism::OptionalParameterNode, + Prism::RequiredParameterNode, Prism::RestParameterNode + target + end + + @target = T.let(highlight_target, T.nilable(Prism::Node)) + @target_value = T.let(node_value(highlight_target), T.nilable(String)) + + if @target && @target_value + dispatcher.register( + self, + :on_call_node_enter, + :on_def_node_enter, + :on_global_variable_target_node_enter, + :on_instance_variable_target_node_enter, + :on_constant_path_target_node_enter, + :on_constant_target_node_enter, + :on_class_variable_target_node_enter, + :on_local_variable_target_node_enter, + :on_block_parameter_node_enter, + :on_required_parameter_node_enter, + :on_class_node_enter, + :on_module_node_enter, + :on_local_variable_read_node_enter, + :on_constant_path_node_enter, + :on_constant_read_node_enter, + :on_instance_variable_read_node_enter, + :on_class_variable_read_node_enter, + :on_global_variable_read_node_enter, + :on_constant_path_write_node_enter, + :on_constant_path_or_write_node_enter, + :on_constant_path_and_write_node_enter, + :on_constant_path_operator_write_node_enter, + :on_local_variable_write_node_enter, + :on_required_keyword_parameter_node_enter, + :on_optional_keyword_parameter_node_enter, + :on_rest_parameter_node_enter, + :on_optional_parameter_node_enter, + :on_keyword_rest_parameter_node_enter, + :on_local_variable_and_write_node_enter, + :on_local_variable_operator_write_node_enter, + :on_local_variable_or_write_node_enter, + :on_class_variable_write_node_enter, + :on_class_variable_or_write_node_enter, + :on_class_variable_operator_write_node_enter, + :on_class_variable_and_write_node_enter, + :on_constant_write_node_enter, + :on_constant_or_write_node_enter, + :on_constant_operator_write_node_enter, + :on_instance_variable_write_node_enter, + :on_constant_and_write_node_enter, + :on_instance_variable_or_write_node_enter, + :on_instance_variable_and_write_node_enter, + :on_instance_variable_operator_write_node_enter, + :on_global_variable_write_node_enter, + :on_global_variable_or_write_node_enter, + :on_global_variable_and_write_node_enter, + :on_global_variable_operator_write_node_enter, + ) + end + end + + sig { params(node: Prism::CallNode).void } + def on_call_node_enter(node) + return unless matches?(node, [Prism::CallNode, Prism::DefNode]) + + add_highlight(Constant::DocumentHighlightKind::READ, node.location) + end + + sig { params(node: Prism::DefNode).void } + def on_def_node_enter(node) + return unless matches?(node, [Prism::CallNode, Prism::DefNode]) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::GlobalVariableTargetNode).void } + def on_global_variable_target_node_enter(node) + return unless matches?(node, GLOBAL_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) + end + + sig { params(node: Prism::InstanceVariableTargetNode).void } + def on_instance_variable_target_node_enter(node) + return unless matches?(node, INSTANCE_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) + end + + sig { params(node: Prism::ConstantPathTargetNode).void } + def on_constant_path_target_node_enter(node) + return unless matches?(node, CONSTANT_PATH_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) + end + + sig { params(node: Prism::ConstantTargetNode).void } + def on_constant_target_node_enter(node) + return unless matches?(node, CONSTANT_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) + end + + sig { params(node: Prism::ClassVariableTargetNode).void } + def on_class_variable_target_node_enter(node) + return unless matches?(node, CLASS_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) + end + + sig { params(node: Prism::LocalVariableTargetNode).void } + def on_local_variable_target_node_enter(node) + return unless matches?(node, LOCAL_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) + end + + sig { params(node: Prism::BlockParameterNode).void } + def on_block_parameter_node_enter(node) + return unless matches?(node, LOCAL_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) + end + + sig { params(node: Prism::RequiredParameterNode).void } + def on_required_parameter_node_enter(node) + return unless matches?(node, LOCAL_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) + end + + sig { params(node: Prism::ClassNode).void } + def on_class_node_enter(node) + return unless matches?(node, CONSTANT_NODES + CONSTANT_PATH_NODES + [Prism::ClassNode]) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.constant_path.location) + end + + sig { params(node: Prism::ModuleNode).void } + def on_module_node_enter(node) + return unless matches?(node, CONSTANT_NODES + CONSTANT_PATH_NODES + [Prism::ModuleNode]) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.constant_path.location) + end + + sig { params(node: Prism::LocalVariableReadNode).void } + def on_local_variable_read_node_enter(node) + return unless matches?(node, LOCAL_NODES) + + add_highlight(Constant::DocumentHighlightKind::READ, node.location) + end + + sig { params(node: Prism::ConstantPathNode).void } + def on_constant_path_node_enter(node) + return unless matches?(node, CONSTANT_PATH_NODES) + + add_highlight(Constant::DocumentHighlightKind::READ, node.location) + end + + sig { params(node: Prism::ConstantReadNode).void } + def on_constant_read_node_enter(node) + return unless matches?(node, CONSTANT_NODES) + + add_highlight(Constant::DocumentHighlightKind::READ, node.location) + end + + sig { params(node: Prism::InstanceVariableReadNode).void } + def on_instance_variable_read_node_enter(node) + return unless matches?(node, INSTANCE_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::READ, node.location) + end + + sig { params(node: Prism::ClassVariableReadNode).void } + def on_class_variable_read_node_enter(node) + return unless matches?(node, CLASS_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::READ, node.location) + end + + sig { params(node: Prism::GlobalVariableReadNode).void } + def on_global_variable_read_node_enter(node) + return unless matches?(node, GLOBAL_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::READ, node.location) + end + + sig { params(node: Prism::ConstantPathWriteNode).void } + def on_constant_path_write_node_enter(node) + return unless matches?(node, CONSTANT_PATH_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.target.location) + end + + sig { params(node: Prism::ConstantPathOrWriteNode).void } + def on_constant_path_or_write_node_enter(node) + return unless matches?(node, CONSTANT_PATH_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.target.location) + end + + sig { params(node: Prism::ConstantPathAndWriteNode).void } + def on_constant_path_and_write_node_enter(node) + return unless matches?(node, CONSTANT_PATH_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.target.location) + end + + sig { params(node: Prism::ConstantPathOperatorWriteNode).void } + def on_constant_path_operator_write_node_enter(node) + return unless matches?(node, CONSTANT_PATH_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.target.location) + end + + sig { params(node: Prism::LocalVariableWriteNode).void } + def on_local_variable_write_node_enter(node) + return unless matches?(node, LOCAL_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::RequiredKeywordParameterNode).void } + def on_required_keyword_parameter_node_enter(node) + return unless matches?(node, LOCAL_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::OptionalKeywordParameterNode).void } + def on_optional_keyword_parameter_node_enter(node) + return unless matches?(node, LOCAL_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::RestParameterNode).void } + def on_rest_parameter_node_enter(node) + return unless matches?(node, LOCAL_NODES) + + name_loc = node.name_loc + add_highlight(Constant::DocumentHighlightKind::WRITE, name_loc) if name_loc + end + + sig { params(node: Prism::OptionalParameterNode).void } + def on_optional_parameter_node_enter(node) + return unless matches?(node, LOCAL_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::KeywordRestParameterNode).void } + def on_keyword_rest_parameter_node_enter(node) + return unless matches?(node, LOCAL_NODES) + + name_loc = node.name_loc + add_highlight(Constant::DocumentHighlightKind::WRITE, name_loc) if name_loc + end + + sig { params(node: Prism::LocalVariableAndWriteNode).void } + def on_local_variable_and_write_node_enter(node) + return unless matches?(node, LOCAL_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::LocalVariableOperatorWriteNode).void } + def on_local_variable_operator_write_node_enter(node) + return unless matches?(node, LOCAL_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::LocalVariableOrWriteNode).void } + def on_local_variable_or_write_node_enter(node) + return unless matches?(node, LOCAL_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::ClassVariableWriteNode).void } + def on_class_variable_write_node_enter(node) + return unless matches?(node, CLASS_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::ClassVariableOrWriteNode).void } + def on_class_variable_or_write_node_enter(node) + return unless matches?(node, CLASS_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::ClassVariableOperatorWriteNode).void } + def on_class_variable_operator_write_node_enter(node) + return unless matches?(node, CLASS_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::ClassVariableAndWriteNode).void } + def on_class_variable_and_write_node_enter(node) + return unless matches?(node, CLASS_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::ConstantWriteNode).void } + def on_constant_write_node_enter(node) + return unless matches?(node, CONSTANT_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::ConstantOrWriteNode).void } + def on_constant_or_write_node_enter(node) + return unless matches?(node, CONSTANT_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::ConstantOperatorWriteNode).void } + def on_constant_operator_write_node_enter(node) + return unless matches?(node, CONSTANT_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::InstanceVariableWriteNode).void } + def on_instance_variable_write_node_enter(node) + return unless matches?(node, INSTANCE_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::InstanceVariableOrWriteNode).void } + def on_instance_variable_or_write_node_enter(node) + return unless matches?(node, INSTANCE_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::InstanceVariableAndWriteNode).void } + def on_instance_variable_and_write_node_enter(node) + return unless matches?(node, INSTANCE_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::InstanceVariableOperatorWriteNode).void } + def on_instance_variable_operator_write_node_enter(node) + return unless matches?(node, INSTANCE_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::ConstantAndWriteNode).void } + def on_constant_and_write_node_enter(node) + return unless matches?(node, CONSTANT_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::GlobalVariableWriteNode).void } + def on_global_variable_write_node_enter(node) + return unless matches?(node, GLOBAL_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::GlobalVariableOrWriteNode).void } + def on_global_variable_or_write_node_enter(node) + return unless matches?(node, GLOBAL_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::GlobalVariableAndWriteNode).void } + def on_global_variable_and_write_node_enter(node) + return unless matches?(node, GLOBAL_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + sig { params(node: Prism::GlobalVariableOperatorWriteNode).void } + def on_global_variable_operator_write_node_enter(node) + return unless matches?(node, GLOBAL_VARIABLE_NODES) + + add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + end + + private + + sig { params(node: Prism::Node, classes: T::Array[T.class_of(Prism::Node)]).returns(T.nilable(T::Boolean)) } + def matches?(node, classes) + classes.any? { |klass| @target.is_a?(klass) } && @target_value == node_value(node) + end + + sig { params(kind: Integer, location: Prism::Location).void } + def add_highlight(kind, location) + @_response << Interface::DocumentHighlight.new(range: range_from_location(location), kind: kind) + end + + sig { params(node: T.nilable(Prism::Node)).returns(T.nilable(String)) } + def node_value(node) + case node + when Prism::ConstantReadNode, Prism::ConstantPathNode, Prism::BlockArgumentNode, Prism::ConstantTargetNode, + Prism::ConstantPathWriteNode, Prism::ConstantPathTargetNode, Prism::ConstantPathOrWriteNode, + Prism::ConstantPathOperatorWriteNode, Prism::ConstantPathAndWriteNode + node.slice + when Prism::GlobalVariableReadNode, Prism::GlobalVariableAndWriteNode, Prism::GlobalVariableOperatorWriteNode, + Prism::GlobalVariableOrWriteNode, Prism::GlobalVariableTargetNode, Prism::GlobalVariableWriteNode, + Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableOperatorWriteNode, + Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableReadNode, Prism::InstanceVariableTargetNode, + Prism::InstanceVariableWriteNode, Prism::ConstantAndWriteNode, Prism::ConstantOperatorWriteNode, + Prism::ConstantOrWriteNode, Prism::ConstantWriteNode, Prism::ClassVariableAndWriteNode, + Prism::ClassVariableOperatorWriteNode, Prism::ClassVariableOrWriteNode, Prism::ClassVariableReadNode, + Prism::ClassVariableTargetNode, Prism::ClassVariableWriteNode, Prism::LocalVariableAndWriteNode, + Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode, Prism::LocalVariableReadNode, + Prism::LocalVariableTargetNode, Prism::LocalVariableWriteNode, Prism::DefNode, Prism::BlockParameterNode, + Prism::RequiredKeywordParameterNode, Prism::OptionalKeywordParameterNode, Prism::KeywordRestParameterNode, + Prism::OptionalParameterNode, Prism::RequiredParameterNode, Prism::RestParameterNode + + node.name.to_s + when Prism::CallNode + node.message + when Prism::ClassNode, Prism::ModuleNode + node.constant_path.slice + end + end + end + end +end diff --git a/lib/ruby_lsp/listeners/document_link.rb b/lib/ruby_lsp/listeners/document_link.rb new file mode 100644 index 000000000..0b37a466e --- /dev/null +++ b/lib/ruby_lsp/listeners/document_link.rb @@ -0,0 +1,162 @@ +# typed: strict +# frozen_string_literal: true + +require "ruby_lsp/requests/support/source_uri" + +module RubyLsp + module Listeners + class DocumentLink < Listener + extend T::Sig + extend T::Generic + + ResponseType = type_member { { fixed: T::Array[Interface::DocumentLink] } } + + GEM_TO_VERSION_MAP = T.let( + [*::Gem::Specification.default_stubs, *::Gem::Specification.stubs].map! do |s| + [s.name, s.version.to_s] + end.to_h.freeze, + T::Hash[String, String], + ) + + class << self + extend T::Sig + + sig { returns(T::Hash[String, T::Hash[String, T::Hash[String, String]]]) } + def gem_paths + @gem_paths ||= T.let( + begin + lookup = {} + + Gem::Specification.stubs.each do |stub| + spec = stub.to_spec + lookup[spec.name] = {} + lookup[spec.name][spec.version.to_s] = {} + + Dir.glob("**/*.rb", base: "#{spec.full_gem_path}/").each do |path| + lookup[spec.name][spec.version.to_s][path] = "#{spec.full_gem_path}/#{path}" + end + end + + Gem::Specification.default_stubs.each do |stub| + spec = stub.to_spec + lookup[spec.name] = {} + lookup[spec.name][spec.version.to_s] = {} + prefix_matchers = Regexp.union(spec.require_paths.map do |rp| + Regexp.new("^#{rp}/") + end) + prefix_matcher = Regexp.union(prefix_matchers, //) + + spec.files.each do |file| + path = file.sub(prefix_matcher, "") + lookup[spec.name][spec.version.to_s][path] = "#{RbConfig::CONFIG["rubylibdir"]}/#{path}" + end + end + + lookup + end, + T.nilable(T::Hash[String, T::Hash[String, T::Hash[String, String]]]), + ) + end + end + + sig { override.returns(ResponseType) } + attr_reader :_response + + sig do + params( + uri: URI::Generic, + comments: T::Array[Prism::Comment], + dispatcher: Prism::Dispatcher, + ).void + end + def initialize(uri, comments, dispatcher) + super(dispatcher) + + # Match the version based on the version in the RBI file name. Notice that the `@` symbol is sanitized to `%40` + # in the URI + 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]) + @lines_to_comments = T.let( + comments.to_h do |comment| + [comment.location.end_line, comment] + end, + T::Hash[Integer, Prism::Comment], + ) + + dispatcher.register( + self, + :on_def_node_enter, + :on_class_node_enter, + :on_module_node_enter, + :on_constant_write_node_enter, + :on_constant_path_write_node_enter, + ) + end + + sig { params(node: Prism::DefNode).void } + def on_def_node_enter(node) + extract_document_link(node) + end + + sig { params(node: Prism::ClassNode).void } + def on_class_node_enter(node) + extract_document_link(node) + end + + sig { params(node: Prism::ModuleNode).void } + def on_module_node_enter(node) + extract_document_link(node) + end + + sig { params(node: Prism::ConstantWriteNode).void } + def on_constant_write_node_enter(node) + extract_document_link(node) + end + + sig { params(node: Prism::ConstantPathWriteNode).void } + def on_constant_path_write_node_enter(node) + extract_document_link(node) + end + + private + + sig { params(node: Prism::Node).void } + def extract_document_link(node) + comment = @lines_to_comments[node.location.start_line - 1] + return unless comment + + match = comment.location.slice.match(%r{source://.*#\d+$}) + return unless match + + uri = T.cast(URI(T.must(match[0])), URI::Source) + gem_version = resolve_version(uri) + return if gem_version.nil? + + 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( + range: range_from_location(comment.location), + target: "file://#{file_path}##{uri.line_number}", + tooltip: "Jump to #{file_path}##{uri.line_number}", + ) + end + + # Try to figure out the gem version for a source:// link. The order of precedence is: + # 1. The version in the URI + # 2. The version in the RBI file name + # 3. The version from the gemspec + sig { params(uri: URI::Source).returns(T.nilable(String)) } + def resolve_version(uri) + version = uri.gem_version + return version unless version.nil? || version.empty? + + return @gem_version unless @gem_version.nil? || @gem_version.empty? + + GEM_TO_VERSION_MAP[uri.gem_name] + end + end + end +end diff --git a/lib/ruby_lsp/listeners/document_symbol.rb b/lib/ruby_lsp/listeners/document_symbol.rb new file mode 100644 index 000000000..69b31b6c4 --- /dev/null +++ b/lib/ruby_lsp/listeners/document_symbol.rb @@ -0,0 +1,223 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + module Listeners + class DocumentSymbol < Listener + extend T::Sig + extend T::Generic + + ResponseType = type_member { { fixed: T::Array[Interface::DocumentSymbol] } } + + ATTR_ACCESSORS = T.let([:attr_reader, :attr_writer, :attr_accessor].freeze, T::Array[Symbol]) + + class SymbolHierarchyRoot + extend T::Sig + + sig { returns(T::Array[Interface::DocumentSymbol]) } + attr_reader :children + + sig { void } + def initialize + @children = T.let([], T::Array[Interface::DocumentSymbol]) + end + end + + sig { override.returns(T::Array[Interface::DocumentSymbol]) } + attr_reader :_response + + sig { params(dispatcher: Prism::Dispatcher).void } + def initialize(dispatcher) + @root = T.let(SymbolHierarchyRoot.new, SymbolHierarchyRoot) + @_response = T.let(@root.children, T::Array[Interface::DocumentSymbol]) + @stack = T.let( + [@root], + T::Array[T.any(SymbolHierarchyRoot, Interface::DocumentSymbol)], + ) + + super + + dispatcher.register( + self, + :on_class_node_enter, + :on_class_node_leave, + :on_call_node_enter, + :on_constant_path_write_node_enter, + :on_constant_write_node_enter, + :on_def_node_enter, + :on_def_node_leave, + :on_module_node_enter, + :on_module_node_leave, + :on_instance_variable_write_node_enter, + :on_class_variable_write_node_enter, + :on_singleton_class_node_enter, + :on_singleton_class_node_leave, + ) + end + + sig { params(node: Prism::ClassNode).void } + def on_class_node_enter(node) + @stack << create_document_symbol( + name: node.constant_path.location.slice, + kind: Constant::SymbolKind::CLASS, + range_location: node.location, + selection_range_location: node.constant_path.location, + ) + end + + sig { params(node: Prism::ClassNode).void } + def on_class_node_leave(node) + @stack.pop + end + + sig { params(node: Prism::SingletonClassNode).void } + def on_singleton_class_node_enter(node) + expression = node.expression + + @stack << create_document_symbol( + name: "<< #{expression.slice}", + kind: Constant::SymbolKind::NAMESPACE, + range_location: node.location, + selection_range_location: expression.location, + ) + end + + sig { params(node: Prism::SingletonClassNode).void } + def on_singleton_class_node_leave(node) + @stack.pop + end + + sig { params(node: Prism::CallNode).void } + def on_call_node_enter(node) + return unless ATTR_ACCESSORS.include?(node.name) && node.receiver.nil? + + arguments = node.arguments + return unless arguments + + arguments.arguments.each do |argument| + next unless argument.is_a?(Prism::SymbolNode) + + name = argument.value + next unless name + + create_document_symbol( + name: name, + kind: Constant::SymbolKind::FIELD, + range_location: argument.location, + selection_range_location: T.must(argument.value_loc), + ) + end + end + + sig { params(node: Prism::ConstantPathWriteNode).void } + def on_constant_path_write_node_enter(node) + create_document_symbol( + name: node.target.location.slice, + kind: Constant::SymbolKind::CONSTANT, + range_location: node.location, + selection_range_location: node.target.location, + ) + end + + sig { params(node: Prism::ConstantWriteNode).void } + def on_constant_write_node_enter(node) + create_document_symbol( + name: node.name.to_s, + kind: Constant::SymbolKind::CONSTANT, + range_location: node.location, + selection_range_location: node.name_loc, + ) + end + + sig { params(node: Prism::DefNode).void } + def on_def_node_leave(node) + @stack.pop + end + + sig { params(node: Prism::ModuleNode).void } + def on_module_node_enter(node) + @stack << create_document_symbol( + name: node.constant_path.location.slice, + kind: Constant::SymbolKind::MODULE, + range_location: node.location, + selection_range_location: node.constant_path.location, + ) + end + + sig { params(node: Prism::DefNode).void } + def on_def_node_enter(node) + receiver = node.receiver + previous_symbol = @stack.last + + if receiver.is_a?(Prism::SelfNode) + name = "self.#{node.name}" + kind = Constant::SymbolKind::FUNCTION + elsif previous_symbol.is_a?(Interface::DocumentSymbol) && previous_symbol.name.start_with?("<<") + name = node.name.to_s + kind = Constant::SymbolKind::FUNCTION + else + name = node.name.to_s + kind = name == "initialize" ? Constant::SymbolKind::CONSTRUCTOR : Constant::SymbolKind::METHOD + end + + symbol = create_document_symbol( + name: name, + kind: kind, + range_location: node.location, + selection_range_location: node.name_loc, + ) + + @stack << symbol + end + + sig { params(node: Prism::ModuleNode).void } + def on_module_node_leave(node) + @stack.pop + end + + sig { params(node: Prism::InstanceVariableWriteNode).void } + def on_instance_variable_write_node_enter(node) + create_document_symbol( + name: node.name.to_s, + kind: Constant::SymbolKind::VARIABLE, + range_location: node.name_loc, + selection_range_location: node.name_loc, + ) + end + + sig { params(node: Prism::ClassVariableWriteNode).void } + def on_class_variable_write_node_enter(node) + create_document_symbol( + name: node.name.to_s, + kind: Constant::SymbolKind::VARIABLE, + range_location: node.name_loc, + selection_range_location: node.name_loc, + ) + end + + private + + sig do + params( + name: String, + kind: Integer, + range_location: Prism::Location, + selection_range_location: Prism::Location, + ).returns(Interface::DocumentSymbol) + end + def create_document_symbol(name:, kind:, range_location:, selection_range_location:) + symbol = Interface::DocumentSymbol.new( + name: name, + kind: kind, + range: range_from_location(range_location), + selection_range: range_from_location(selection_range_location), + children: [], + ) + + T.must(@stack.last).children << symbol + + symbol + end + end + end +end diff --git a/lib/ruby_lsp/listeners/folding_ranges.rb b/lib/ruby_lsp/listeners/folding_ranges.rb new file mode 100644 index 000000000..96bc9516b --- /dev/null +++ b/lib/ruby_lsp/listeners/folding_ranges.rb @@ -0,0 +1,271 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + module Listeners + class FoldingRanges < Listener + extend T::Sig + extend T::Generic + + ResponseType = type_member { { fixed: T::Array[Interface::FoldingRange] } } + + sig { params(comments: T::Array[Prism::Comment], dispatcher: Prism::Dispatcher).void } + def initialize(comments, dispatcher) + super(dispatcher) + + @_response = T.let([], ResponseType) + @requires = T.let([], T::Array[Prism::CallNode]) + @finalized_response = T.let(false, T::Boolean) + @comments = comments + + dispatcher.register( + self, + :on_if_node_enter, + :on_in_node_enter, + :on_rescue_node_enter, + :on_when_node_enter, + :on_interpolated_string_node_enter, + :on_array_node_enter, + :on_block_node_enter, + :on_case_node_enter, + :on_case_match_node_enter, + :on_class_node_enter, + :on_module_node_enter, + :on_for_node_enter, + :on_hash_node_enter, + :on_singleton_class_node_enter, + :on_unless_node_enter, + :on_until_node_enter, + :on_while_node_enter, + :on_else_node_enter, + :on_ensure_node_enter, + :on_begin_node_enter, + :on_def_node_enter, + :on_call_node_enter, + :on_lambda_node_enter, + ) + end + + sig { override.returns(ResponseType) } + def _response + unless @finalized_response + push_comment_ranges + emit_requires_range + @finalized_response = true + end + + @_response + end + + sig { params(node: Prism::IfNode).void } + def on_if_node_enter(node) + add_statements_range(node) + end + + sig { params(node: Prism::InNode).void } + def on_in_node_enter(node) + add_statements_range(node) + end + + sig { params(node: Prism::RescueNode).void } + def on_rescue_node_enter(node) + add_statements_range(node) + end + + sig { params(node: Prism::WhenNode).void } + def on_when_node_enter(node) + add_statements_range(node) + end + + sig { params(node: Prism::InterpolatedStringNode).void } + def on_interpolated_string_node_enter(node) + opening_loc = node.opening_loc || node.location + closing_loc = node.closing_loc || node.parts.last&.location || node.location + + add_lines_range(opening_loc.start_line, closing_loc.start_line - 1) + end + + sig { params(node: Prism::ArrayNode).void } + def on_array_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::BlockNode).void } + def on_block_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::CaseNode).void } + def on_case_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::CaseMatchNode).void } + def on_case_match_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::ClassNode).void } + def on_class_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::ModuleNode).void } + def on_module_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::ForNode).void } + def on_for_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::HashNode).void } + def on_hash_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::SingletonClassNode).void } + def on_singleton_class_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::UnlessNode).void } + def on_unless_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::UntilNode).void } + def on_until_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::WhileNode).void } + def on_while_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::ElseNode).void } + def on_else_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::EnsureNode).void } + def on_ensure_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::BeginNode).void } + def on_begin_node_enter(node) + add_simple_range(node) + end + + sig { params(node: Prism::DefNode).void } + def on_def_node_enter(node) + params = node.parameters + parameter_loc = params&.location + location = node.location + + if params && parameter_loc.end_line > location.start_line + # Multiline parameters + add_lines_range(location.start_line, parameter_loc.end_line) + add_lines_range(parameter_loc.end_line + 1, location.end_line - 1) + else + add_lines_range(location.start_line, location.end_line - 1) + end + end + + sig { params(node: Prism::CallNode).void } + def on_call_node_enter(node) + # If we find a require, don't visit the child nodes (prevent `super`), so that we can keep accumulating into + # the `@requires` array and then push the range whenever we find a node that isn't a CallNode + if require?(node) + @requires << node + return + end + + location = node.location + add_lines_range(location.start_line, location.end_line - 1) + end + + sig { params(node: Prism::LambdaNode).void } + def on_lambda_node_enter(node) + add_simple_range(node) + end + + private + + sig { void } + def push_comment_ranges + # Group comments that are on consecutive lines and then push ranges for each group that has at least 2 comments + @comments.chunk_while do |this, other| + this.location.end_line + 1 == other.location.start_line + end.each do |chunk| + next if chunk.length == 1 + + @_response << Interface::FoldingRange.new( + start_line: T.must(chunk.first).location.start_line - 1, + end_line: T.must(chunk.last).location.end_line - 1, + kind: "comment", + ) + end + end + + sig { void } + def emit_requires_range + if @requires.length > 1 + @_response << Interface::FoldingRange.new( + start_line: T.must(@requires.first).location.start_line - 1, + end_line: T.must(@requires.last).location.end_line - 1, + kind: "imports", + ) + end + + @requires.clear + end + + sig { params(node: Prism::CallNode).returns(T::Boolean) } + def require?(node) + message = node.message + return false unless message == "require" || message == "require_relative" + + receiver = node.receiver + return false unless receiver.nil? || receiver.slice == "Kernel" + + arguments = node.arguments&.arguments + return false unless arguments + + arguments.length == 1 && arguments.first.is_a?(Prism::StringNode) + end + + sig { params(node: T.any(Prism::IfNode, Prism::InNode, Prism::RescueNode, Prism::WhenNode)).void } + def add_statements_range(node) + statements = node.statements + return unless statements + + body = statements.body + return if body.empty? + + add_lines_range(node.location.start_line, T.must(body.last).location.end_line) + end + + sig { params(node: Prism::Node).void } + def add_simple_range(node) + location = node.location + add_lines_range(location.start_line, location.end_line - 1) + end + + sig { params(start_line: Integer, end_line: Integer).void } + def add_lines_range(start_line, end_line) + emit_requires_range + return if start_line >= end_line + + @_response << Interface::FoldingRange.new( + start_line: start_line - 1, + end_line: end_line - 1, + kind: "region", + ) + end + end + end +end diff --git a/lib/ruby_lsp/listeners/hover.rb b/lib/ruby_lsp/listeners/hover.rb new file mode 100644 index 000000000..719805cd1 --- /dev/null +++ b/lib/ruby_lsp/listeners/hover.rb @@ -0,0 +1,152 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + module Listeners + class Hover < Listener + extend T::Sig + extend T::Generic + + ResponseType = type_member { { fixed: T.nilable(Interface::Hover) } } + + ALLOWED_TARGETS = T.let( + [ + Prism::CallNode, + Prism::ConstantReadNode, + Prism::ConstantWriteNode, + Prism::ConstantPathNode, + ], + T::Array[T.class_of(Prism::Node)], + ) + + sig { override.returns(ResponseType) } + attr_reader :_response + + sig do + params( + uri: URI::Generic, + nesting: T::Array[String], + index: RubyIndexer::Index, + dispatcher: Prism::Dispatcher, + typechecker_enabled: T::Boolean, + ).void + end + def initialize(uri, nesting, index, dispatcher, typechecker_enabled) + @path = T.let(uri.to_standardized_path, T.nilable(String)) + @nesting = nesting + @index = index + @typechecker_enabled = typechecker_enabled + @_response = T.let(nil, ResponseType) + + super(dispatcher) + dispatcher.register( + self, + :on_constant_read_node_enter, + :on_constant_write_node_enter, + :on_constant_path_node_enter, + :on_call_node_enter, + ) + end + + sig { params(node: Prism::ConstantReadNode).void } + def on_constant_read_node_enter(node) + return if @typechecker_enabled + + generate_hover(node.slice, node.location) + end + + sig { params(node: Prism::ConstantWriteNode).void } + def on_constant_write_node_enter(node) + return if DependencyDetector.instance.typechecker + + generate_hover(node.name.to_s, node.name_loc) + end + + sig { params(node: Prism::ConstantPathNode).void } + def on_constant_path_node_enter(node) + return if DependencyDetector.instance.typechecker + + generate_hover(node.slice, node.location) + end + + sig { params(node: Prism::CallNode).void } + def on_call_node_enter(node) + return unless self_receiver?(node) + + if @path && File.basename(@path) == GEMFILE_NAME && node.name == :gem + generate_gem_hover(node) + return + end + + return if @typechecker_enabled + + message = node.message + return unless message + + target_method = @index.resolve_method(message, @nesting.join("::")) + return unless target_method + + location = target_method.location + + @_response = Interface::Hover.new( + range: range_from_location(location), + contents: markdown_from_index_entries(message, target_method), + ) + end + + private + + sig { params(name: String, location: Prism::Location).void } + def generate_hover(name, location) + entries = @index.resolve(name, @nesting) + return unless entries + + # We should only show hover for private constants if the constant is defined in the same namespace as the + # reference + first_entry = T.must(entries.first) + return if first_entry.visibility == :private && first_entry.name != "#{@nesting.join("::")}::#{name}" + + @_response = Interface::Hover.new( + range: range_from_location(location), + contents: markdown_from_index_entries(name, entries), + ) + end + + sig { params(node: Prism::CallNode).void } + def generate_gem_hover(node) + first_argument = node.arguments&.arguments&.first + return unless first_argument.is_a?(Prism::StringNode) + + spec = Gem::Specification.find_by_name(first_argument.content) + return unless spec + + info = T.let( + [ + spec.description, + spec.summary, + "This rubygem does not have a description or summary.", + ].find { |text| !text.nil? && !text.empty? }, + String, + ) + + # Remove leading whitespace if a heredoc was used for the summary or description + info = info.gsub(/^ +/, "") + + markdown = <<~MARKDOWN + **#{spec.name}** (#{spec.version}) + #{info} + MARKDOWN + + @_response = Interface::Hover.new( + range: range_from_location(node.location), + contents: Interface::MarkupContent.new( + kind: Constant::MarkupKind::MARKDOWN, + value: markdown, + ), + ) + rescue Gem::MissingSpecError + # Do nothing if the spec cannot be found + end + end + end +end diff --git a/lib/ruby_lsp/listeners/inlay_hints.rb b/lib/ruby_lsp/listeners/inlay_hints.rb new file mode 100644 index 000000000..18e352cc0 --- /dev/null +++ b/lib/ruby_lsp/listeners/inlay_hints.rb @@ -0,0 +1,80 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + module Listeners + class InlayHints < Listener + extend T::Sig + extend T::Generic + + ResponseType = type_member { { fixed: T::Array[Interface::InlayHint] } } + + RESCUE_STRING_LENGTH = T.let("rescue".length, Integer) + + sig { override.returns(ResponseType) } + attr_reader :_response + + sig do + params( + range: T::Range[Integer], + hints_configuration: RequestConfig, + dispatcher: Prism::Dispatcher, + ).void + end + def initialize(range, hints_configuration, dispatcher) + super(dispatcher) + + @_response = T.let([], ResponseType) + @range = range + @hints_configuration = hints_configuration + + dispatcher.register(self, :on_rescue_node_enter, :on_implicit_node_enter) + end + + sig { params(node: Prism::RescueNode).void } + def on_rescue_node_enter(node) + return unless @hints_configuration.enabled?(:implicitRescue) + return unless node.exceptions.empty? + + loc = node.location + return unless visible?(node, @range) + + @_response << Interface::InlayHint.new( + position: { line: loc.start_line - 1, character: loc.start_column + RESCUE_STRING_LENGTH }, + label: "StandardError", + padding_left: true, + tooltip: "StandardError is implied in a bare rescue", + ) + end + + sig { params(node: Prism::ImplicitNode).void } + def on_implicit_node_enter(node) + return unless @hints_configuration.enabled?(:implicitHashValue) + return unless visible?(node, @range) + + node_value = node.value + loc = node.location + tooltip = "" + node_name = "" + case node_value + when Prism::CallNode + node_name = node_value.name + tooltip = "This is a method call. Method name: #{node_name}" + when Prism::ConstantReadNode + node_name = node_value.name + tooltip = "This is a constant: #{node_name}" + when Prism::LocalVariableReadNode + node_name = node_value.name + tooltip = "This is a local variable: #{node_name}" + end + + @_response << Interface::InlayHint.new( + position: { line: loc.start_line - 1, character: loc.start_column + node_name.length + 1 }, + label: node_name, + padding_left: true, + tooltip: tooltip, + ) + end + end + end +end diff --git a/lib/ruby_lsp/listeners/semantic_highlighting.rb b/lib/ruby_lsp/listeners/semantic_highlighting.rb new file mode 100644 index 000000000..c651494d6 --- /dev/null +++ b/lib/ruby_lsp/listeners/semantic_highlighting.rb @@ -0,0 +1,430 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + module Listeners + class SemanticHighlighting < Listener + extend T::Sig + extend T::Generic + + ResponseType = type_member { { fixed: T::Array[SemanticToken] } } + + TOKEN_TYPES = T.let( + { + namespace: 0, + type: 1, + class: 2, + enum: 3, + interface: 4, + struct: 5, + typeParameter: 6, + parameter: 7, + variable: 8, + property: 9, + enumMember: 10, + event: 11, + function: 12, + method: 13, + macro: 14, + keyword: 15, + modifier: 16, + comment: 17, + string: 18, + number: 19, + regexp: 20, + operator: 21, + decorator: 22, + }.freeze, + T::Hash[Symbol, Integer], + ) + + TOKEN_MODIFIERS = T.let( + { + declaration: 0, + definition: 1, + readonly: 2, + static: 3, + deprecated: 4, + abstract: 5, + async: 6, + modification: 7, + documentation: 8, + default_library: 9, + }.freeze, + T::Hash[Symbol, Integer], + ) + + SPECIAL_RUBY_METHODS = T.let( + [ + Module.instance_methods(false), + Kernel.instance_methods(false), + Kernel.methods(false), + Bundler::Dsl.instance_methods(false), + Module.private_instance_methods(false), + ].flatten.map(&:to_s), + T::Array[String], + ) + + class SemanticToken + extend T::Sig + + sig { returns(Prism::Location) } + attr_reader :location + + sig { returns(Integer) } + attr_reader :length + + sig { returns(Integer) } + attr_reader :type + + sig { returns(T::Array[Integer]) } + attr_reader :modifier + + sig { params(location: Prism::Location, length: Integer, type: Integer, modifier: T::Array[Integer]).void } + def initialize(location:, length:, type:, modifier:) + @location = location + @length = length + @type = type + @modifier = modifier + end + end + + sig { override.returns(ResponseType) } + attr_reader :_response + + sig { params(dispatcher: Prism::Dispatcher, range: T.nilable(T::Range[Integer])).void } + def initialize(dispatcher, range: nil) + super(dispatcher) + + @_response = T.let([], ResponseType) + @range = range + @special_methods = T.let(nil, T.nilable(T::Array[String])) + @current_scope = T.let(ParameterScope.new, ParameterScope) + @inside_regex_capture = T.let(false, T::Boolean) + + dispatcher.register( + self, + :on_call_node_enter, + :on_class_node_enter, + :on_def_node_enter, + :on_def_node_leave, + :on_block_node_enter, + :on_block_node_leave, + :on_self_node_enter, + :on_module_node_enter, + :on_local_variable_write_node_enter, + :on_local_variable_read_node_enter, + :on_block_parameter_node_enter, + :on_required_keyword_parameter_node_enter, + :on_optional_keyword_parameter_node_enter, + :on_keyword_rest_parameter_node_enter, + :on_optional_parameter_node_enter, + :on_required_parameter_node_enter, + :on_rest_parameter_node_enter, + :on_constant_read_node_enter, + :on_constant_write_node_enter, + :on_constant_and_write_node_enter, + :on_constant_operator_write_node_enter, + :on_constant_or_write_node_enter, + :on_constant_target_node_enter, + :on_local_variable_and_write_node_enter, + :on_local_variable_operator_write_node_enter, + :on_local_variable_or_write_node_enter, + :on_local_variable_target_node_enter, + :on_block_local_variable_node_enter, + :on_match_write_node_enter, + :on_match_write_node_leave, + ) + end + + sig { params(node: Prism::CallNode).void } + def on_call_node_enter(node) + return unless visible?(node, @range) + + message = node.message + return unless message + + # We can't push a semantic token for [] and []= because the argument inside the brackets is a part of + # the message_loc + return if message.start_with?("[") && (message.end_with?("]") || message.end_with?("]=")) + return if message == "=~" + return if special_method?(message) + + type = Requests::Support::Sorbet.annotation?(node) ? :type : :method + add_token(T.must(node.message_loc), type) + end + + sig { params(node: Prism::MatchWriteNode).void } + def on_match_write_node_enter(node) + call = node.call + + if call.message == "=~" + @inside_regex_capture = true + process_regexp_locals(call) + end + end + + sig { params(node: Prism::MatchWriteNode).void } + def on_match_write_node_leave(node) + @inside_regex_capture = true if node.call.message == "=~" + end + + sig { params(node: Prism::ConstantReadNode).void } + def on_constant_read_node_enter(node) + return unless visible?(node, @range) + # 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 } + + add_token(node.location, :namespace) + end + + sig { params(node: Prism::ConstantWriteNode).void } + def on_constant_write_node_enter(node) + return unless visible?(node, @range) + + add_token(node.name_loc, :namespace) + end + + sig { params(node: Prism::ConstantAndWriteNode).void } + def on_constant_and_write_node_enter(node) + return unless visible?(node, @range) + + add_token(node.name_loc, :namespace) + end + + sig { params(node: Prism::ConstantOperatorWriteNode).void } + def on_constant_operator_write_node_enter(node) + return unless visible?(node, @range) + + add_token(node.name_loc, :namespace) + end + + sig { params(node: Prism::ConstantOrWriteNode).void } + def on_constant_or_write_node_enter(node) + return unless visible?(node, @range) + + add_token(node.name_loc, :namespace) + end + + sig { params(node: Prism::ConstantTargetNode).void } + def on_constant_target_node_enter(node) + return unless visible?(node, @range) + + add_token(node.location, :namespace) + end + + sig { params(node: Prism::DefNode).void } + def on_def_node_enter(node) + @current_scope = ParameterScope.new(@current_scope) + return unless visible?(node, @range) + + add_token(node.name_loc, :method, [:declaration]) + end + + sig { params(node: Prism::DefNode).void } + def on_def_node_leave(node) + @current_scope = T.must(@current_scope.parent) + end + + sig { params(node: Prism::BlockNode).void } + def on_block_node_enter(node) + @current_scope = ParameterScope.new(@current_scope) + end + + sig { params(node: Prism::BlockNode).void } + def on_block_node_leave(node) + @current_scope = T.must(@current_scope.parent) + end + + sig { params(node: Prism::BlockLocalVariableNode).void } + def on_block_local_variable_node_enter(node) + add_token(node.location, :variable) + end + + sig { params(node: Prism::BlockParameterNode).void } + def on_block_parameter_node_enter(node) + name = node.name + @current_scope << name.to_sym if name + end + + sig { params(node: Prism::RequiredKeywordParameterNode).void } + def on_required_keyword_parameter_node_enter(node) + @current_scope << node.name + return unless visible?(node, @range) + + location = node.name_loc + add_token(location.copy(length: location.length - 1), :parameter) + end + + sig { params(node: Prism::OptionalKeywordParameterNode).void } + def on_optional_keyword_parameter_node_enter(node) + @current_scope << node.name + return unless visible?(node, @range) + + location = node.name_loc + add_token(location.copy(length: location.length - 1), :parameter) + end + + sig { params(node: Prism::KeywordRestParameterNode).void } + def on_keyword_rest_parameter_node_enter(node) + name = node.name + + if name + @current_scope << name.to_sym + + add_token(T.must(node.name_loc), :parameter) if visible?(node, @range) + end + end + + sig { params(node: Prism::OptionalParameterNode).void } + def on_optional_parameter_node_enter(node) + @current_scope << node.name + return unless visible?(node, @range) + + add_token(node.name_loc, :parameter) + end + + sig { params(node: Prism::RequiredParameterNode).void } + def on_required_parameter_node_enter(node) + @current_scope << node.name + return unless visible?(node, @range) + + add_token(node.location, :parameter) + end + + sig { params(node: Prism::RestParameterNode).void } + def on_rest_parameter_node_enter(node) + name = node.name + + if name + @current_scope << name.to_sym + + add_token(T.must(node.name_loc), :parameter) if visible?(node, @range) + end + end + + sig { params(node: Prism::SelfNode).void } + def on_self_node_enter(node) + return unless visible?(node, @range) + + add_token(node.location, :variable, [:default_library]) + end + + sig { params(node: Prism::LocalVariableWriteNode).void } + def on_local_variable_write_node_enter(node) + return unless visible?(node, @range) + + add_token(node.name_loc, @current_scope.type_for(node.name)) + end + + sig { params(node: Prism::LocalVariableReadNode).void } + def on_local_variable_read_node_enter(node) + return unless visible?(node, @range) + + # Numbered parameters + if /_\d+/.match?(node.name) + add_token(node.location, :parameter) + return + end + + add_token(node.location, @current_scope.type_for(node.name)) + end + + sig { params(node: Prism::LocalVariableAndWriteNode).void } + def on_local_variable_and_write_node_enter(node) + return unless visible?(node, @range) + + add_token(node.name_loc, @current_scope.type_for(node.name)) + end + + sig { params(node: Prism::LocalVariableOperatorWriteNode).void } + def on_local_variable_operator_write_node_enter(node) + return unless visible?(node, @range) + + add_token(node.name_loc, @current_scope.type_for(node.name)) + end + + sig { params(node: Prism::LocalVariableOrWriteNode).void } + def on_local_variable_or_write_node_enter(node) + return unless visible?(node, @range) + + add_token(node.name_loc, @current_scope.type_for(node.name)) + end + + sig { params(node: Prism::LocalVariableTargetNode).void } + def on_local_variable_target_node_enter(node) + # If we're inside a regex capture, Prism will add LocalVariableTarget nodes for each captured variable. + # Unfortunately, if the regex contains a backslash, the location will be incorrect and we'll end up highlighting + # the entire regex as a local variable. We process these captures in process_regexp_locals instead and then + # prevent pushing local variable target tokens. See https://github.com/ruby/prism/issues/1912 + return if @inside_regex_capture + + return unless visible?(node, @range) + + add_token(node.location, @current_scope.type_for(node.name)) + end + + sig { params(node: Prism::ClassNode).void } + def on_class_node_enter(node) + return unless visible?(node, @range) + + add_token(node.constant_path.location, :class, [:declaration]) + + superclass = node.superclass + add_token(superclass.location, :class) if superclass + end + + sig { params(node: Prism::ModuleNode).void } + def on_module_node_enter(node) + return unless visible?(node, @range) + + add_token(node.constant_path.location, :namespace, [:declaration]) + end + + private + + sig { params(location: Prism::Location, type: Symbol, modifiers: T::Array[Symbol]).void } + def add_token(location, type, modifiers = []) + length = location.end_offset - location.start_offset + modifiers_indices = modifiers.filter_map { |modifier| TOKEN_MODIFIERS[modifier] } + @_response.push( + SemanticToken.new( + location: location, + length: length, + type: T.must(TOKEN_TYPES[type]), + modifier: modifiers_indices, + ), + ) + end + + # Textmate provides highlighting for a subset of these special Ruby-specific methods. We want to utilize that + # highlighting, so we avoid making a semantic token for it. + sig { params(method_name: String).returns(T::Boolean) } + def special_method?(method_name) + SPECIAL_RUBY_METHODS.include?(method_name) + end + + sig { params(node: Prism::CallNode).void } + def process_regexp_locals(node) + receiver = node.receiver + + # The regexp needs to be the receiver of =~ for local variable capture + return unless receiver.is_a?(Prism::RegularExpressionNode) + + content = receiver.content + loc = receiver.content_loc + + # For each capture name we find in the regexp, look for a local in the current_scope + Regexp.new(content, Regexp::FIXEDENCODING).names.each do |name| + # The +3 is to compensate for the "(?<" part of the capture name + capture_name_offset = T.must(content.index("(?<#{name}>")) + 3 + local_var_loc = loc.copy(start_offset: loc.start_offset + capture_name_offset, length: name.length) + + add_token(local_var_loc, @current_scope.type_for(name)) + end + end + end + end +end diff --git a/lib/ruby_lsp/listeners/signature_help.rb b/lib/ruby_lsp/listeners/signature_help.rb new file mode 100644 index 000000000..199a6abcd --- /dev/null +++ b/lib/ruby_lsp/listeners/signature_help.rb @@ -0,0 +1,74 @@ +# typed: strict +# frozen_string_literal: true + +module RubyLsp + module Listeners + class SignatureHelp < Listener + extend T::Sig + extend T::Generic + + ResponseType = type_member { { fixed: T.nilable(T.any(Interface::SignatureHelp, T::Hash[Symbol, T.untyped])) } } + + sig { override.returns(ResponseType) } + attr_reader :_response + + sig do + params( + nesting: T::Array[String], + index: RubyIndexer::Index, + dispatcher: Prism::Dispatcher, + ).void + end + def initialize(nesting, index, dispatcher) + @nesting = nesting + @index = index + @_response = T.let(nil, ResponseType) + + super(dispatcher) + dispatcher.register(self, :on_call_node_enter) + end + + sig { params(node: Prism::CallNode).void } + def on_call_node_enter(node) + return if DependencyDetector.instance.typechecker + return unless self_receiver?(node) + + message = node.message + return unless message + + target_method = @index.resolve_method(message, @nesting.join("::")) + return unless target_method + + parameters = target_method.parameters + name = target_method.name + + # If the method doesn't have any parameters, there's no need to show signature help + return if parameters.empty? + + label = "#{name}(#{parameters.map(&:decorated_name).join(", ")})" + + arguments_node = node.arguments + arguments = arguments_node&.arguments || [] + active_parameter = (arguments.length - 1).clamp(0, parameters.length - 1) + + # If there are arguments, then we need to check if there's a trailing comma after the end of the last argument + # to advance the active parameter to the next one + if arguments_node && + node.slice.byteslice(arguments_node.location.end_offset - node.location.start_offset) == "," + active_parameter += 1 + end + + @_response = Interface::SignatureHelp.new( + signatures: [ + Interface::SignatureInformation.new( + label: label, + parameters: parameters.map { |param| Interface::ParameterInformation.new(label: param.name) }, + documentation: markdown_from_index_entries("", target_method), + ), + ], + active_parameter: active_parameter, + ) + end + end + end +end diff --git a/lib/ruby_lsp/requests/code_lens.rb b/lib/ruby_lsp/requests/code_lens.rb index 7dc53f1bc..82424377d 100644 --- a/lib/ruby_lsp/requests/code_lens.rb +++ b/lib/ruby_lsp/requests/code_lens.rb @@ -3,6 +3,8 @@ require "shellwords" +require "ruby_lsp/listeners/code_lens" + module RubyLsp module Requests # ![Code lens demo](../../code_lens.gif) @@ -22,7 +24,7 @@ module Requests # class Test < Minitest::Test # end # ``` - class CodeLens < ExtensibleListener + class CodeLens < Request extend T::Sig extend T::Generic @@ -37,21 +39,6 @@ def provider ResponseType = type_member { { fixed: T::Array[Interface::CodeLens] } } - BASE_COMMAND = T.let( - begin - Bundler.with_original_env { Bundler.default_lockfile } - "bundle exec ruby" - rescue Bundler::GemfileNotFound - "ruby" - end + " -Itest ", - String, - ) - ACCESS_MODIFIERS = T.let([:public, :private, :protected], T::Array[Symbol]) - SUPPORTED_TEST_LIBRARIES = T.let(["minitest", "test-unit"], T::Array[String]) - - sig { override.returns(ResponseType) } - attr_reader :_response - sig do params( uri: URI::Generic, @@ -60,209 +47,21 @@ def provider ).void end def initialize(uri, lenses_configuration, dispatcher) - @uri = T.let(uri, URI::Generic) - @_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(Symbol)]]) - @class_stack = T.let([], T::Array[String]) - @group_id = T.let(1, Integer) - @group_id_stack = T.let([], T::Array[Integer]) - @lenses_configuration = lenses_configuration - - super(dispatcher) - - dispatcher.register( - self, - :on_class_node_enter, - :on_class_node_leave, - :on_def_node_enter, - :on_call_node_enter, - :on_call_node_leave, - ) - end - - sig { params(node: Prism::ClassNode).void } - def on_class_node_enter(node) - @visibility_stack.push([:public, :public]) - class_name = node.constant_path.slice - @class_stack.push(class_name) - - if @path && class_name.end_with?("Test") - add_test_code_lens( - node, - name: class_name, - command: generate_test_command(class_name: class_name), - kind: :group, - ) - end - - @group_id_stack.push(@group_id) - @group_id += 1 - end - - sig { params(node: Prism::ClassNode).void } - def on_class_node_leave(node) - @visibility_stack.pop - @class_stack.pop - @group_id_stack.pop - end - - sig { params(node: Prism::DefNode).void } - def on_def_node_enter(node) - class_name = @class_stack.last - return unless class_name&.end_with?("Test") - - visibility, _ = @visibility_stack.last - if visibility == :public - method_name = node.name.to_s - if @path && method_name.start_with?("test_") - add_test_code_lens( - node, - name: method_name, - command: generate_test_command(method_name: method_name, class_name: class_name), - kind: :example, - ) - end - end - end - - sig { params(node: Prism::CallNode).void } - def on_call_node_enter(node) - name = node.name - arguments = node.arguments - - # If we found `private` by itself or `private def foo` - if ACCESS_MODIFIERS.include?(name) - if arguments.nil? - @visibility_stack.pop - @visibility_stack.push([name, name]) - elsif arguments.arguments.first.is_a?(Prism::DefNode) - visibility, _ = @visibility_stack.pop - @visibility_stack.push([name, visibility]) - end - - return - end - - if @path&.include?(GEMFILE_NAME) && name == :gem && arguments - return unless @lenses_configuration.enabled?(:gemfileLinks) - - first_argument = arguments.arguments.first - return unless first_argument.is_a?(Prism::StringNode) - - remote = resolve_gem_remote(first_argument) - return unless remote - - add_open_gem_remote_code_lens(node, remote) - end - end - - sig { params(node: Prism::CallNode).void } - def on_call_node_leave(node) - _, prev_visibility = @visibility_stack.pop - @visibility_stack.push([prev_visibility, prev_visibility]) - end - - sig { override.params(addon: Addon).returns(T.nilable(Listener[ResponseType])) } - def initialize_external_listener(addon) - addon.create_code_lens_listener(@uri, @dispatcher) - end - - sig { override.params(other: Listener[ResponseType]).returns(T.self_type) } - def merge_response!(other) - @_response.concat(other.response) - self - end - - private - - sig { params(node: Prism::Node, name: String, command: String, kind: Symbol).void } - def add_test_code_lens(node, name:, command:, kind:) - # don't add code lenses if the test library is not supported or unknown - return unless SUPPORTED_TEST_LIBRARIES.include?(DependencyDetector.instance.detected_test_library) && @path - - arguments = [ - @path, - name, - command, - { - start_line: node.location.start_line - 1, - start_column: node.location.start_column, - end_line: node.location.end_line - 1, - end_column: node.location.end_column, - }, - ] - - grouping_data = { group_id: @group_id_stack.last, kind: kind } - grouping_data[:id] = @group_id if kind == :group - - @_response << create_code_lens( - node, - title: "Run", - command_name: "rubyLsp.runTest", - arguments: arguments, - data: { type: "test", **grouping_data }, + super() + @listeners = T.let( + [Listeners::CodeLens.new(uri, lenses_configuration, dispatcher)], + T::Array[Listener[ResponseType]], ) - @_response << create_code_lens( - node, - title: "Run In Terminal", - command_name: "rubyLsp.runTestInTerminal", - arguments: arguments, - data: { type: "test_in_terminal", **grouping_data }, - ) - - @_response << create_code_lens( - node, - title: "Debug", - command_name: "rubyLsp.debugTest", - arguments: arguments, - data: { type: "debug", **grouping_data }, - ) - end - - sig { params(gem_name: Prism::StringNode).returns(T.nilable(String)) } - def resolve_gem_remote(gem_name) - spec = Gem::Specification.stubs.find { |gem| gem.name == gem_name.content }&.to_spec - return if spec.nil? - - [spec.homepage, spec.metadata["source_code_uri"]].compact.find do |page| - page.start_with?("https://github.com", "https://gitlab.com") + Addon.addons.each do |addon| + addon_listener = addon.create_code_lens_listener(uri, dispatcher) + @listeners << addon_listener if addon_listener end end - sig { params(class_name: String, method_name: T.nilable(String)).returns(String) } - def generate_test_command(class_name:, method_name: nil) - command = BASE_COMMAND + T.must(@path) - - case DependencyDetector.instance.detected_test_library - when "minitest" - command += if method_name - " --name " + "/#{Shellwords.escape(class_name + "#" + method_name)}/" - else - " --name " + "/#{Shellwords.escape(class_name)}/" - end - when "test-unit" - command += " --testcase " + "/#{Shellwords.escape(class_name)}/" - - if method_name - command += " --name " + Shellwords.escape(method_name) - end - end - - command - end - - sig { params(node: Prism::CallNode, remote: String).void } - def add_open_gem_remote_code_lens(node, remote) - @_response << create_code_lens( - node, - title: "Open remote", - command_name: "rubyLsp.openLink", - arguments: [remote], - data: { type: "link" }, - ) + sig { override.returns(ResponseType) } + def response + @listeners.flat_map(&:response) end end end diff --git a/lib/ruby_lsp/requests/completion.rb b/lib/ruby_lsp/requests/completion.rb index ffd4c9e53..c2599b09c 100644 --- a/lib/ruby_lsp/requests/completion.rb +++ b/lib/ruby_lsp/requests/completion.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require "ruby_lsp/listeners/completion" + module RubyLsp module Requests # ![Completion demo](../../completion.gif) @@ -22,7 +24,7 @@ module Requests # # RubyLsp::Requests:: # --> completion: suggests `Completion`, `Hover`, ... # ``` - class Completion < Listener + class Completion < Request extend T::Sig extend T::Generic @@ -43,266 +45,66 @@ def provider ResponseType = type_member { { fixed: T::Array[Interface::CompletionItem] } } - sig { override.returns(ResponseType) } - attr_reader :_response - sig do params( + document: Document, index: RubyIndexer::Index, - nesting: T::Array[String], - dispatcher: Prism::Dispatcher, + position: T::Hash[Symbol, T.untyped], typechecker_enabled: T::Boolean, + dispatcher: Prism::Dispatcher, ).void end - def initialize(index, nesting, dispatcher, typechecker_enabled) - super(dispatcher) - @_response = T.let([], ResponseType) - @index = index - @nesting = nesting - @typechecker_enabled = typechecker_enabled - - dispatcher.register( - self, - :on_string_node_enter, - :on_constant_path_node_enter, - :on_constant_read_node_enter, - :on_call_node_enter, - ) - end - - sig { params(node: Prism::StringNode).void } - def on_string_node_enter(node) - @index.search_require_paths(node.content).map!(&:require_path).sort!.each do |path| - @_response << build_completion(T.must(path), node) - end - end - - # Handle completion on regular constant references (e.g. `Bar`) - sig { params(node: Prism::ConstantReadNode).void } - def on_constant_read_node_enter(node) - return if DependencyDetector.instance.typechecker - - name = node.slice - candidates = @index.prefix_search(name, @nesting) - candidates.each do |entries| - complete_name = T.must(entries.first).name - @_response << build_entry_completion( - complete_name, - name, - node, - entries, - top_level?(complete_name), - ) - end - end - - # Handle completion on namespaced constant references (e.g. `Foo::Bar`) - sig { params(node: Prism::ConstantPathNode).void } - def on_constant_path_node_enter(node) - return if DependencyDetector.instance.typechecker - - name = node.slice - - top_level_reference = if name.start_with?("::") - name = name.delete_prefix("::") - true - else - false - end - - # If we're trying to provide completion for an aliased namespace, we need to first discover it's real name in - # order to find which possible constants match the desired search - *namespace, incomplete_name = name.split("::") - aliased_namespace = T.must(namespace).join("::") - namespace_entries = @index.resolve(aliased_namespace, @nesting) - return unless namespace_entries - - real_namespace = @index.follow_aliased_namespace(T.must(namespace_entries.first).name) - - candidates = @index.prefix_search("#{real_namespace}::#{incomplete_name}", top_level_reference ? [] : @nesting) - candidates.each do |entries| - # The only time we may have a private constant reference from outside of the namespace is if we're dealing - # with ConstantPath and the entry name doesn't start with the current nesting - first_entry = T.must(entries.first) - next if first_entry.visibility == :private && !first_entry.name.start_with?("#{@nesting}::") - - constant_name = T.must(first_entry.name.split("::").last) - - full_name = aliased_namespace.empty? ? constant_name : "#{aliased_namespace}::#{constant_name}" - - @_response << build_entry_completion( - full_name, - name, - node, - entries, - top_level_reference || top_level?(T.must(entries.first).name), - ) - end - end - - sig { params(node: Prism::CallNode).void } - def on_call_node_enter(node) - return if @typechecker_enabled - return unless self_receiver?(node) - - name = node.message - return unless name - - receiver_entries = @index[@nesting.join("::")] - return unless receiver_entries - - receiver = T.must(receiver_entries.first) - - @index.prefix_search(name).each do |entries| - entry = entries.find { |e| e.is_a?(RubyIndexer::Entry::Member) && e.owner&.name == receiver.name } - next unless entry - - @_response << build_method_completion(T.cast(entry, RubyIndexer::Entry::Member), node) - end - end - - private - - sig do - params( - entry: RubyIndexer::Entry::Member, - node: Prism::CallNode, - ).returns(Interface::CompletionItem) - end - def build_method_completion(entry, node) - name = entry.name - - Interface::CompletionItem.new( - label: name, - filter_text: name, - text_edit: Interface::TextEdit.new(range: range_from_node(node), new_text: name), - kind: Constant::CompletionItemKind::METHOD, - label_details: Interface::CompletionItemLabelDetails.new( - detail: "(#{entry.parameters.map(&:decorated_name).join(", ")})", - description: entry.file_name, - ), - documentation: markdown_from_index_entries(name, entry), + def initialize(document, index, position, typechecker_enabled, dispatcher) + super() + @target = T.let(nil, T.nilable(Prism::Node)) + @dispatcher = dispatcher + # Completion always receives the position immediately after the character that was just typed. Here we adjust it + # back by 1, so that we find the right node + char_position = document.create_scanner.find_char_position(position) - 1 + matched, parent, nesting = document.locate( + document.tree, + char_position, + node_types: [Prism::CallNode, Prism::ConstantReadNode, Prism::ConstantPathNode], ) - end - - sig { params(label: String, node: Prism::StringNode).returns(Interface::CompletionItem) } - def build_completion(label, node) - # We should use the content location as we only replace the content and not the delimiters of the string - loc = node.content_loc - Interface::CompletionItem.new( - label: label, - text_edit: Interface::TextEdit.new( - range: range_from_location(loc), - new_text: label, - ), - kind: Constant::CompletionItemKind::FILE, + @listener = T.let( + Listeners::Completion.new(index, nesting, typechecker_enabled, dispatcher), + Listener[ResponseType], ) - end - sig do - params( - real_name: String, - incomplete_name: String, - node: Prism::Node, - entries: T::Array[RubyIndexer::Entry], - top_level: T::Boolean, - ).returns(Interface::CompletionItem) - end - def build_entry_completion(real_name, incomplete_name, node, entries, top_level) - first_entry = T.must(entries.first) - kind = case first_entry - when RubyIndexer::Entry::Class - Constant::CompletionItemKind::CLASS - when RubyIndexer::Entry::Module - Constant::CompletionItemKind::MODULE - when RubyIndexer::Entry::Constant - Constant::CompletionItemKind::CONSTANT - else - Constant::CompletionItemKind::REFERENCE - end - - insertion_text = real_name.dup - filter_text = real_name.dup - - # If we have two entries with the same name inside the current namespace and the user selects the top level - # option, we have to ensure it's prefixed with `::` or else we're completing the wrong constant. For example: - # If we have the index with ["Foo::Bar", "Bar"], and we're providing suggestions for `B` inside a `Foo` module, - # then selecting the `Foo::Bar` option needs to complete to `Bar` and selecting the top level `Bar` option needs - # to complete to `::Bar`. - if top_level - insertion_text.prepend("::") - filter_text.prepend("::") + return unless matched && parent + + @target = case matched + when Prism::CallNode + message = matched.message + + if message == "require" + args = matched.arguments&.arguments + return if args.nil? || args.is_a?(Prism::ForwardingArgumentsNode) + + argument = args.first + return unless argument.is_a?(Prism::StringNode) + return unless (argument.location.start_offset..argument.location.end_offset).cover?(char_position) + + argument + else + matched + end + when Prism::ConstantReadNode, Prism::ConstantPathNode + if parent.is_a?(Prism::ConstantPathNode) && matched.is_a?(Prism::ConstantReadNode) + parent + else + matched + end end - - # If the user is searching for a constant inside the current namespace, then we prefer completing the short name - # of that constant. E.g.: - # - # module Foo - # class Bar - # end - # - # Foo::B # --> completion inserts `Bar` instead of `Foo::Bar` - # end - @nesting.each do |namespace| - prefix = "#{namespace}::" - shortened_name = insertion_text.delete_prefix(prefix) - - # If a different entry exists for the shortened name, then there's a conflict and we should not shorten it - conflict_name = "#{@nesting.join("::")}::#{shortened_name}" - break if real_name != conflict_name && @index[conflict_name] - - insertion_text = shortened_name - - # If the user is typing a fully qualified name `Foo::Bar::Baz`, then we should not use the short name (e.g.: - # `Baz`) as filtering. So we only shorten the filter text if the user is not including the namespaces in their - # typing - filter_text.delete_prefix!(prefix) unless incomplete_name.start_with?(prefix) - end - - # When using a top level constant reference (e.g.: `::Bar`), the editor includes the `::` as part of the filter. - # For these top level references, we need to include the `::` as part of the filter text or else it won't match - # the right entries in the index - Interface::CompletionItem.new( - label: real_name, - filter_text: filter_text, - text_edit: Interface::TextEdit.new( - range: range_from_node(node), - new_text: insertion_text, - ), - kind: kind, - label_details: Interface::CompletionItemLabelDetails.new( - description: entries.map(&:file_name).join(","), - ), - documentation: markdown_from_index_entries(real_name, entries), - ) end - # Check if there are any conflicting names for `entry_name`, which would require us to use a top level reference. - # For example: - # - # ```ruby - # class Bar; end - # - # module Foo - # class Bar; end - # - # # in this case, the completion for `Bar` conflicts with `Foo::Bar`, so we can't suggest `Bar` as the - # # completion, but instead need to suggest `::Bar` - # B - # end - # ``` - sig { params(entry_name: String).returns(T::Boolean) } - def top_level?(entry_name) - @nesting.length.downto(0).each do |i| - prefix = T.must(@nesting[0...i]).join("::") - full_name = prefix.empty? ? entry_name : "#{prefix}::#{entry_name}" - next if full_name == entry_name - - return true if @index[full_name] - end + sig { override.returns(ResponseType) } + def response + return [] unless @target - false + @dispatcher.dispatch_once(@target) + @listener.response end end end diff --git a/lib/ruby_lsp/requests/definition.rb b/lib/ruby_lsp/requests/definition.rb index 9d95c314d..dd9c11db2 100644 --- a/lib/ruby_lsp/requests/definition.rb +++ b/lib/ruby_lsp/requests/definition.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require "ruby_lsp/listeners/definition" + module RubyLsp module Requests # ![Definition demo](../../definition.gif) @@ -22,176 +24,59 @@ module Requests # require "some_gem/file" # <- Request go to definition on this string will take you to the file # Product.new # <- Request go to definition on this class name will take you to its declaration. # ``` - class Definition < ExtensibleListener + class Definition < Request extend T::Sig extend T::Generic ResponseType = type_member { { fixed: T.nilable(T.any(T::Array[Interface::Location], Interface::Location)) } } - sig { override.returns(ResponseType) } - attr_reader :_response - sig do params( - uri: URI::Generic, - nesting: T::Array[String], + document: Document, index: RubyIndexer::Index, + position: T::Hash[Symbol, T.untyped], dispatcher: Prism::Dispatcher, typechecker_enabled: T::Boolean, ).void end - def initialize(uri, nesting, index, dispatcher, typechecker_enabled) - @uri = uri - @nesting = nesting - @index = index - @_response = T.let(nil, ResponseType) - @typechecker_enabled = typechecker_enabled - - super(dispatcher) - - dispatcher.register( - self, - :on_call_node_enter, - :on_constant_read_node_enter, - :on_constant_path_node_enter, + def initialize(document, index, position, dispatcher, typechecker_enabled) + super() + target, parent, nesting = document.locate_node( + position, + node_types: [Prism::CallNode, Prism::ConstantReadNode, Prism::ConstantPathNode], ) - end - - sig { override.params(addon: Addon).returns(T.nilable(RubyLsp::Listener[ResponseType])) } - def initialize_external_listener(addon) - addon.create_definition_listener(@uri, @nesting, @index, @dispatcher) - end - sig { override.params(other: Listener[ResponseType]).returns(T.self_type) } - def merge_response!(other) - other_response = other._response - - case @_response - when Interface::Location - @_response = [@_response, *other_response] - when Array - @_response.concat(Array(other_response)) - when nil - @_response = other_response - end + target = parent if target.is_a?(Prism::ConstantReadNode) && parent.is_a?(Prism::ConstantPathNode) - self - end - - sig { params(node: Prism::CallNode).void } - def on_call_node_enter(node) - message = node.name - - if message == :require || message == :require_relative - handle_require_definition(node) - else - handle_method_definition(node) + @listeners = T.let( + [Listeners::Definition.new(document.uri, nesting, index, dispatcher, typechecker_enabled)], + T::Array[Listener[T.nilable(T.any(T::Array[Interface::Location], Interface::Location))]], + ) + Addon.addons.each do |addon| + addon_listener = addon.create_definition_listener(document.uri, nesting, index, dispatcher) + @listeners << addon_listener if addon_listener end - end - sig { params(node: Prism::ConstantPathNode).void } - def on_constant_path_node_enter(node) - find_in_index(node.slice) + @target = T.let(target, T.nilable(Prism::Node)) + @dispatcher = dispatcher end - sig { params(node: Prism::ConstantReadNode).void } - def on_constant_read_node_enter(node) - find_in_index(node.slice) - end - - private - - sig { params(node: Prism::CallNode).void } - def handle_method_definition(node) - return unless self_receiver?(node) - - message = node.message - return unless message - - target_method = @index.resolve_method(message, @nesting.join("::")) - return unless target_method - - location = target_method.location - file_path = target_method.file_path - return if @typechecker_enabled && not_in_dependencies?(file_path) - - @_response = Interface::Location.new( - uri: URI::Generic.from_path(path: file_path).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), - ), - ) - end - - sig { params(node: Prism::CallNode).void } - def handle_require_definition(node) - message = node.name - arguments = node.arguments - return unless arguments - - argument = arguments.arguments.first - return unless argument.is_a?(Prism::StringNode) - - case message - when :require - entry = @index.search_require_paths(argument.content).find do |indexable_path| - indexable_path.require_path == argument.content - end - - if entry - candidate = entry.full_path - - @_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), - end: Interface::Position.new(line: 0, character: 0), - ), - ) + sig { override.returns(ResponseType) } + def response + @dispatcher.dispatch_once(@target) + result = [] + + @listeners.each do |listener| + res = listener.response + case res + when Interface::Location + result << res + when Array + result.concat(res) end - when :require_relative - required_file = "#{argument.content}.rb" - path = @uri.to_standardized_path - 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( - uri: URI::Generic.from_path(path: candidate).to_s, - range: Interface::Range.new( - start: Interface::Position.new(line: 0, character: 0), - end: Interface::Position.new(line: 0, character: 0), - ), - ) end - end - sig { params(value: String).void } - def find_in_index(value) - entries = @index.resolve(value, @nesting) - return unless entries - - # We should only allow jumping to the definition of private constants if the constant is defined in the same - # namespace as the reference - first_entry = T.must(entries.first) - return if first_entry.visibility == :private && first_entry.name != "#{@nesting.join("::")}::#{value}" - - @_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 - # in the project, even if the files are typed false - file_path = entry.file_path - next if DependencyDetector.instance.typechecker && not_in_dependencies?(file_path) - - Interface::Location.new( - uri: URI::Generic.from_path(path: file_path).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), - ), - ) - end + result if result.any? end end end diff --git a/lib/ruby_lsp/requests/document_highlight.rb b/lib/ruby_lsp/requests/document_highlight.rb index 880251108..3c179987b 100644 --- a/lib/ruby_lsp/requests/document_highlight.rb +++ b/lib/ruby_lsp/requests/document_highlight.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require "ruby_lsp/listeners/document_highlight" + module RubyLsp module Requests # ![Document highlight demo](../../document_highlight.gif) @@ -22,553 +24,27 @@ module Requests # FOO # should be highlighted as "read" # end # ``` - class DocumentHighlight < Listener + class DocumentHighlight < Request extend T::Sig ResponseType = type_member { { fixed: T::Array[Interface::DocumentHighlight] } } - GLOBAL_VARIABLE_NODES = T.let( - [ - Prism::GlobalVariableAndWriteNode, - Prism::GlobalVariableOperatorWriteNode, - Prism::GlobalVariableOrWriteNode, - Prism::GlobalVariableReadNode, - Prism::GlobalVariableTargetNode, - Prism::GlobalVariableWriteNode, - ], - T::Array[T.class_of(Prism::Node)], - ) - - INSTANCE_VARIABLE_NODES = T.let( - [ - Prism::InstanceVariableAndWriteNode, - Prism::InstanceVariableOperatorWriteNode, - Prism::InstanceVariableOrWriteNode, - Prism::InstanceVariableReadNode, - Prism::InstanceVariableTargetNode, - Prism::InstanceVariableWriteNode, - ], - T::Array[T.class_of(Prism::Node)], - ) - - CONSTANT_NODES = T.let( - [ - Prism::ConstantAndWriteNode, - Prism::ConstantOperatorWriteNode, - Prism::ConstantOrWriteNode, - Prism::ConstantReadNode, - Prism::ConstantTargetNode, - Prism::ConstantWriteNode, - ], - T::Array[T.class_of(Prism::Node)], - ) - - CONSTANT_PATH_NODES = T.let( - [ - Prism::ConstantPathAndWriteNode, - Prism::ConstantPathNode, - Prism::ConstantPathOperatorWriteNode, - Prism::ConstantPathOrWriteNode, - Prism::ConstantPathTargetNode, - Prism::ConstantPathWriteNode, - ], - T::Array[T.class_of(Prism::Node)], - ) - - CLASS_VARIABLE_NODES = T.let( - [ - Prism::ClassVariableAndWriteNode, - Prism::ClassVariableOperatorWriteNode, - Prism::ClassVariableOrWriteNode, - Prism::ClassVariableReadNode, - Prism::ClassVariableTargetNode, - Prism::ClassVariableWriteNode, - ], - T::Array[T.class_of(Prism::Node)], - ) - - LOCAL_NODES = T.let( - [ - Prism::LocalVariableAndWriteNode, - Prism::LocalVariableOperatorWriteNode, - Prism::LocalVariableOrWriteNode, - Prism::LocalVariableReadNode, - Prism::LocalVariableTargetNode, - Prism::LocalVariableWriteNode, - Prism::BlockParameterNode, - Prism::RequiredParameterNode, - Prism::RequiredKeywordParameterNode, - Prism::OptionalKeywordParameterNode, - Prism::RestParameterNode, - Prism::OptionalParameterNode, - Prism::KeywordRestParameterNode, - ], - T::Array[T.class_of(Prism::Node)], - ) - - sig { override.returns(ResponseType) } - attr_reader :_response - sig do params( - target: T.nilable(Prism::Node), - parent: T.nilable(Prism::Node), + document: Document, + position: T::Hash[Symbol, T.untyped], dispatcher: Prism::Dispatcher, ).void end - def initialize(target, parent, dispatcher) - super(dispatcher) - - @_response = T.let([], T::Array[Interface::DocumentHighlight]) - - return unless target && parent - - highlight_target = - case target - when Prism::GlobalVariableReadNode, Prism::GlobalVariableAndWriteNode, Prism::GlobalVariableOperatorWriteNode, - Prism::GlobalVariableOrWriteNode, Prism::GlobalVariableTargetNode, Prism::GlobalVariableWriteNode, - Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableOperatorWriteNode, - Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableReadNode, Prism::InstanceVariableTargetNode, - Prism::InstanceVariableWriteNode, Prism::ConstantAndWriteNode, Prism::ConstantOperatorWriteNode, - Prism::ConstantOrWriteNode, Prism::ConstantPathAndWriteNode, Prism::ConstantPathNode, - Prism::ConstantPathOperatorWriteNode, Prism::ConstantPathOrWriteNode, Prism::ConstantPathTargetNode, - Prism::ConstantPathWriteNode, Prism::ConstantReadNode, Prism::ConstantTargetNode, Prism::ConstantWriteNode, - Prism::ClassVariableAndWriteNode, Prism::ClassVariableOperatorWriteNode, Prism::ClassVariableOrWriteNode, - Prism::ClassVariableReadNode, Prism::ClassVariableTargetNode, Prism::ClassVariableWriteNode, - Prism::LocalVariableAndWriteNode, Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode, - Prism::LocalVariableReadNode, Prism::LocalVariableTargetNode, Prism::LocalVariableWriteNode, - Prism::CallNode, Prism::BlockParameterNode, Prism::RequiredKeywordParameterNode, - Prism::RequiredKeywordParameterNode, Prism::KeywordRestParameterNode, Prism::OptionalParameterNode, - Prism::RequiredParameterNode, Prism::RestParameterNode - target - end - - @target = T.let(highlight_target, T.nilable(Prism::Node)) - @target_value = T.let(node_value(highlight_target), T.nilable(String)) - - if @target && @target_value - dispatcher.register( - self, - :on_call_node_enter, - :on_def_node_enter, - :on_global_variable_target_node_enter, - :on_instance_variable_target_node_enter, - :on_constant_path_target_node_enter, - :on_constant_target_node_enter, - :on_class_variable_target_node_enter, - :on_local_variable_target_node_enter, - :on_block_parameter_node_enter, - :on_required_parameter_node_enter, - :on_class_node_enter, - :on_module_node_enter, - :on_local_variable_read_node_enter, - :on_constant_path_node_enter, - :on_constant_read_node_enter, - :on_instance_variable_read_node_enter, - :on_class_variable_read_node_enter, - :on_global_variable_read_node_enter, - :on_constant_path_write_node_enter, - :on_constant_path_or_write_node_enter, - :on_constant_path_and_write_node_enter, - :on_constant_path_operator_write_node_enter, - :on_local_variable_write_node_enter, - :on_required_keyword_parameter_node_enter, - :on_optional_keyword_parameter_node_enter, - :on_rest_parameter_node_enter, - :on_optional_parameter_node_enter, - :on_keyword_rest_parameter_node_enter, - :on_local_variable_and_write_node_enter, - :on_local_variable_operator_write_node_enter, - :on_local_variable_or_write_node_enter, - :on_class_variable_write_node_enter, - :on_class_variable_or_write_node_enter, - :on_class_variable_operator_write_node_enter, - :on_class_variable_and_write_node_enter, - :on_constant_write_node_enter, - :on_constant_or_write_node_enter, - :on_constant_operator_write_node_enter, - :on_instance_variable_write_node_enter, - :on_constant_and_write_node_enter, - :on_instance_variable_or_write_node_enter, - :on_instance_variable_and_write_node_enter, - :on_instance_variable_operator_write_node_enter, - :on_global_variable_write_node_enter, - :on_global_variable_or_write_node_enter, - :on_global_variable_and_write_node_enter, - :on_global_variable_operator_write_node_enter, - ) - end - end - - sig { params(node: Prism::CallNode).void } - def on_call_node_enter(node) - return unless matches?(node, [Prism::CallNode, Prism::DefNode]) - - add_highlight(Constant::DocumentHighlightKind::READ, node.location) - end - - sig { params(node: Prism::DefNode).void } - def on_def_node_enter(node) - return unless matches?(node, [Prism::CallNode, Prism::DefNode]) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::GlobalVariableTargetNode).void } - def on_global_variable_target_node_enter(node) - return unless matches?(node, GLOBAL_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) - end - - sig { params(node: Prism::InstanceVariableTargetNode).void } - def on_instance_variable_target_node_enter(node) - return unless matches?(node, INSTANCE_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) - end - - sig { params(node: Prism::ConstantPathTargetNode).void } - def on_constant_path_target_node_enter(node) - return unless matches?(node, CONSTANT_PATH_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) - end - - sig { params(node: Prism::ConstantTargetNode).void } - def on_constant_target_node_enter(node) - return unless matches?(node, CONSTANT_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) - end - - sig { params(node: Prism::ClassVariableTargetNode).void } - def on_class_variable_target_node_enter(node) - return unless matches?(node, CLASS_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) - end - - sig { params(node: Prism::LocalVariableTargetNode).void } - def on_local_variable_target_node_enter(node) - return unless matches?(node, LOCAL_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) - end - - sig { params(node: Prism::BlockParameterNode).void } - def on_block_parameter_node_enter(node) - return unless matches?(node, LOCAL_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) - end - - sig { params(node: Prism::RequiredParameterNode).void } - def on_required_parameter_node_enter(node) - return unless matches?(node, LOCAL_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.location) - end - - sig { params(node: Prism::ClassNode).void } - def on_class_node_enter(node) - return unless matches?(node, CONSTANT_NODES + CONSTANT_PATH_NODES + [Prism::ClassNode]) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.constant_path.location) - end - - sig { params(node: Prism::ModuleNode).void } - def on_module_node_enter(node) - return unless matches?(node, CONSTANT_NODES + CONSTANT_PATH_NODES + [Prism::ModuleNode]) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.constant_path.location) - end - - sig { params(node: Prism::LocalVariableReadNode).void } - def on_local_variable_read_node_enter(node) - return unless matches?(node, LOCAL_NODES) - - add_highlight(Constant::DocumentHighlightKind::READ, node.location) - end - - sig { params(node: Prism::ConstantPathNode).void } - def on_constant_path_node_enter(node) - return unless matches?(node, CONSTANT_PATH_NODES) - - add_highlight(Constant::DocumentHighlightKind::READ, node.location) - end - - sig { params(node: Prism::ConstantReadNode).void } - def on_constant_read_node_enter(node) - return unless matches?(node, CONSTANT_NODES) - - add_highlight(Constant::DocumentHighlightKind::READ, node.location) - end - - sig { params(node: Prism::InstanceVariableReadNode).void } - def on_instance_variable_read_node_enter(node) - return unless matches?(node, INSTANCE_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::READ, node.location) - end - - sig { params(node: Prism::ClassVariableReadNode).void } - def on_class_variable_read_node_enter(node) - return unless matches?(node, CLASS_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::READ, node.location) - end - - sig { params(node: Prism::GlobalVariableReadNode).void } - def on_global_variable_read_node_enter(node) - return unless matches?(node, GLOBAL_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::READ, node.location) - end - - sig { params(node: Prism::ConstantPathWriteNode).void } - def on_constant_path_write_node_enter(node) - return unless matches?(node, CONSTANT_PATH_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.target.location) - end - - sig { params(node: Prism::ConstantPathOrWriteNode).void } - def on_constant_path_or_write_node_enter(node) - return unless matches?(node, CONSTANT_PATH_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.target.location) - end - - sig { params(node: Prism::ConstantPathAndWriteNode).void } - def on_constant_path_and_write_node_enter(node) - return unless matches?(node, CONSTANT_PATH_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.target.location) - end - - sig { params(node: Prism::ConstantPathOperatorWriteNode).void } - def on_constant_path_operator_write_node_enter(node) - return unless matches?(node, CONSTANT_PATH_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.target.location) - end - - sig { params(node: Prism::LocalVariableWriteNode).void } - def on_local_variable_write_node_enter(node) - return unless matches?(node, LOCAL_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::RequiredKeywordParameterNode).void } - def on_required_keyword_parameter_node_enter(node) - return unless matches?(node, LOCAL_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::OptionalKeywordParameterNode).void } - def on_optional_keyword_parameter_node_enter(node) - return unless matches?(node, LOCAL_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::RestParameterNode).void } - def on_rest_parameter_node_enter(node) - return unless matches?(node, LOCAL_NODES) - - name_loc = node.name_loc - add_highlight(Constant::DocumentHighlightKind::WRITE, name_loc) if name_loc - end - - sig { params(node: Prism::OptionalParameterNode).void } - def on_optional_parameter_node_enter(node) - return unless matches?(node, LOCAL_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::KeywordRestParameterNode).void } - def on_keyword_rest_parameter_node_enter(node) - return unless matches?(node, LOCAL_NODES) - - name_loc = node.name_loc - add_highlight(Constant::DocumentHighlightKind::WRITE, name_loc) if name_loc - end - - sig { params(node: Prism::LocalVariableAndWriteNode).void } - def on_local_variable_and_write_node_enter(node) - return unless matches?(node, LOCAL_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::LocalVariableOperatorWriteNode).void } - def on_local_variable_operator_write_node_enter(node) - return unless matches?(node, LOCAL_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::LocalVariableOrWriteNode).void } - def on_local_variable_or_write_node_enter(node) - return unless matches?(node, LOCAL_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::ClassVariableWriteNode).void } - def on_class_variable_write_node_enter(node) - return unless matches?(node, CLASS_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::ClassVariableOrWriteNode).void } - def on_class_variable_or_write_node_enter(node) - return unless matches?(node, CLASS_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::ClassVariableOperatorWriteNode).void } - def on_class_variable_operator_write_node_enter(node) - return unless matches?(node, CLASS_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::ClassVariableAndWriteNode).void } - def on_class_variable_and_write_node_enter(node) - return unless matches?(node, CLASS_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::ConstantWriteNode).void } - def on_constant_write_node_enter(node) - return unless matches?(node, CONSTANT_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) + def initialize(document, position, dispatcher) + super() + target, parent = document.locate_node(position) + @listener = T.let(Listeners::DocumentHighlight.new(target, parent, dispatcher), Listener[ResponseType]) end - sig { params(node: Prism::ConstantOrWriteNode).void } - def on_constant_or_write_node_enter(node) - return unless matches?(node, CONSTANT_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::ConstantOperatorWriteNode).void } - def on_constant_operator_write_node_enter(node) - return unless matches?(node, CONSTANT_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::InstanceVariableWriteNode).void } - def on_instance_variable_write_node_enter(node) - return unless matches?(node, INSTANCE_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::InstanceVariableOrWriteNode).void } - def on_instance_variable_or_write_node_enter(node) - return unless matches?(node, INSTANCE_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::InstanceVariableAndWriteNode).void } - def on_instance_variable_and_write_node_enter(node) - return unless matches?(node, INSTANCE_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::InstanceVariableOperatorWriteNode).void } - def on_instance_variable_operator_write_node_enter(node) - return unless matches?(node, INSTANCE_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::ConstantAndWriteNode).void } - def on_constant_and_write_node_enter(node) - return unless matches?(node, CONSTANT_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::GlobalVariableWriteNode).void } - def on_global_variable_write_node_enter(node) - return unless matches?(node, GLOBAL_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::GlobalVariableOrWriteNode).void } - def on_global_variable_or_write_node_enter(node) - return unless matches?(node, GLOBAL_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::GlobalVariableAndWriteNode).void } - def on_global_variable_and_write_node_enter(node) - return unless matches?(node, GLOBAL_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - sig { params(node: Prism::GlobalVariableOperatorWriteNode).void } - def on_global_variable_operator_write_node_enter(node) - return unless matches?(node, GLOBAL_VARIABLE_NODES) - - add_highlight(Constant::DocumentHighlightKind::WRITE, node.name_loc) - end - - private - - sig { params(node: Prism::Node, classes: T::Array[T.class_of(Prism::Node)]).returns(T.nilable(T::Boolean)) } - def matches?(node, classes) - classes.any? { |klass| @target.is_a?(klass) } && @target_value == node_value(node) - end - - sig { params(kind: Integer, location: Prism::Location).void } - def add_highlight(kind, location) - @_response << Interface::DocumentHighlight.new(range: range_from_location(location), kind: kind) - end - - sig { params(node: T.nilable(Prism::Node)).returns(T.nilable(String)) } - def node_value(node) - case node - when Prism::ConstantReadNode, Prism::ConstantPathNode, Prism::BlockArgumentNode, Prism::ConstantTargetNode, - Prism::ConstantPathWriteNode, Prism::ConstantPathTargetNode, Prism::ConstantPathOrWriteNode, - Prism::ConstantPathOperatorWriteNode, Prism::ConstantPathAndWriteNode - node.slice - when Prism::GlobalVariableReadNode, Prism::GlobalVariableAndWriteNode, Prism::GlobalVariableOperatorWriteNode, - Prism::GlobalVariableOrWriteNode, Prism::GlobalVariableTargetNode, Prism::GlobalVariableWriteNode, - Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableOperatorWriteNode, - Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableReadNode, Prism::InstanceVariableTargetNode, - Prism::InstanceVariableWriteNode, Prism::ConstantAndWriteNode, Prism::ConstantOperatorWriteNode, - Prism::ConstantOrWriteNode, Prism::ConstantWriteNode, Prism::ClassVariableAndWriteNode, - Prism::ClassVariableOperatorWriteNode, Prism::ClassVariableOrWriteNode, Prism::ClassVariableReadNode, - Prism::ClassVariableTargetNode, Prism::ClassVariableWriteNode, Prism::LocalVariableAndWriteNode, - Prism::LocalVariableOperatorWriteNode, Prism::LocalVariableOrWriteNode, Prism::LocalVariableReadNode, - Prism::LocalVariableTargetNode, Prism::LocalVariableWriteNode, Prism::DefNode, Prism::BlockParameterNode, - Prism::RequiredKeywordParameterNode, Prism::OptionalKeywordParameterNode, Prism::KeywordRestParameterNode, - Prism::OptionalParameterNode, Prism::RequiredParameterNode, Prism::RestParameterNode - - node.name.to_s - when Prism::CallNode - node.message - when Prism::ClassNode, Prism::ModuleNode - node.constant_path.slice - end + sig { override.returns(ResponseType) } + def response + @listener.response end end end diff --git a/lib/ruby_lsp/requests/document_link.rb b/lib/ruby_lsp/requests/document_link.rb index 4928ebee1..39d0bd2fe 100644 --- a/lib/ruby_lsp/requests/document_link.rb +++ b/lib/ruby_lsp/requests/document_link.rb @@ -1,7 +1,7 @@ # typed: strict # frozen_string_literal: true -require "ruby_lsp/requests/support/source_uri" +require "ruby_lsp/listeners/document_link" module RubyLsp module Requests @@ -18,7 +18,7 @@ module Requests # def format(source, maxwidth = T.unsafe(nil)) # end # ``` - class DocumentLink < Listener + class DocumentLink < Request extend T::Sig extend T::Generic @@ -33,57 +33,6 @@ def provider ResponseType = type_member { { fixed: T::Array[Interface::DocumentLink] } } - GEM_TO_VERSION_MAP = T.let( - [*::Gem::Specification.default_stubs, *::Gem::Specification.stubs].map! do |s| - [s.name, s.version.to_s] - end.to_h.freeze, - T::Hash[String, String], - ) - - class << self - extend T::Sig - - sig { returns(T::Hash[String, T::Hash[String, T::Hash[String, String]]]) } - def gem_paths - @gem_paths ||= T.let( - begin - lookup = {} - - Gem::Specification.stubs.each do |stub| - spec = stub.to_spec - lookup[spec.name] = {} - lookup[spec.name][spec.version.to_s] = {} - - Dir.glob("**/*.rb", base: "#{spec.full_gem_path}/").each do |path| - lookup[spec.name][spec.version.to_s][path] = "#{spec.full_gem_path}/#{path}" - end - end - - Gem::Specification.default_stubs.each do |stub| - spec = stub.to_spec - lookup[spec.name] = {} - lookup[spec.name][spec.version.to_s] = {} - prefix_matchers = Regexp.union(spec.require_paths.map do |rp| - Regexp.new("^#{rp}/") - end) - prefix_matcher = Regexp.union(prefix_matchers, //) - - spec.files.each do |file| - path = file.sub(prefix_matcher, "") - lookup[spec.name][spec.version.to_s][path] = "#{RbConfig::CONFIG["rubylibdir"]}/#{path}" - end - end - - lookup - end, - T.nilable(T::Hash[String, T::Hash[String, T::Hash[String, String]]]), - ) - end - end - - sig { override.returns(ResponseType) } - attr_reader :_response - sig do params( uri: URI::Generic, @@ -92,92 +41,13 @@ def gem_paths ).void end def initialize(uri, comments, dispatcher) - super(dispatcher) - - # Match the version based on the version in the RBI file name. Notice that the `@` symbol is sanitized to `%40` - # in the URI - 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]) - @lines_to_comments = T.let( - comments.to_h do |comment| - [comment.location.end_line, comment] - end, - T::Hash[Integer, Prism::Comment], - ) - - dispatcher.register( - self, - :on_def_node_enter, - :on_class_node_enter, - :on_module_node_enter, - :on_constant_write_node_enter, - :on_constant_path_write_node_enter, - ) - end - - sig { params(node: Prism::DefNode).void } - def on_def_node_enter(node) - extract_document_link(node) + super() + @listener = T.let(Listeners::DocumentLink.new(uri, comments, dispatcher), Listener[ResponseType]) end - sig { params(node: Prism::ClassNode).void } - def on_class_node_enter(node) - extract_document_link(node) - end - - sig { params(node: Prism::ModuleNode).void } - def on_module_node_enter(node) - extract_document_link(node) - end - - sig { params(node: Prism::ConstantWriteNode).void } - def on_constant_write_node_enter(node) - extract_document_link(node) - end - - sig { params(node: Prism::ConstantPathWriteNode).void } - def on_constant_path_write_node_enter(node) - extract_document_link(node) - end - - private - - sig { params(node: Prism::Node).void } - def extract_document_link(node) - comment = @lines_to_comments[node.location.start_line - 1] - return unless comment - - match = comment.location.slice.match(%r{source://.*#\d+$}) - return unless match - - uri = T.cast(URI(T.must(match[0])), URI::Source) - gem_version = resolve_version(uri) - return if gem_version.nil? - - 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( - range: range_from_location(comment.location), - target: "file://#{file_path}##{uri.line_number}", - tooltip: "Jump to #{file_path}##{uri.line_number}", - ) - end - - # Try to figure out the gem version for a source:// link. The order of precedence is: - # 1. The version in the URI - # 2. The version in the RBI file name - # 3. The version from the gemspec - sig { params(uri: URI::Source).returns(T.nilable(String)) } - def resolve_version(uri) - version = uri.gem_version - return version unless version.nil? || version.empty? - - return @gem_version unless @gem_version.nil? || @gem_version.empty? - - GEM_TO_VERSION_MAP[uri.gem_name] + sig { override.returns(ResponseType) } + def response + @listener.response end end end diff --git a/lib/ruby_lsp/requests/document_symbol.rb b/lib/ruby_lsp/requests/document_symbol.rb index 77faee853..883b41b06 100644 --- a/lib/ruby_lsp/requests/document_symbol.rb +++ b/lib/ruby_lsp/requests/document_symbol.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require "ruby_lsp/listeners/document_symbol" + module RubyLsp module Requests # ![Document symbol demo](../../document_symbol.gif) @@ -26,7 +28,7 @@ module Requests # end # end # ``` - class DocumentSymbol < ExtensibleListener + class DocumentSymbol < Request extend T::Sig extend T::Generic @@ -46,226 +48,23 @@ def provider ResponseType = type_member { { fixed: T::Array[Interface::DocumentSymbol] } } - ATTR_ACCESSORS = T.let([:attr_reader, :attr_writer, :attr_accessor].freeze, T::Array[Symbol]) - - class SymbolHierarchyRoot - extend T::Sig - - sig { returns(T::Array[Interface::DocumentSymbol]) } - attr_reader :children - - sig { void } - def initialize - @children = T.let([], T::Array[Interface::DocumentSymbol]) - end - end - - sig { override.returns(T::Array[Interface::DocumentSymbol]) } - attr_reader :_response - sig { params(dispatcher: Prism::Dispatcher).void } def initialize(dispatcher) - @root = T.let(SymbolHierarchyRoot.new, SymbolHierarchyRoot) - @_response = T.let(@root.children, T::Array[Interface::DocumentSymbol]) - @stack = T.let( - [@root], - T::Array[T.any(SymbolHierarchyRoot, Interface::DocumentSymbol)], - ) - - super - - dispatcher.register( - self, - :on_class_node_enter, - :on_class_node_leave, - :on_call_node_enter, - :on_constant_path_write_node_enter, - :on_constant_write_node_enter, - :on_def_node_enter, - :on_def_node_leave, - :on_module_node_enter, - :on_module_node_leave, - :on_instance_variable_write_node_enter, - :on_class_variable_write_node_enter, - :on_singleton_class_node_enter, - :on_singleton_class_node_leave, - ) - end - - sig { override.params(addon: Addon).returns(T.nilable(Listener[ResponseType])) } - def initialize_external_listener(addon) - addon.create_document_symbol_listener(@dispatcher) - end - - # Merges responses from other listeners - sig { override.params(other: Listener[ResponseType]).returns(T.self_type) } - def merge_response!(other) - @_response.concat(other.response) - self - end - - sig { params(node: Prism::ClassNode).void } - def on_class_node_enter(node) - @stack << create_document_symbol( - name: node.constant_path.location.slice, - kind: Constant::SymbolKind::CLASS, - range_location: node.location, - selection_range_location: node.constant_path.location, - ) - end - - sig { params(node: Prism::ClassNode).void } - def on_class_node_leave(node) - @stack.pop - end - - sig { params(node: Prism::SingletonClassNode).void } - def on_singleton_class_node_enter(node) - expression = node.expression - - @stack << create_document_symbol( - name: "<< #{expression.slice}", - kind: Constant::SymbolKind::NAMESPACE, - range_location: node.location, - selection_range_location: expression.location, - ) - end - - sig { params(node: Prism::SingletonClassNode).void } - def on_singleton_class_node_leave(node) - @stack.pop - end - - sig { params(node: Prism::CallNode).void } - def on_call_node_enter(node) - return unless ATTR_ACCESSORS.include?(node.name) && node.receiver.nil? - - arguments = node.arguments - return unless arguments - - arguments.arguments.each do |argument| - next unless argument.is_a?(Prism::SymbolNode) - - name = argument.value - next unless name - - create_document_symbol( - name: name, - kind: Constant::SymbolKind::FIELD, - range_location: argument.location, - selection_range_location: T.must(argument.value_loc), - ) - end - end - - sig { params(node: Prism::ConstantPathWriteNode).void } - def on_constant_path_write_node_enter(node) - create_document_symbol( - name: node.target.location.slice, - kind: Constant::SymbolKind::CONSTANT, - range_location: node.location, - selection_range_location: node.target.location, - ) - end - - sig { params(node: Prism::ConstantWriteNode).void } - def on_constant_write_node_enter(node) - create_document_symbol( - name: node.name.to_s, - kind: Constant::SymbolKind::CONSTANT, - range_location: node.location, - selection_range_location: node.name_loc, - ) - end - - sig { params(node: Prism::DefNode).void } - def on_def_node_leave(node) - @stack.pop - end - - sig { params(node: Prism::ModuleNode).void } - def on_module_node_enter(node) - @stack << create_document_symbol( - name: node.constant_path.location.slice, - kind: Constant::SymbolKind::MODULE, - range_location: node.location, - selection_range_location: node.constant_path.location, + super() + @listeners = T.let( + [Listeners::DocumentSymbol.new(dispatcher)], + T::Array[Listener[ResponseType]], ) - end - - sig { params(node: Prism::DefNode).void } - def on_def_node_enter(node) - receiver = node.receiver - previous_symbol = @stack.last - if receiver.is_a?(Prism::SelfNode) - name = "self.#{node.name}" - kind = Constant::SymbolKind::FUNCTION - elsif previous_symbol.is_a?(Interface::DocumentSymbol) && previous_symbol.name.start_with?("<<") - name = node.name.to_s - kind = Constant::SymbolKind::FUNCTION - else - name = node.name.to_s - kind = name == "initialize" ? Constant::SymbolKind::CONSTRUCTOR : Constant::SymbolKind::METHOD + Addon.addons.each do |addon| + addon_listener = addon.create_document_symbol_listener(dispatcher) + @listeners << addon_listener if addon_listener end - - symbol = create_document_symbol( - name: name, - kind: kind, - range_location: node.location, - selection_range_location: node.name_loc, - ) - - @stack << symbol - end - - sig { params(node: Prism::ModuleNode).void } - def on_module_node_leave(node) - @stack.pop - end - - sig { params(node: Prism::InstanceVariableWriteNode).void } - def on_instance_variable_write_node_enter(node) - create_document_symbol( - name: node.name.to_s, - kind: Constant::SymbolKind::VARIABLE, - range_location: node.name_loc, - selection_range_location: node.name_loc, - ) end - sig { params(node: Prism::ClassVariableWriteNode).void } - def on_class_variable_write_node_enter(node) - create_document_symbol( - name: node.name.to_s, - kind: Constant::SymbolKind::VARIABLE, - range_location: node.name_loc, - selection_range_location: node.name_loc, - ) - end - - private - - sig do - params( - name: String, - kind: Integer, - range_location: Prism::Location, - selection_range_location: Prism::Location, - ).returns(Interface::DocumentSymbol) - end - def create_document_symbol(name:, kind:, range_location:, selection_range_location:) - symbol = Interface::DocumentSymbol.new( - name: name, - kind: kind, - range: range_from_location(range_location), - selection_range: range_from_location(selection_range_location), - children: [], - ) - - T.must(@stack.last).children << symbol - - symbol + sig { override.returns(ResponseType) } + def response + @listeners.flat_map(&:response).compact end end end diff --git a/lib/ruby_lsp/requests/folding_ranges.rb b/lib/ruby_lsp/requests/folding_ranges.rb index 02ab560ea..0b2af34c1 100644 --- a/lib/ruby_lsp/requests/folding_ranges.rb +++ b/lib/ruby_lsp/requests/folding_ranges.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require "ruby_lsp/listeners/folding_ranges" + module RubyLsp module Requests # ![Folding ranges demo](../../folding_ranges.gif) @@ -15,7 +17,7 @@ module Requests # puts "Hello" # end # <-- folding range end # ``` - class FoldingRanges < Listener + class FoldingRanges < Request extend T::Sig extend T::Generic @@ -32,260 +34,13 @@ def provider sig { params(comments: T::Array[Prism::Comment], dispatcher: Prism::Dispatcher).void } def initialize(comments, dispatcher) - super(dispatcher) - - @_response = T.let([], ResponseType) - @requires = T.let([], T::Array[Prism::CallNode]) - @finalized_response = T.let(false, T::Boolean) - @comments = comments - - dispatcher.register( - self, - :on_if_node_enter, - :on_in_node_enter, - :on_rescue_node_enter, - :on_when_node_enter, - :on_interpolated_string_node_enter, - :on_array_node_enter, - :on_block_node_enter, - :on_case_node_enter, - :on_case_match_node_enter, - :on_class_node_enter, - :on_module_node_enter, - :on_for_node_enter, - :on_hash_node_enter, - :on_singleton_class_node_enter, - :on_unless_node_enter, - :on_until_node_enter, - :on_while_node_enter, - :on_else_node_enter, - :on_ensure_node_enter, - :on_begin_node_enter, - :on_def_node_enter, - :on_call_node_enter, - :on_lambda_node_enter, - ) + super() + @listener = T.let(Listeners::FoldingRanges.new(comments, dispatcher), Listener[ResponseType]) end sig { override.returns(ResponseType) } - def _response - unless @finalized_response - push_comment_ranges - emit_requires_range - @finalized_response = true - end - - @_response - end - - sig { params(node: Prism::IfNode).void } - def on_if_node_enter(node) - add_statements_range(node) - end - - sig { params(node: Prism::InNode).void } - def on_in_node_enter(node) - add_statements_range(node) - end - - sig { params(node: Prism::RescueNode).void } - def on_rescue_node_enter(node) - add_statements_range(node) - end - - sig { params(node: Prism::WhenNode).void } - def on_when_node_enter(node) - add_statements_range(node) - end - - sig { params(node: Prism::InterpolatedStringNode).void } - def on_interpolated_string_node_enter(node) - opening_loc = node.opening_loc || node.location - closing_loc = node.closing_loc || node.parts.last&.location || node.location - - add_lines_range(opening_loc.start_line, closing_loc.start_line - 1) - end - - sig { params(node: Prism::ArrayNode).void } - def on_array_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::BlockNode).void } - def on_block_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::CaseNode).void } - def on_case_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::CaseMatchNode).void } - def on_case_match_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::ClassNode).void } - def on_class_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::ModuleNode).void } - def on_module_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::ForNode).void } - def on_for_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::HashNode).void } - def on_hash_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::SingletonClassNode).void } - def on_singleton_class_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::UnlessNode).void } - def on_unless_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::UntilNode).void } - def on_until_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::WhileNode).void } - def on_while_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::ElseNode).void } - def on_else_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::EnsureNode).void } - def on_ensure_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::BeginNode).void } - def on_begin_node_enter(node) - add_simple_range(node) - end - - sig { params(node: Prism::DefNode).void } - def on_def_node_enter(node) - params = node.parameters - parameter_loc = params&.location - location = node.location - - if params && parameter_loc.end_line > location.start_line - # Multiline parameters - add_lines_range(location.start_line, parameter_loc.end_line) - add_lines_range(parameter_loc.end_line + 1, location.end_line - 1) - else - add_lines_range(location.start_line, location.end_line - 1) - end - end - - sig { params(node: Prism::CallNode).void } - def on_call_node_enter(node) - # If we find a require, don't visit the child nodes (prevent `super`), so that we can keep accumulating into - # the `@requires` array and then push the range whenever we find a node that isn't a CallNode - if require?(node) - @requires << node - return - end - - location = node.location - add_lines_range(location.start_line, location.end_line - 1) - end - - sig { params(node: Prism::LambdaNode).void } - def on_lambda_node_enter(node) - add_simple_range(node) - end - - private - - sig { void } - def push_comment_ranges - # Group comments that are on consecutive lines and then push ranges for each group that has at least 2 comments - @comments.chunk_while do |this, other| - this.location.end_line + 1 == other.location.start_line - end.each do |chunk| - next if chunk.length == 1 - - @_response << Interface::FoldingRange.new( - start_line: T.must(chunk.first).location.start_line - 1, - end_line: T.must(chunk.last).location.end_line - 1, - kind: "comment", - ) - end - end - - sig { void } - def emit_requires_range - if @requires.length > 1 - @_response << Interface::FoldingRange.new( - start_line: T.must(@requires.first).location.start_line - 1, - end_line: T.must(@requires.last).location.end_line - 1, - kind: "imports", - ) - end - - @requires.clear - end - - sig { params(node: Prism::CallNode).returns(T::Boolean) } - def require?(node) - message = node.message - return false unless message == "require" || message == "require_relative" - - receiver = node.receiver - return false unless receiver.nil? || receiver.slice == "Kernel" - - arguments = node.arguments&.arguments - return false unless arguments - - arguments.length == 1 && arguments.first.is_a?(Prism::StringNode) - end - - sig { params(node: T.any(Prism::IfNode, Prism::InNode, Prism::RescueNode, Prism::WhenNode)).void } - def add_statements_range(node) - statements = node.statements - return unless statements - - body = statements.body - return if body.empty? - - add_lines_range(node.location.start_line, T.must(body.last).location.end_line) - end - - sig { params(node: Prism::Node).void } - def add_simple_range(node) - location = node.location - add_lines_range(location.start_line, location.end_line - 1) - end - - sig { params(start_line: Integer, end_line: Integer).void } - def add_lines_range(start_line, end_line) - emit_requires_range - return if start_line >= end_line - - @_response << Interface::FoldingRange.new( - start_line: start_line - 1, - end_line: end_line - 1, - kind: "region", - ) + def response + @listener.response end end end diff --git a/lib/ruby_lsp/requests/hover.rb b/lib/ruby_lsp/requests/hover.rb index f9d6d8c47..67e0aa7d7 100644 --- a/lib/ruby_lsp/requests/hover.rb +++ b/lib/ruby_lsp/requests/hover.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require "ruby_lsp/listeners/hover" + module RubyLsp module Requests # ![Hover demo](../../hover.gif) @@ -13,7 +15,7 @@ module Requests # ```ruby # String # -> Hovering over the class reference will show all declaration locations and the documentation # ``` - class Hover < ExtensibleListener + class Hover < Request extend T::Sig extend T::Generic @@ -28,164 +30,62 @@ def provider ResponseType = type_member { { fixed: T.nilable(Interface::Hover) } } - ALLOWED_TARGETS = T.let( - [ - Prism::CallNode, - Prism::ConstantReadNode, - Prism::ConstantWriteNode, - Prism::ConstantPathNode, - ], - T::Array[T.class_of(Prism::Node)], - ) - - sig { override.returns(ResponseType) } - attr_reader :_response - sig do params( - uri: URI::Generic, + document: Document, index: RubyIndexer::Index, - nesting: T::Array[String], + position: T::Hash[Symbol, T.untyped], dispatcher: Prism::Dispatcher, typechecker_enabled: T::Boolean, ).void end - def initialize(uri, index, nesting, dispatcher, typechecker_enabled) - @path = T.let(uri.to_standardized_path, T.nilable(String)) - @index = index - @nesting = nesting - @_response = T.let(nil, ResponseType) - @typechecker_enabled = typechecker_enabled - - super(dispatcher) - dispatcher.register( - self, - :on_constant_read_node_enter, - :on_constant_write_node_enter, - :on_constant_path_node_enter, - :on_call_node_enter, + def initialize(document, index, position, dispatcher, typechecker_enabled) + super() + target, parent, nesting = document.locate_node( + position, + node_types: Listeners::Hover::ALLOWED_TARGETS, ) - end - - sig { override.params(addon: Addon).returns(T.nilable(Listener[ResponseType])) } - def initialize_external_listener(addon) - addon.create_hover_listener(@nesting, @index, @dispatcher) - 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 - else - @_response.contents.value << "\n\n" << other_response.contents.value - end - - self - end - - sig { params(node: Prism::ConstantReadNode).void } - def on_constant_read_node_enter(node) - return if DependencyDetector.instance.typechecker - - generate_hover(node.slice, node.location) - end - - sig { params(node: Prism::ConstantWriteNode).void } - def on_constant_write_node_enter(node) - return if DependencyDetector.instance.typechecker - - generate_hover(node.name.to_s, node.name_loc) - end - - sig { params(node: Prism::ConstantPathNode).void } - def on_constant_path_node_enter(node) - return if DependencyDetector.instance.typechecker - - generate_hover(node.slice, node.location) - end - - sig { params(node: Prism::CallNode).void } - def on_call_node_enter(node) - return unless self_receiver?(node) - if @path && File.basename(@path) == GEMFILE_NAME && node.name == :gem - generate_gem_hover(node) - return + if (Listeners::Hover::ALLOWED_TARGETS.include?(parent.class) && + !Listeners::Hover::ALLOWED_TARGETS.include?(target.class)) || + (parent.is_a?(Prism::ConstantPathNode) && target.is_a?(Prism::ConstantReadNode)) + target = parent end - return if @typechecker_enabled + @listeners = T.let([], T::Array[Listener[ResponseType]]) - message = node.message - return unless message + # Don't need to instantiate any listeners if there's no target + return unless target - target_method = @index.resolve_method(message, @nesting.join("::")) - return unless target_method - - location = target_method.location - - @_response = Interface::Hover.new( - range: range_from_location(location), - contents: markdown_from_index_entries(message, target_method), + uri = document.uri + @listeners = T.let( + [Listeners::Hover.new(uri, nesting, index, dispatcher, typechecker_enabled)], + T::Array[Listener[ResponseType]], ) - end - - private - - sig { params(name: String, location: Prism::Location).void } - def generate_hover(name, location) - entries = @index.resolve(name, @nesting) - return unless entries - - # We should only show hover for private constants if the constant is defined in the same namespace as the - # reference - first_entry = T.must(entries.first) - return if first_entry.visibility == :private && first_entry.name != "#{@nesting.join("::")}::#{name}" + Addon.addons.each do |addon| + addon_listener = addon.create_hover_listener(nesting, index, dispatcher) + @listeners << addon_listener if addon_listener + end - @_response = Interface::Hover.new( - range: range_from_location(location), - contents: markdown_from_index_entries(name, entries), - ) + @target = T.let(target, Prism::Node) + @dispatcher = dispatcher end - sig { params(node: Prism::CallNode).void } - def generate_gem_hover(node) - first_argument = node.arguments&.arguments&.first - return unless first_argument.is_a?(Prism::StringNode) - - spec = Gem::Specification.find_by_name(first_argument.content) - return unless spec - - info = T.let( - [ - spec.description, - spec.summary, - "This rubygem does not have a description or summary.", - ].find { |text| !text.nil? && !text.empty? }, - String, - ) + sig { override.returns(ResponseType) } + def response + @dispatcher.dispatch_once(@target) + responses = @listeners.map(&:response).compact - # Remove leading whitespace if a heredoc was used for the summary or description - info = info.gsub(/^ +/, "") + first_response, *other_responses = responses - markdown = <<~MARKDOWN - **#{spec.name}** (#{spec.version}) + return unless first_response - #{info} - MARKDOWN + # TODO: other_responses should never be nil. Check Sorbet + T.must(other_responses).each do |other_response| + first_response.contents.value << "\n\n" << other_response.contents.value + end - @_response = Interface::Hover.new( - range: range_from_location(node.location), - contents: Interface::MarkupContent.new( - kind: Constant::MarkupKind::MARKDOWN, - value: markdown, - ), - ) - rescue Gem::MissingSpecError - # Do nothing if the spec cannot be found + first_response end end end diff --git a/lib/ruby_lsp/requests/inlay_hints.rb b/lib/ruby_lsp/requests/inlay_hints.rb index f43fcd4a3..90f8ede57 100644 --- a/lib/ruby_lsp/requests/inlay_hints.rb +++ b/lib/ruby_lsp/requests/inlay_hints.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require "ruby_lsp/listeners/inlay_hints" + module RubyLsp module Requests # ![Inlay hint demo](../../inlay_hints.gif) @@ -36,7 +38,7 @@ module Requests # a: "hello", # } # ``` - class InlayHints < Listener + class InlayHints < Request extend T::Sig extend T::Generic @@ -51,71 +53,27 @@ def provider ResponseType = type_member { { fixed: T::Array[Interface::InlayHint] } } - RESCUE_STRING_LENGTH = T.let("rescue".length, Integer) - - sig { override.returns(ResponseType) } - attr_reader :_response - sig do params( - range: T::Range[Integer], + document: Document, + range: T::Hash[Symbol, T.untyped], hints_configuration: RequestConfig, dispatcher: Prism::Dispatcher, ).void end - def initialize(range, hints_configuration, dispatcher) - super(dispatcher) - - @_response = T.let([], ResponseType) - @range = range - @hints_configuration = hints_configuration - - dispatcher.register(self, :on_rescue_node_enter, :on_implicit_node_enter) - end - - sig { params(node: Prism::RescueNode).void } - def on_rescue_node_enter(node) - return unless @hints_configuration.enabled?(:implicitRescue) - return unless node.exceptions.empty? - - loc = node.location - return unless visible?(node, @range) - - @_response << Interface::InlayHint.new( - position: { line: loc.start_line - 1, character: loc.start_column + RESCUE_STRING_LENGTH }, - label: "StandardError", - padding_left: true, - tooltip: "StandardError is implied in a bare rescue", + def initialize(document, range, hints_configuration, dispatcher) + super() + start_line = range.dig(:start, :line) + end_line = range.dig(:end, :line) + @listener = T.let( + Listeners::InlayHints.new(start_line..end_line, hints_configuration, dispatcher), + Listener[ResponseType], ) end - sig { params(node: Prism::ImplicitNode).void } - def on_implicit_node_enter(node) - return unless @hints_configuration.enabled?(:implicitHashValue) - return unless visible?(node, @range) - - node_value = node.value - loc = node.location - tooltip = "" - node_name = "" - case node_value - when Prism::CallNode - node_name = node_value.name - tooltip = "This is a method call. Method name: #{node_name}" - when Prism::ConstantReadNode - node_name = node_value.name - tooltip = "This is a constant: #{node_name}" - when Prism::LocalVariableReadNode - node_name = node_value.name - tooltip = "This is a local variable: #{node_name}" - end - - @_response << Interface::InlayHint.new( - position: { line: loc.start_line - 1, character: loc.start_column + node_name.length + 1 }, - label: node_name, - padding_left: true, - tooltip: tooltip, - ) + sig { override.returns(ResponseType) } + def response + @listener.response end end end diff --git a/lib/ruby_lsp/requests/semantic_highlighting.rb b/lib/ruby_lsp/requests/semantic_highlighting.rb index 300c6f141..901a0f87b 100644 --- a/lib/ruby_lsp/requests/semantic_highlighting.rb +++ b/lib/ruby_lsp/requests/semantic_highlighting.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require "ruby_lsp/listeners/semantic_highlighting" + module RubyLsp module Requests # ![Semantic highlighting demo](../../semantic_highlighting.gif) @@ -18,10 +20,12 @@ module Requests # var # --> semantic highlighting: local variable # end # ``` - class SemanticHighlighting < Listener + class SemanticHighlighting < Request extend T::Sig extend T::Generic + ResponseType = type_member { { fixed: T::Array[Listeners::SemanticHighlighting::SemanticToken] } } + class << self extend T::Sig @@ -30,8 +34,8 @@ def provider Interface::SemanticTokensRegistrationOptions.new( document_selector: { scheme: "file", language: "ruby" }, legend: Interface::SemanticTokensLegend.new( - token_types: Requests::SemanticHighlighting::TOKEN_TYPES.keys, - token_modifiers: Requests::SemanticHighlighting::TOKEN_MODIFIERS.keys, + token_types: Listeners::SemanticHighlighting::TOKEN_TYPES.keys, + token_modifiers: Listeners::SemanticHighlighting::TOKEN_MODIFIERS.keys, ), range: true, full: { delta: false }, @@ -39,423 +43,15 @@ def provider end end - ResponseType = type_member { { fixed: T::Array[SemanticToken] } } - - TOKEN_TYPES = T.let( - { - namespace: 0, - type: 1, - class: 2, - enum: 3, - interface: 4, - struct: 5, - typeParameter: 6, - parameter: 7, - variable: 8, - property: 9, - enumMember: 10, - event: 11, - function: 12, - method: 13, - macro: 14, - keyword: 15, - modifier: 16, - comment: 17, - string: 18, - number: 19, - regexp: 20, - operator: 21, - decorator: 22, - }.freeze, - T::Hash[Symbol, Integer], - ) - - TOKEN_MODIFIERS = T.let( - { - declaration: 0, - definition: 1, - readonly: 2, - static: 3, - deprecated: 4, - abstract: 5, - async: 6, - modification: 7, - documentation: 8, - default_library: 9, - }.freeze, - T::Hash[Symbol, Integer], - ) - - SPECIAL_RUBY_METHODS = T.let( - [ - Module.instance_methods(false), - Kernel.instance_methods(false), - Kernel.methods(false), - Bundler::Dsl.instance_methods(false), - Module.private_instance_methods(false), - ].flatten.map(&:to_s), - T::Array[String], - ) - - class SemanticToken - extend T::Sig - - sig { returns(Prism::Location) } - attr_reader :location - - sig { returns(Integer) } - attr_reader :length - - sig { returns(Integer) } - attr_reader :type - - sig { returns(T::Array[Integer]) } - attr_reader :modifier - - sig { params(location: Prism::Location, length: Integer, type: Integer, modifier: T::Array[Integer]).void } - def initialize(location:, length:, type:, modifier:) - @location = location - @length = length - @type = type - @modifier = modifier - end - end - - sig { override.returns(ResponseType) } - attr_reader :_response - sig { params(dispatcher: Prism::Dispatcher, range: T.nilable(T::Range[Integer])).void } def initialize(dispatcher, range: nil) - super(dispatcher) - - @_response = T.let([], ResponseType) - @range = range - @special_methods = T.let(nil, T.nilable(T::Array[String])) - @current_scope = T.let(ParameterScope.new, ParameterScope) - @inside_regex_capture = T.let(false, T::Boolean) - - dispatcher.register( - self, - :on_call_node_enter, - :on_class_node_enter, - :on_def_node_enter, - :on_def_node_leave, - :on_block_node_enter, - :on_block_node_leave, - :on_self_node_enter, - :on_module_node_enter, - :on_local_variable_write_node_enter, - :on_local_variable_read_node_enter, - :on_block_parameter_node_enter, - :on_required_keyword_parameter_node_enter, - :on_optional_keyword_parameter_node_enter, - :on_keyword_rest_parameter_node_enter, - :on_optional_parameter_node_enter, - :on_required_parameter_node_enter, - :on_rest_parameter_node_enter, - :on_constant_read_node_enter, - :on_constant_write_node_enter, - :on_constant_and_write_node_enter, - :on_constant_operator_write_node_enter, - :on_constant_or_write_node_enter, - :on_constant_target_node_enter, - :on_local_variable_and_write_node_enter, - :on_local_variable_operator_write_node_enter, - :on_local_variable_or_write_node_enter, - :on_local_variable_target_node_enter, - :on_block_local_variable_node_enter, - :on_match_write_node_enter, - :on_match_write_node_leave, - ) - end - - sig { params(node: Prism::CallNode).void } - def on_call_node_enter(node) - return unless visible?(node, @range) - - message = node.message - return unless message - - # We can't push a semantic token for [] and []= because the argument inside the brackets is a part of - # the message_loc - return if message.start_with?("[") && (message.end_with?("]") || message.end_with?("]=")) - return if message == "=~" - return if special_method?(message) - - type = Support::Sorbet.annotation?(node) ? :type : :method - add_token(T.must(node.message_loc), type) + super() + @listener = T.let(Listeners::SemanticHighlighting.new(dispatcher, range: range), Listener[ResponseType]) end - sig { params(node: Prism::MatchWriteNode).void } - def on_match_write_node_enter(node) - call = node.call - - if call.message == "=~" - @inside_regex_capture = true - process_regexp_locals(call) - end - end - - sig { params(node: Prism::MatchWriteNode).void } - def on_match_write_node_leave(node) - @inside_regex_capture = true if node.call.message == "=~" - end - - sig { params(node: Prism::ConstantReadNode).void } - def on_constant_read_node_enter(node) - return unless visible?(node, @range) - # 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 } - - add_token(node.location, :namespace) - end - - sig { params(node: Prism::ConstantWriteNode).void } - def on_constant_write_node_enter(node) - return unless visible?(node, @range) - - add_token(node.name_loc, :namespace) - end - - sig { params(node: Prism::ConstantAndWriteNode).void } - def on_constant_and_write_node_enter(node) - return unless visible?(node, @range) - - add_token(node.name_loc, :namespace) - end - - sig { params(node: Prism::ConstantOperatorWriteNode).void } - def on_constant_operator_write_node_enter(node) - return unless visible?(node, @range) - - add_token(node.name_loc, :namespace) - end - - sig { params(node: Prism::ConstantOrWriteNode).void } - def on_constant_or_write_node_enter(node) - return unless visible?(node, @range) - - add_token(node.name_loc, :namespace) - end - - sig { params(node: Prism::ConstantTargetNode).void } - def on_constant_target_node_enter(node) - return unless visible?(node, @range) - - add_token(node.location, :namespace) - end - - sig { params(node: Prism::DefNode).void } - def on_def_node_enter(node) - @current_scope = ParameterScope.new(@current_scope) - return unless visible?(node, @range) - - add_token(node.name_loc, :method, [:declaration]) - end - - sig { params(node: Prism::DefNode).void } - def on_def_node_leave(node) - @current_scope = T.must(@current_scope.parent) - end - - sig { params(node: Prism::BlockNode).void } - def on_block_node_enter(node) - @current_scope = ParameterScope.new(@current_scope) - end - - sig { params(node: Prism::BlockNode).void } - def on_block_node_leave(node) - @current_scope = T.must(@current_scope.parent) - end - - sig { params(node: Prism::BlockLocalVariableNode).void } - def on_block_local_variable_node_enter(node) - add_token(node.location, :variable) - end - - sig { params(node: Prism::BlockParameterNode).void } - def on_block_parameter_node_enter(node) - name = node.name - @current_scope << name.to_sym if name - end - - sig { params(node: Prism::RequiredKeywordParameterNode).void } - def on_required_keyword_parameter_node_enter(node) - @current_scope << node.name - return unless visible?(node, @range) - - location = node.name_loc - add_token(location.copy(length: location.length - 1), :parameter) - end - - sig { params(node: Prism::OptionalKeywordParameterNode).void } - def on_optional_keyword_parameter_node_enter(node) - @current_scope << node.name - return unless visible?(node, @range) - - location = node.name_loc - add_token(location.copy(length: location.length - 1), :parameter) - end - - sig { params(node: Prism::KeywordRestParameterNode).void } - def on_keyword_rest_parameter_node_enter(node) - name = node.name - - if name - @current_scope << name.to_sym - - add_token(T.must(node.name_loc), :parameter) if visible?(node, @range) - end - end - - sig { params(node: Prism::OptionalParameterNode).void } - def on_optional_parameter_node_enter(node) - @current_scope << node.name - return unless visible?(node, @range) - - add_token(node.name_loc, :parameter) - end - - sig { params(node: Prism::RequiredParameterNode).void } - def on_required_parameter_node_enter(node) - @current_scope << node.name - return unless visible?(node, @range) - - add_token(node.location, :parameter) - end - - sig { params(node: Prism::RestParameterNode).void } - def on_rest_parameter_node_enter(node) - name = node.name - - if name - @current_scope << name.to_sym - - add_token(T.must(node.name_loc), :parameter) if visible?(node, @range) - end - end - - sig { params(node: Prism::SelfNode).void } - def on_self_node_enter(node) - return unless visible?(node, @range) - - add_token(node.location, :variable, [:default_library]) - end - - sig { params(node: Prism::LocalVariableWriteNode).void } - def on_local_variable_write_node_enter(node) - return unless visible?(node, @range) - - add_token(node.name_loc, @current_scope.type_for(node.name)) - end - - sig { params(node: Prism::LocalVariableReadNode).void } - def on_local_variable_read_node_enter(node) - return unless visible?(node, @range) - - # Numbered parameters - if /_\d+/.match?(node.name) - add_token(node.location, :parameter) - return - end - - add_token(node.location, @current_scope.type_for(node.name)) - end - - sig { params(node: Prism::LocalVariableAndWriteNode).void } - def on_local_variable_and_write_node_enter(node) - return unless visible?(node, @range) - - add_token(node.name_loc, @current_scope.type_for(node.name)) - end - - sig { params(node: Prism::LocalVariableOperatorWriteNode).void } - def on_local_variable_operator_write_node_enter(node) - return unless visible?(node, @range) - - add_token(node.name_loc, @current_scope.type_for(node.name)) - end - - sig { params(node: Prism::LocalVariableOrWriteNode).void } - def on_local_variable_or_write_node_enter(node) - return unless visible?(node, @range) - - add_token(node.name_loc, @current_scope.type_for(node.name)) - end - - sig { params(node: Prism::LocalVariableTargetNode).void } - def on_local_variable_target_node_enter(node) - # If we're inside a regex capture, Prism will add LocalVariableTarget nodes for each captured variable. - # Unfortunately, if the regex contains a backslash, the location will be incorrect and we'll end up highlighting - # the entire regex as a local variable. We process these captures in process_regexp_locals instead and then - # prevent pushing local variable target tokens. See https://github.com/ruby/prism/issues/1912 - return if @inside_regex_capture - - return unless visible?(node, @range) - - add_token(node.location, @current_scope.type_for(node.name)) - end - - sig { params(node: Prism::ClassNode).void } - def on_class_node_enter(node) - return unless visible?(node, @range) - - add_token(node.constant_path.location, :class, [:declaration]) - - superclass = node.superclass - add_token(superclass.location, :class) if superclass - end - - sig { params(node: Prism::ModuleNode).void } - def on_module_node_enter(node) - return unless visible?(node, @range) - - add_token(node.constant_path.location, :namespace, [:declaration]) - end - - private - - sig { params(location: Prism::Location, type: Symbol, modifiers: T::Array[Symbol]).void } - def add_token(location, type, modifiers = []) - length = location.end_offset - location.start_offset - modifiers_indices = modifiers.filter_map { |modifier| TOKEN_MODIFIERS[modifier] } - @_response.push( - SemanticToken.new( - location: location, - length: length, - type: T.must(TOKEN_TYPES[type]), - modifier: modifiers_indices, - ), - ) - end - - # Textmate provides highlighting for a subset of these special Ruby-specific methods. We want to utilize that - # highlighting, so we avoid making a semantic token for it. - sig { params(method_name: String).returns(T::Boolean) } - def special_method?(method_name) - SPECIAL_RUBY_METHODS.include?(method_name) - end - - sig { params(node: Prism::CallNode).void } - def process_regexp_locals(node) - receiver = node.receiver - - # The regexp needs to be the receiver of =~ for local variable capture - return unless receiver.is_a?(Prism::RegularExpressionNode) - - content = receiver.content - loc = receiver.content_loc - - # For each capture name we find in the regexp, look for a local in the current_scope - Regexp.new(content, Regexp::FIXEDENCODING).names.each do |name| - # The +3 is to compensate for the "(?<" part of the capture name - capture_name_offset = T.must(content.index("(?<#{name}>")) + 3 - local_var_loc = loc.copy(start_offset: loc.start_offset + capture_name_offset, length: name.length) - - add_token(local_var_loc, @current_scope.type_for(name)) - end + sig { override.returns(ResponseType) } + def response + @listener.response end end end diff --git a/lib/ruby_lsp/requests/signature_help.rb b/lib/ruby_lsp/requests/signature_help.rb index 6f3934f76..da107d6bc 100644 --- a/lib/ruby_lsp/requests/signature_help.rb +++ b/lib/ruby_lsp/requests/signature_help.rb @@ -1,6 +1,8 @@ # typed: strict # frozen_string_literal: true +require "ruby_lsp/listeners/signature_help" + module RubyLsp module Requests # ![Signature help demo](../../signature_help.gif) @@ -22,7 +24,7 @@ module Requests # bar( # -> Signature help will show the parameters of `bar` # end # ``` - class SignatureHelp < Listener + class SignatureHelp < Request extend T::Sig extend T::Generic @@ -40,65 +42,45 @@ def provider ResponseType = type_member { { fixed: T.nilable(T.any(Interface::SignatureHelp, T::Hash[Symbol, T.untyped])) } } - sig { override.returns(ResponseType) } - attr_reader :_response - sig do params( - nesting: T::Array[String], + document: Document, index: RubyIndexer::Index, + position: T::Hash[Symbol, T.untyped], + context: T.nilable(T::Hash[Symbol, T.untyped]), dispatcher: Prism::Dispatcher, ).void end - def initialize(nesting, index, dispatcher) - @nesting = nesting - @index = index - @_response = T.let(nil, ResponseType) - - super(dispatcher) - dispatcher.register(self, :on_call_node_enter) - end - - sig { params(node: Prism::CallNode).void } - def on_call_node_enter(node) - return if DependencyDetector.instance.typechecker - return unless self_receiver?(node) - - message = node.message - return unless message - - target_method = @index.resolve_method(message, @nesting.join("::")) - return unless target_method - - parameters = target_method.parameters - name = target_method.name + def initialize(document, index, position, context, dispatcher) + super() + current_signature = context && context[:activeSignatureHelp] + target, parent, nesting = document.locate_node( + { line: position[:line], character: position[:character] - 2 }, + node_types: [Prism::CallNode], + ) - # If the method doesn't have any parameters, there's no need to show signature help - return if parameters.empty? + # If we're typing a nested method call (e.g.: `foo(bar)`), then we may end up locating `bar` as the target + # method call incorrectly. To correct that, we check if there's an active signature with the same name as the + # parent node and then replace the target + if current_signature && parent.is_a?(Prism::CallNode) + active_signature = current_signature[:activeSignature] || 0 - label = "#{name}(#{parameters.map(&:decorated_name).join(", ")})" + if current_signature.dig(:signatures, active_signature, :label)&.start_with?(parent.message) + target = parent + end + end - arguments_node = node.arguments - arguments = arguments_node&.arguments || [] - active_parameter = (arguments.length - 1).clamp(0, parameters.length - 1) + @target = T.let(target, T.nilable(Prism::Node)) + @dispatcher = dispatcher + @listener = T.let(Listeners::SignatureHelp.new(nesting, index, dispatcher), Listener[ResponseType]) + end - # If there are arguments, then we need to check if there's a trailing comma after the end of the last argument - # to advance the active parameter to the next one - if arguments_node && - node.slice.byteslice(arguments_node.location.end_offset - node.location.start_offset) == "," - active_parameter += 1 - end + sig { override.returns(ResponseType) } + def response + return unless @target - @_response = Interface::SignatureHelp.new( - signatures: [ - Interface::SignatureInformation.new( - label: label, - parameters: parameters.map { |param| Interface::ParameterInformation.new(label: param.name) }, - documentation: markdown_from_index_entries("", target_method), - ), - ], - active_parameter: active_parameter, - ) + @dispatcher.dispatch_once(@target) + @listener.response end end end diff --git a/lib/ruby_lsp/requests/support/semantic_token_encoder.rb b/lib/ruby_lsp/requests/support/semantic_token_encoder.rb index 4c5a3c9a4..efb3c32c3 100644 --- a/lib/ruby_lsp/requests/support/semantic_token_encoder.rb +++ b/lib/ruby_lsp/requests/support/semantic_token_encoder.rb @@ -15,7 +15,7 @@ def initialize sig do params( - tokens: T::Array[SemanticHighlighting::SemanticToken], + tokens: T::Array[Listeners::SemanticHighlighting::SemanticToken], ).returns(Interface::SemanticTokens) end def encode(tokens) @@ -39,7 +39,7 @@ def encode(tokens) # For more information on how each number is calculated, read: # https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_semanticTokens - sig { params(token: SemanticHighlighting::SemanticToken).returns(T::Array[Integer]) } + sig { params(token: Listeners::SemanticHighlighting::SemanticToken).returns(T::Array[Integer]) } def compute_delta(token) row = token.location.start_line - 1 column = token.location.start_column diff --git a/test/expectations/definition/require_relative.exp.json b/test/expectations/definition/require_relative.exp.json index fc4d9a719..b506fd8dc 100644 --- a/test/expectations/definition/require_relative.exp.json +++ b/test/expectations/definition/require_relative.exp.json @@ -1,5 +1,5 @@ { - "result": { + "result": [{ "uri": "file:///ruby_lsp/utils.rb", "range": { "start": { @@ -11,7 +11,7 @@ "character": 0 } } - }, + }], "params": [ { "line": 0, diff --git a/test/expectations/definition/requires.exp.json b/test/expectations/definition/requires.exp.json index 61d442cb2..10c1cb7be 100644 --- a/test/expectations/definition/requires.exp.json +++ b/test/expectations/definition/requires.exp.json @@ -1,5 +1,5 @@ { - "result": { + "result": [{ "uri": "file:///ruby_lsp/listener.rb", "range": { "start": { @@ -11,7 +11,7 @@ "character": 0 } } - }, + }], "params": [ { "line": 0, diff --git a/test/expectations/expectations_test_runner.rb b/test/expectations/expectations_test_runner.rb index 0660e3fab..e7299443b 100644 --- a/test/expectations/expectations_test_runner.rb +++ b/test/expectations/expectations_test_runner.rb @@ -9,23 +9,15 @@ class ExpectationsTestRunner < Minitest::Test class << self def expectations_tests(handler_class, expectation_suffix) - execute_request = if handler_class < RubyLsp::Listener - <<~RUBY - dispatcher = Prism::Dispatcher.new - listener = #{handler_class}.new(dispatcher) - dispatcher.dispatch(document.tree) - listener.response - RUBY - else - "#{handler_class}.new(document, *params).run" - end - class_eval(<<~RB, __FILE__, __LINE__ + 1) module ExpectationsRunnerMethods def run_expectations(source) params = @__params&.any? ? @__params : default_args document = RubyLsp::RubyDocument.new(source: source, version: 1, uri: URI("file:///fake.rb")) - #{execute_request} + dispatcher = Prism::Dispatcher.new + listener = #{handler_class}.new(dispatcher) + dispatcher.dispatch(document.tree) + listener.response end def assert_expectations(source, expected) diff --git a/test/integration_test.rb b/test/integration_test.rb index 3f34d514c..4f35855f7 100644 --- a/test/integration_test.rb +++ b/test/integration_test.rb @@ -111,7 +111,7 @@ def test_definition ) assert_nil(response[:error]) - assert(response[:result][:uri].end_with?("ruby_lsp/utils.rb")) + assert(response[:result].first[:uri].end_with?("ruby_lsp/utils.rb")) end def test_document_highlight_with_syntax_error @@ -132,6 +132,7 @@ def test_semantic_highlighting open_file_with("class Foo\nend") response = make_request("textDocument/semanticTokens/full", { textDocument: { uri: @uri } }) + assert_nil(response[:error]) assert_equal([0, 6, 3, 2, 1], response[:result][:data]) end @@ -396,7 +397,11 @@ def initialize_lsp(enabled_features, experimental_features_enabled: false) window: { workDoneProgress: false }, }, }, - )[:result] + ) + + assert_nil(response[:error]) + + response = response[:result] assert(true, response.dig(:capabilities, :textDocumentSync, :openClose)) assert( diff --git a/test/requests/definition_expectations_test.rb b/test/requests/definition_expectations_test.rb index 7b2ac981b..eb638ca99 100644 --- a/test/requests/definition_expectations_test.rb +++ b/test/requests/definition_expectations_test.rb @@ -125,7 +125,7 @@ def test_jumping_to_default_require_of_a_gem params: { textDocument: { uri: "file:///folder/fake.rb" }, position: { character: 10, line: 0 } }, }).response - assert_equal(uri.to_s, response.attributes[:uri]) + assert_equal(uri.to_s, response.first.attributes[:uri]) ensure T.must(message_queue).close end @@ -156,9 +156,10 @@ class A response = executor.execute({ method: "textDocument/definition", params: { textDocument: { uri: "file:///folder/fake.rb" }, position: { character: 2, line: 4 } }, - }).response + }) - assert_equal(uri.to_s, response.first.attributes[:uri]) + assert_nil(response.error, response.error&.full_message) + assert_equal(uri.to_s, response.response.first.attributes[:uri]) ensure T.must(message_queue).close end @@ -253,7 +254,7 @@ def foo; end params: { textDocument: { uri: "file:///folder/fake.rb" }, position: { character: 4, line: 4 } }, }).response - assert_equal(uri.to_s, response.attributes[:uri]) + assert_equal(uri.to_s, response.first.attributes[:uri]) ensure T.must(message_queue).close end @@ -288,7 +289,7 @@ def foo; end params: { textDocument: { uri: "file:///folder/fake.rb" }, position: { character: 9, line: 4 } }, }).response - assert_equal(uri.to_s, response.attributes[:uri]) + assert_equal(uri.to_s, response.first.attributes[:uri]) ensure T.must(message_queue).close end diff --git a/test/requests/document_highlight_expectations_test.rb b/test/requests/document_highlight_expectations_test.rb index a31795d91..abf8f927c 100644 --- a/test/requests/document_highlight_expectations_test.rb +++ b/test/requests/document_highlight_expectations_test.rb @@ -11,11 +11,9 @@ def run_expectations(source) uri = URI("file://#{@_path}") params = @__params&.any? ? @__params : default_args document = RubyLsp::RubyDocument.new(source: source, version: 1, uri: uri) - target, parent = document.locate_node(params.first) dispatcher = Prism::Dispatcher.new - - listener = RubyLsp::Requests::DocumentHighlight.new(target, parent, dispatcher) + listener = RubyLsp::Requests::DocumentHighlight.new(document, params.first, dispatcher) dispatcher.dispatch(document.tree) listener.response end diff --git a/test/requests/hover_expectations_test.rb b/test/requests/hover_expectations_test.rb index 7808f1b5f..f89f4b8cb 100644 --- a/test/requests/hover_expectations_test.rb +++ b/test/requests/hover_expectations_test.rb @@ -244,9 +244,10 @@ class Post response = executor.execute({ method: "textDocument/hover", params: { textDocument: { uri: "file:///fake.rb" }, position: { line: 4, character: 0 } }, - }).response + }) - assert_match("Hello\n\nHello from middleware: Post", response.contents.value) + assert_nil(response.error, response.error&.full_message) + assert_match("Hello\n\nHello from middleware: Post", response.response.contents.value) end end diff --git a/test/requests/inlay_hints_expectations_test.rb b/test/requests/inlay_hints_expectations_test.rb index a122a1c7f..f623514a1 100644 --- a/test/requests/inlay_hints_expectations_test.rb +++ b/test/requests/inlay_hints_expectations_test.rb @@ -14,13 +14,13 @@ def run_expectations(source) dispatcher = Prism::Dispatcher.new hints_configuration = RubyLsp::RequestConfig.new({ implicitRescue: true, implicitHashValue: true }) - listener = RubyLsp::Requests::InlayHints.new(params.first, hints_configuration, dispatcher) + request = RubyLsp::Requests::InlayHints.new(document, params.first, hints_configuration, dispatcher) dispatcher.dispatch(document.tree) - listener.response + request.response end def default_args - [0..20] + [{ start: { line: 0, character: 0 }, end: { line: 20, character: 20 } }] end def test_skip_implicit_hash_value @@ -31,9 +31,9 @@ def test_skip_implicit_hash_value dispatcher = Prism::Dispatcher.new hints_configuration = RubyLsp::RequestConfig.new({ implicitRescue: true, implicitHashValue: false }) - listener = RubyLsp::Requests::InlayHints.new(default_args.first, hints_configuration, dispatcher) + request = RubyLsp::Requests::InlayHints.new(document, default_args.first, hints_configuration, dispatcher) dispatcher.dispatch(document.tree) - assert_empty(listener.response) + request.response end def test_skip_implicit_rescue @@ -46,8 +46,8 @@ def test_skip_implicit_rescue dispatcher = Prism::Dispatcher.new hints_configuration = RubyLsp::RequestConfig.new({ implicitRescue: false, implicitHashValue: true }) - listener = RubyLsp::Requests::InlayHints.new(default_args.first, hints_configuration, dispatcher) + request = RubyLsp::Requests::InlayHints.new(document, default_args.first, hints_configuration, dispatcher) dispatcher.dispatch(document.tree) - assert_empty(listener.response) + assert_empty(request.response) end end diff --git a/test/requests/support/semantic_token_encoder_test.rb b/test/requests/support/semantic_token_encoder_test.rb index a3952b48c..e3cdea849 100644 --- a/test/requests/support/semantic_token_encoder_test.rb +++ b/test/requests/support/semantic_token_encoder_test.rb @@ -100,7 +100,7 @@ def stub_token(start_line, start_column, length, type, modifier) location.expects(:start_line).returns(start_line).at_least_once location.expects(:start_column).returns(start_column).at_least_once - RubyLsp::Requests::SemanticHighlighting::SemanticToken.new( + RubyLsp::Listeners::SemanticHighlighting::SemanticToken.new( location: location, length: length, type: type,