From ef3f39d7ea4dfb38ece84ade7d137c05c8a6d0e7 Mon Sep 17 00:00:00 2001 From: Vinicius Stock Date: Mon, 20 Apr 2026 16:57:04 -0400 Subject: [PATCH] Migrate type hierarchy to use Rubydex --- lib/ruby_lsp/internal.rb | 1 + .../requests/prepare_type_hierarchy.rb | 88 +++- .../requests/type_hierarchy_supertypes.rb | 128 ++++-- lib/ruby_lsp/rubydex/declaration.rb | 48 ++ lib/ruby_lsp/rubydex/definition.rb | 57 +++ lib/ruby_lsp/server.rb | 4 +- test/requests/prepare_type_hierarchy_test.rb | 165 ++++++- test/requests/type_hierarchy_supertypes.rb | 74 --- .../type_hierarchy_supertypes_test.rb | 421 ++++++++++++++++++ 9 files changed, 837 insertions(+), 149 deletions(-) create mode 100644 lib/ruby_lsp/rubydex/declaration.rb delete mode 100644 test/requests/type_hierarchy_supertypes.rb create mode 100644 test/requests/type_hierarchy_supertypes_test.rb diff --git a/lib/ruby_lsp/internal.rb b/lib/ruby_lsp/internal.rb index 1a2cbdf953..420a152604 100644 --- a/lib/ruby_lsp/internal.rb +++ b/lib/ruby_lsp/internal.rb @@ -32,6 +32,7 @@ require "set" # Rubydex LSP additions +require "ruby_lsp/rubydex/declaration" require "ruby_lsp/rubydex/definition" require "ruby_lsp/rubydex/reference" diff --git a/lib/ruby_lsp/requests/prepare_type_hierarchy.rb b/lib/ruby_lsp/requests/prepare_type_hierarchy.rb index cb51118ba3..cf086ff0a0 100644 --- a/lib/ruby_lsp/requests/prepare_type_hierarchy.rb +++ b/lib/ruby_lsp/requests/prepare_type_hierarchy.rb @@ -5,9 +5,7 @@ module RubyLsp module Requests # The [prepare type hierarchy # request](https://microsoft.github.io/language-server-protocol/specification#textDocument_prepareTypeHierarchy) - # displays the list of ancestors (supertypes) and descendants (subtypes) for the selected type. - # - # Currently only supports supertypes due to a limitation of the index. + # displays the list of direct ancestors (supertypes) and descendants (subtypes) for the selected type. class PrepareTypeHierarchy < Request include Support::Common @@ -18,12 +16,12 @@ def provider end end - #: ((RubyDocument | ERBDocument) document, RubyIndexer::Index index, Hash[Symbol, untyped] position) -> void - def initialize(document, index, position) + #: ((RubyDocument | ERBDocument) document, GlobalState global_state, Hash[Symbol, untyped] position) -> void + def initialize(document, global_state, position) super() @document = document - @index = index + @graph = global_state.graph #: Rubydex::Graph @position = position end @@ -36,32 +34,78 @@ def perform Prism::ConstantReadNode, Prism::ConstantWriteNode, Prism::ConstantPathNode, + Prism::SingletonClassNode, ], ) - node = context.node - parent = context.parent - return unless node && parent + node = context.node #: as (Prism::ConstantReadNode | Prism::ConstantPathNode | Prism::ConstantWriteNode | Prism::SingletonClassNode)? + return unless node + + pair = name_and_nesting(node, context) + return unless pair - target = determine_target(node, parent, @position) - entries = @index.resolve(target.slice, context.nesting) - return unless entries + declaration = @graph.resolve_constant(pair.first, pair.last) + return unless declaration.is_a?(Rubydex::Namespace) - # While the spec allows for multiple entries, VSCode seems to only support one - # We'll just return the first one for now - first_entry = entries.first #: as !nil - range = range_from_location(first_entry.location) + primary = declaration.definitions.first + return unless primary [ - Interface::TypeHierarchyItem.new( - name: first_entry.name, - kind: kind_for_entry(first_entry), - uri: first_entry.uri.to_s, - range: range, - selection_range: range, + primary.to_lsp_type_hierarchy_item( + declaration.name, + detail: declaration.lsp_type_hierarchy_detail, ), ] end + + private + + # Returns the `(name, nesting)` pair to pass to `Rubydex::Graph#resolve_constant`, covering three cases: + # + #: ((Prism::ConstantReadNode | Prism::ConstantPathNode | Prism::ConstantWriteNode | Prism::SingletonClassNode), NodeContext) -> [String, Array[String]]? + def name_and_nesting(node, context) + parent = context.parent + nesting = context.nesting + + singleton_node = singleton_class_node_for(node, parent) + return singleton_lookup(singleton_node, nesting) if singleton_node + + target = parent ? determine_target(node, parent, @position) : node + [target.slice, nesting] + end + + # Ensures that we're returning the target of the singleton class block regardless of whether the cursor is on the + # `class` keyword or the constant reference for the target + #: ((Prism::ConstantReadNode | Prism::ConstantPathNode | Prism::ConstantWriteNode | Prism::SingletonClassNode), Prism::Node?) -> Prism::SingletonClassNode? + def singleton_class_node_for(node, parent) + return node if node.is_a?(Prism::SingletonClassNode) + return unless parent.is_a?(Prism::SingletonClassNode) && parent.expression == node + + parent + end + + # Builds the synthesized singleton class name (e.g. `Foo::`) for a `class << X` block, together with the + # outer lexical nesting. `NodeContext` already appends a `` marker as the last element of the nesting + # whenever the cursor sits inside (or on) a `SingletonClassNode`, so we drop that marker to obtain the scope in + # which the singleton should be resolved. + #: (Prism::SingletonClassNode, Array[String]) -> [String, Array[String]]? + def singleton_lookup(singleton_node, nesting) + outer = nesting[0...-1] || [] + + case expression = singleton_node.expression + when Prism::SelfNode + name = nesting.last + return unless name + + [name, outer] + when Prism::ConstantReadNode, Prism::ConstantPathNode + name = constant_name(expression) + return unless name + + unqualified = name.split("::").last #: as !nil + ["#{name}::<#{unqualified}>", outer] + end + end end end end diff --git a/lib/ruby_lsp/requests/type_hierarchy_supertypes.rb b/lib/ruby_lsp/requests/type_hierarchy_supertypes.rb index de602782f1..ebfdc94875 100644 --- a/lib/ruby_lsp/requests/type_hierarchy_supertypes.rb +++ b/lib/ruby_lsp/requests/type_hierarchy_supertypes.rb @@ -9,65 +9,101 @@ module Requests class TypeHierarchySupertypes < Request include Support::Common - #: (RubyIndexer::Index index, Hash[Symbol, untyped] item) -> void - def initialize(index, item) + #: (GlobalState, Hash[Symbol, untyped]) -> void + def initialize(global_state, item) super() - @index = index + @graph = global_state.graph #: Rubydex::Graph @item = item end # @override #: -> Array[Interface::TypeHierarchyItem]? def perform - name = @item[:name] - entries = @index[name] - - parents = Set.new #: Set[RubyIndexer::Entry::Namespace] - return unless entries&.any? - - entries.each do |entry| - next unless entry.is_a?(RubyIndexer::Entry::Namespace) - - if entry.is_a?(RubyIndexer::Entry::Class) - parent_class_name = entry.parent_class - if parent_class_name - resolved_parent_entries = @index.resolve(parent_class_name, entry.nesting) - resolved_parent_entries&.each do |entry| - next unless entry.is_a?(RubyIndexer::Entry::Class) - - parents << entry - end - end - end - - entry.mixin_operations.each do |mixin_operation| - mixin_name = mixin_operation.module_name - resolved_mixin_entries = @index.resolve(mixin_name, entry.nesting) - next unless resolved_mixin_entries - - resolved_mixin_entries.each do |mixin_entry| - next unless mixin_entry.is_a?(RubyIndexer::Entry::Module) - - parents << mixin_entry - end - end - end + fully_qualified_name = @item.dig(:data, :fully_qualified_name) || @item[:name] #: String? + return unless fully_qualified_name + + declaration = @graph[fully_qualified_name] + return unless declaration.is_a?(Rubydex::Namespace) - parents.map { |entry| hierarchy_item(entry) } + compute_supertypes(declaration).filter_map { |name, backing| hierarchy_item(name, backing) } end private - #: (RubyIndexer::Entry entry) -> Interface::TypeHierarchyItem - def hierarchy_item(entry) - Interface::TypeHierarchyItem.new( - name: entry.name, - kind: kind_for_entry(entry), - uri: entry.uri.to_s, - range: range_from_location(entry.location), - selection_range: range_from_location(entry.name_location), - detail: entry.file_name, + # Returns an array of `[display_name, backing_declaration]` pairs. `display_name` is the name shown in the type + # hierarchy item (which may be a synthesized singleton class name like `Object::`). `backing_declaration` + # is the namespace whose primary definition provides the location for the hierarchy item — it may differ from the + # display name when the singleton class is implicit and has no definitions of its own, in which case we fall back + # to the attached object's definition so the user still lands somewhere useful. + # + #: (Rubydex::Namespace) -> Array[[String, Rubydex::Namespace]] + def compute_supertypes(declaration) + case declaration + when Rubydex::SingletonClass + singleton_supertypes(declaration) + when Rubydex::Class + class_supertypes(declaration) + else + explicit_supertypes(declaration) + end + end + + #: (Rubydex::Class) -> Array[[String, Rubydex::Namespace]] + def class_supertypes(declaration) + # `BasicObject` is the root of the Ruby class hierarchy + supertypes = explicit_supertypes(declaration) + return supertypes if declaration.name == "BasicObject" + + # If the class has any superclass reference (resolved or unresolved), don't re-add the implicit `Object`. + has_superclass = declaration.definitions.any? do |d| + d.is_a?(Rubydex::ClassDefinition) && !d.superclass.nil? + end + return supertypes if has_superclass + + object = @graph["Object"] #: as Rubydex::Namespace + supertypes << ["Object", object] + supertypes + end + + #: (Rubydex::Namespace) -> Array[[String, Rubydex::Namespace]] + def explicit_supertypes(declaration) + declaration.direct_supertypes.map { |s| [s.name, s] } + end + + # Singleton classes don't have their own superclass references. Their direct supertype is the singleton class of + # the attached object's superclass, computed recursively so that nested singleton classes (e.g. + # `Foo::::<>`) still resolve to the matching depth on the parent chain. When the synthesized singleton + # class name has no backing declaration with definitions (implicit singleton), we fall back to the attached + # supertype's backing so the user is still navigated to a meaningful location. + # + #: (Rubydex::SingletonClass) -> Array[[String, Rubydex::Namespace]] + def singleton_supertypes(declaration) + attached = declaration.owner + return [] unless attached.is_a?(Rubydex::Namespace) + + compute_supertypes(attached).map do |parent_name, parent_backing| + singleton_name = singleton_name_of(parent_name) + found = @graph[singleton_name] + backing = found.is_a?(Rubydex::Namespace) && found.definitions.any? ? found : parent_backing + [singleton_name, backing] + end + end + + #: (String) -> String + def singleton_name_of(name) + unqualified = name.split("::").last || name + "#{name}::<#{unqualified}>" + end + + #: (String, Rubydex::Namespace) -> Interface::TypeHierarchyItem? + def hierarchy_item(name, declaration) + primary = declaration.definitions.first #: Rubydex::Definition? + return unless primary + + primary.to_lsp_type_hierarchy_item( + name, + detail: declaration.lsp_type_hierarchy_detail, ) end end diff --git a/lib/ruby_lsp/rubydex/declaration.rb b/lib/ruby_lsp/rubydex/declaration.rb new file mode 100644 index 0000000000..d3836fc8af --- /dev/null +++ b/lib/ruby_lsp/rubydex/declaration.rb @@ -0,0 +1,48 @@ +# typed: strict +# frozen_string_literal: true + +module Rubydex + class Declaration + # Detail text shown on a `TypeHierarchyItem` for this declaration. Hints at multiplicity + # when the declaration spans more than one re-open; otherwise falls back to the primary + # definition's file name so users can quickly see where the type comes from. + # + #: () -> String? + def lsp_type_hierarchy_detail + defs = definitions + count = defs.count + return "#{count} definitions" if count > 1 + + primary = defs.first + return unless primary + + uri = URI(primary.location.uri) + path = uri.full_path + path ? File.basename(path) : uri.to_s + end + end + + class Namespace + # Resolved, deduplicated direct supertypes across every re-open of this declaration. + # Aggregates each definition's own `superclass`/`include`/`prepend` references and drops + # unresolved ones. Order is stable (first-seen across definitions). + #: () -> Array[Rubydex::Namespace] + def direct_supertypes + seen = {} #: Hash[String, Rubydex::Namespace] + + definitions.each do |definition| + definition.direct_supertype_references.each do |ref| + next unless ref.is_a?(ResolvedConstantReference) + + target = ref.declaration + next unless target.is_a?(Namespace) + next if seen.key?(target.name) + + seen[target.name] = target + end + end + + seen.values + end + end +end diff --git a/lib/ruby_lsp/rubydex/definition.rb b/lib/ruby_lsp/rubydex/definition.rb index ca04f2391e..992a6766ab 100644 --- a/lib/ruby_lsp/rubydex/definition.rb +++ b/lib/ruby_lsp/rubydex/definition.rb @@ -21,6 +21,29 @@ def to_lsp_kind raise RubyLsp::AbstractMethodInvokedError end + # Direct ancestor references contributed by this definition (superclass, includes, prepends). + # Extends are intentionally excluded here because they extend the singleton class, not the + # instance-side ancestor chain. Definition subclasses that can't contribute ancestors return []. + #: () -> Array[Rubydex::ConstantReference] + def direct_supertype_references + [] + end + + #: (String name, ?detail: String?) -> RubyLsp::Interface::TypeHierarchyItem + def to_lsp_type_hierarchy_item(name, detail: nil) + range = to_lsp_selection_range + + RubyLsp::Interface::TypeHierarchyItem.new( + name: name, + kind: to_lsp_kind, + uri: location.uri, + range: range, + selection_range: to_lsp_name_range || range, + detail: detail, + data: { fully_qualified_name: name }, + ) + end + #: (String name) -> RubyLsp::Interface::WorkspaceSymbol def to_lsp_workspace_symbol(name) # We use the namespace as the container name, but we also use the full name as the regular name. The reason we do @@ -86,15 +109,47 @@ def to_lsp_name_location end end + # Shared supertype aggregation for Rubydex definition types that carry namespace mixins + # (`ClassDefinition`, `ModuleDefinition`, `SingletonClassDefinition`). The including class is + # expected to provide `#mixins`, which every Rubydex namespace definition already does. + # @abstract + module NamespaceDefinition + # @abstract + #: () -> Array[Rubydex::Mixin] + def mixins + raise RubyLsp::AbstractMethodInvokedError + end + + #: () -> Array[Rubydex::ConstantReference] + def direct_supertype_references + mixins.filter_map do |mixin| + mixin.constant_reference if mixin.is_a?(Include) || mixin.is_a?(Prepend) + end + end + end + class ClassDefinition + include NamespaceDefinition + # @override #: () -> Integer def to_lsp_kind RubyLsp::Constant::SymbolKind::CLASS end + + # @override + #: () -> Array[Rubydex::ConstantReference] + def direct_supertype_references + refs = super + superclass_ref = superclass + refs << superclass_ref if superclass_ref + refs + end end class ModuleDefinition + include NamespaceDefinition + # @override #: () -> Integer def to_lsp_kind @@ -103,6 +158,8 @@ def to_lsp_kind end class SingletonClassDefinition + include NamespaceDefinition + # @override #: () -> Integer def to_lsp_kind diff --git a/lib/ruby_lsp/server.rb b/lib/ruby_lsp/server.rb index e2dc3ab1c2..5ba7a41f3d 100644 --- a/lib/ruby_lsp/server.rb +++ b/lib/ruby_lsp/server.rb @@ -1212,7 +1212,7 @@ def text_document_prepare_type_hierarchy(message) response = Requests::PrepareTypeHierarchy.new( document, - @global_state.index, + @global_state, params[:position], ).perform @@ -1222,7 +1222,7 @@ def text_document_prepare_type_hierarchy(message) #: (Hash[Symbol, untyped] message) -> void def type_hierarchy_supertypes(message) response = Requests::TypeHierarchySupertypes.new( - @global_state.index, + @global_state, message.dig(:params, :item), ).perform send_message(Result.new(id: message[:id], response: response)) diff --git a/test/requests/prepare_type_hierarchy_test.rb b/test/requests/prepare_type_hierarchy_test.rb index e83ad288a9..248007a16d 100644 --- a/test/requests/prepare_type_hierarchy_test.rb +++ b/test/requests/prepare_type_hierarchy_test.rb @@ -14,8 +14,8 @@ class Foo; end textDocument: { uri: uri }, position: { line: 0, character: 1 }, }) - result = server.pop_response.response + result = server.pop_response.response assert_nil(result) end end @@ -30,8 +30,8 @@ class Foo::Bar; end textDocument: { uri: uri }, position: { line: 0, character: 12 }, }) - result = server.pop_response.response + result = server.pop_response.response assert_equal("Foo::Bar", result.first.name) end end @@ -46,8 +46,8 @@ def test_prepare_type_hierarchy_returns_nil_if_constant_not_indexed textDocument: { uri: uri }, position: { line: 0, character: 6 }, }) - result = server.pop_response.response + result = server.pop_response.response assert_nil(result) end end @@ -63,12 +63,69 @@ class Bar; end textDocument: { uri: uri }, position: { line: 1, character: 6 }, }) - result = server.pop_response.response + result = server.pop_response.response assert_equal("Bar", result.first.name) end end + def test_prepare_type_hierarchy_on_parent_of_compact_namespace + source = +<<~RUBY + class Foo; end + class Foo::Bar; end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "textDocument/prepareTypeHierarchy", params: { + textDocument: { uri: uri }, + position: { line: 1, character: 7 }, + }) + + result = server.pop_response.response + assert_equal("Foo", result.first.name) + end + end + + def test_prepare_type_hierarchy_on_singleton_class_block + source = +<<~RUBY + class Foo + class << self + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "textDocument/prepareTypeHierarchy", params: { + textDocument: { uri: uri }, + position: { line: 1, character: 4 }, + }) + + result = server.pop_response.response + assert_equal("Foo::", result.first.name) + end + end + + def test_prepare_type_hierarchy_on_nested_singleton_class_block + source = +<<~RUBY + class Foo + class << self + class << self + end + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "textDocument/prepareTypeHierarchy", params: { + textDocument: { uri: uri }, + position: { line: 2, character: 6 }, + }) + + result = server.pop_response.response + assert_equal("Foo::::<>", result.first.name) + end + end + def test_prepare_type_hierarchy_only_returns_the_first_entry source = <<~RUBY class Bar; end @@ -81,9 +138,107 @@ class Bar; end textDocument: { uri: uri }, position: { line: 2, character: 6 }, }) - result = server.pop_response.response + result = server.pop_response.response assert_equal(["Bar"], result.map(&:name)) end end + + def test_nesting_constant_references_are_resolved + source = +<<~RUBY + module Bar; end + + module Foo + class Bar::Baz + class << self + end + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "textDocument/prepareTypeHierarchy", params: { + textDocument: { uri: uri }, + position: { line: 4, character: 6 }, + }) + + result = server.pop_response.response + assert_equal("Bar::Baz::", result.first.name) + end + end + + def test_singleton_class_targets + source = +<<~RUBY + module Bar; end + + module Foo + class << Bar + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "textDocument/prepareTypeHierarchy", params: { + textDocument: { uri: uri }, + position: { line: 3, character: 11 }, + }) + + result = server.pop_response.response + assert_equal("Bar::", result.first.name) + end + end + + def test_parent_scopes_are_resolved + source = +<<~RUBY + module Qux; end + module Bar + include Qux + end + + class Zip; end + + module Foo + class Bar::Baz < Zip + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "textDocument/prepareTypeHierarchy", params: { + textDocument: { uri: uri }, + position: { line: 8, character: 8 }, + }) + + result = server.pop_response.response + assert_equal("Bar", result.first.name) + + server.process_message(id: 2, method: "textDocument/prepareTypeHierarchy", params: { + textDocument: { uri: uri }, + position: { line: 8, character: 13 }, + }) + + result = server.pop_response.response + assert_equal("Bar::Baz", result.first.name) + end + end + + def test_dynamic_singleton_target + source = +<<~RUBY + module Bar; end + + class Foo + class << Bar::baz + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "textDocument/prepareTypeHierarchy", params: { + textDocument: { uri: uri }, + position: { line: 3, character: 16 }, + }) + + assert_nil(server.pop_response.response) + end + end end diff --git a/test/requests/type_hierarchy_supertypes.rb b/test/requests/type_hierarchy_supertypes.rb deleted file mode 100644 index 4908ba5aa9..0000000000 --- a/test/requests/type_hierarchy_supertypes.rb +++ /dev/null @@ -1,74 +0,0 @@ -# typed: true -# frozen_string_literal: true - -require "test_helper" - -class TypeHierarchySupertypesTest < Minitest::Test - def test_type_hierarchy_supertypes_returns_nil_if_item_name_not_indexed - source = +<<~RUBY - class Foo; end - RUBY - - with_server(source) do |server, uri| - server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { - textDocument: { uri: uri }, - item: { name: "Bar" }, - }) - result = server.pop_response.response - - assert_nil(result) - end - end - - def test_type_hierarchy_supertypes_returns_empty_array_if_no_supertypes - source = +<<~RUBY - class Foo::Bar; end - RUBY - - with_server(source) do |server, uri| - server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { - textDocument: { uri: uri }, - item: { name: "Foo::Bar" }, - }) - result = server.pop_response.response - - assert_empty(result) - end - end - - def test_type_hierarchy_returns_supertypes - source = <<~RUBY - module Foo - class Bar; end - class Baz < Bar; end - class Qux < Baz; end - end - RUBY - - with_server(source) do |server, uri| - server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { - textDocument: { uri: uri }, - item: { name: "Foo::Qux" }, - }) - result = server.pop_response.response - - assert_equal(["Foo::Baz"], result.map(&:name)) - - server.process_message(id: 2, method: "typeHierarchy/supertypes", params: { - textDocument: { uri: uri }, - item: { name: "Foo::Baz" }, - }) - result = server.pop_response.response - - assert_equal(["Foo::Bar"], result.map(&:name)) - - server.process_message(id: 2, method: "typeHierarchy/supertypes", params: { - textDocument: { uri: uri }, - item: { name: "Foo::Bar" }, - }) - result = server.pop_response.response - - assert_empty(result) - end - end -end diff --git a/test/requests/type_hierarchy_supertypes_test.rb b/test/requests/type_hierarchy_supertypes_test.rb new file mode 100644 index 0000000000..9303b13517 --- /dev/null +++ b/test/requests/type_hierarchy_supertypes_test.rb @@ -0,0 +1,421 @@ +# typed: true +# frozen_string_literal: true + +require "test_helper" + +class TypeHierarchySupertypesTest < Minitest::Test + def test_returns_nil_if_item_name_not_indexed + source = +<<~RUBY + class Foo; end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Bar" }, + }) + + result = server.pop_response.response + assert_nil(result) + end + end + + def test_basic_object_has_no_implicit_supertype + source = +<<~RUBY + class BasicObject + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "BasicObject" }, + }) + + result = server.pop_response.response + assert_empty(result) + end + end + + def test_basic_object_includes_are_reported_without_implicit_object + source = +<<~RUBY + module M; end + + class BasicObject + include M + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "BasicObject" }, + }) + + result = server.pop_response.response + assert_equal(["M"], result.map(&:name)) + end + end + + def test_basic_object_singleton_has_no_implicit_supertype + source = +<<~RUBY + class BasicObject + class << self + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "BasicObject::" }, + }) + + result = server.pop_response.response + assert_empty(result) + end + end + + def test_adds_implicit_object_when_class_has_no_explicit_superclass + source = +<<~RUBY + class Foo; end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo" }, + }) + + result = server.pop_response.response + assert_equal(["Object"], result.map(&:name)) + end + end + + def test_does_not_duplicate_object_when_class_explicitly_inherits_from_it + source = +<<~RUBY + class Foo < Object; end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo" }, + }) + + result = server.pop_response.response + assert_equal(["Object"], result.map(&:name)) + end + end + + def test_module_has_no_implicit_object + source = +<<~RUBY + module Foo; end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo" }, + }) + + result = server.pop_response.response + assert_empty(result) + end + end + + def test_singleton_class_falls_back_to_object_singleton_when_no_explicit_parent + source = +<<~RUBY + class Foo + class << self + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo::" }, + }) + + result = server.pop_response.response + assert_equal(["Object::"], result.map(&:name)) + end + end + + def test_singleton_ancestors_points_to_singleton_class_definition + source = +<<~RUBY + class Foo + class << self + end + end + + class Bar < Foo + class << self + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Bar::" }, + }) + + result = server.pop_response.response + assert_equal(["Foo::"], result.map(&:name)) + + range = result.first.attributes[:range] + assert_equal(1, range.attributes[:start].attributes[:line]) + assert_equal(2, range.attributes[:end].attributes[:line]) + end + end + + def test_nested_singleton_class_falls_back_to_object_at_same_depth + source = +<<~RUBY + class Foo + class << self + class << self + end + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo::::<>" }, + }) + + result = server.pop_response.response + assert_equal(["Object::::<>"], result.map(&:name)) + end + end + + def test_singleton_class_inherits_from_parents_singleton_when_attached_has_explicit_superclass + source = +<<~RUBY + class Bar; end + class Foo < Bar + class << self + end + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo::" }, + }) + + result = server.pop_response.response + assert_equal(["Bar::"], result.map(&:name)) + end + end + + def test_returns_direct_superclass + source = <<~RUBY + module Foo + class Bar; end + class Baz < Bar; end + class Qux < Baz; end + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo::Qux" }, + }) + + result = server.pop_response.response + assert_equal(["Foo::Baz"], result.map(&:name)) + + server.process_message(id: 2, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo::Baz" }, + }) + + result = server.pop_response.response + assert_equal(["Foo::Bar"], result.map(&:name)) + + server.process_message(id: 2, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo::Bar" }, + }) + + result = server.pop_response.response + assert_equal(["Object"], result.map(&:name)) + end + end + + def test_returns_includes_and_prepends + source = <<~RUBY + module A; end + module B; end + class Parent; end + + class Foo < Parent + include A + prepend B + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo" }, + }) + + result = server.pop_response.response + assert_equal(["Parent", "A", "B"].sort, result.map(&:name).sort) + end + end + + def test_excludes_extend_from_class_supertypes + source = <<~RUBY + module A; end + module M; end + class Foo + include A + extend M + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo" }, + }) + result = server.pop_response.response + + names = result.map(&:name) + assert_includes(names, "A") + refute_includes(names, "M") + end + end + + def test_aggregates_mixins_across_reopens_and_dedupes + source = <<~RUBY + module A; end + module B; end + + class Foo + include A + end + + class Foo + include B + end + + class Foo + include A + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo" }, + }) + result = server.pop_response.response + + names = result.map(&:name).sort + assert_equal(["A", "B", "Object"], names) + end + end + + def test_module_supertypes_include_mixins_only + source = <<~RUBY + module A; end + module M + include A + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "M" }, + }) + result = server.pop_response.response + + assert_equal(["A"], result.map(&:name)) + end + end + + def test_uses_fully_qualified_name_from_data_when_present + source = <<~RUBY + class Parent; end + class Foo < Parent; end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { + name: "", + data: { fully_qualified_name: "Foo" }, + }, + }) + + result = server.pop_response.response + assert_equal(["Parent"], result.map(&:name)) + end + end + + def test_skips_unresolved_supertype_references + source = <<~RUBY + class Foo < ReferenceThatDoesNotExist; end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo" }, + }) + + result = server.pop_response.response + assert_empty(result) + end + end + + def test_mixes_resolved_and_unresolved_references + source = <<~RUBY + module A; end + + class Foo + include DoesNotExist + include A + end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo" }, + }) + + result = server.pop_response.response + assert_equal(["A", "Object"], result.map(&:name)) + end + end + + def test_returned_items_embed_fully_qualified_name_in_data + source = <<~RUBY + class Parent; end + class Foo < Parent; end + RUBY + + with_server(source) do |server, uri| + server.process_message(id: 1, method: "typeHierarchy/supertypes", params: { + textDocument: { uri: uri }, + item: { name: "Foo" }, + }) + result = server.pop_response.response + + parent = result.first + assert_equal("Parent", parent.name) + assert_equal("Parent", parent.attributes[:data][:fully_qualified_name]) + end + end +end