Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Hover, Completion, Go to definition for Class Variables #2944

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
64 changes: 64 additions & 0 deletions lib/ruby_indexer/lib/ruby_indexer/declaration_listener.rb
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ def initialize(index, dispatcher, parse_result, uri, collect_comments: false)
:on_instance_variable_or_write_node_enter,
:on_instance_variable_target_node_enter,
:on_alias_method_node_enter,
:on_class_variable_and_write_node_enter,
:on_class_variable_operator_write_node_enter,
:on_class_variable_or_write_node_enter,
:on_class_variable_target_node_enter,
:on_class_variable_write_node_enter,
)
end

Expand Down Expand Up @@ -434,6 +439,31 @@ def on_alias_method_node_enter(node)
)
end

sig { params(node: Prism::ClassVariableAndWriteNode).void }
def on_class_variable_and_write_node_enter(node)
handle_class_variable(node, node.name_loc)
end

sig { params(node: Prism::ClassVariableOperatorWriteNode).void }
def on_class_variable_operator_write_node_enter(node)
handle_class_variable(node, node.name_loc)
end

sig { params(node: Prism::ClassVariableOrWriteNode).void }
def on_class_variable_or_write_node_enter(node)
handle_class_variable(node, node.name_loc)
end

sig { params(node: Prism::ClassVariableTargetNode).void }
def on_class_variable_target_node_enter(node)
handle_class_variable(node, node.location)
end

sig { params(node: Prism::ClassVariableWriteNode).void }
def on_class_variable_write_node_enter(node)
handle_class_variable(node, node.name_loc)
end

sig do
params(
name: String,
Expand Down Expand Up @@ -552,6 +582,40 @@ def handle_global_variable(node, loc)
))
end

sig do
params(
node: T.any(
Prism::ClassVariableAndWriteNode,
Prism::ClassVariableOperatorWriteNode,
Prism::ClassVariableOrWriteNode,
Prism::ClassVariableTargetNode,
Prism::ClassVariableWriteNode,
),
loc: Prism::Location,
).void
end
def handle_class_variable(node, loc)
name = node.name.to_s
return if name == "@@"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add a comment to explain that this isn't valid Ruby, but the code could be temporarily in this state.


comments = collect_comments(node)

owner = @owner_stack.last

# set the class variable's owner to the attached context when defined within a singleton scope.
if owner.is_a?(Entry::SingletonClass)
owner = @owner_stack.reverse.find { |entry| !entry.name.include?("<Class:") }
end

@index.add(Entry::ClassVariable.new(
name,
@uri,
Location.from_prism_location(loc, @code_units_cache),
comments,
owner,
))
Comment on lines +612 to +618
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we actually need to add the class variables to both the attached and the first level singleton class. For example:

# Inside singleton context
class Foo
  class << self
    @@hello = 123
  end
end

Foo.class_variables # => [:@@hello]
Foo.singleton_class.class_variables # => [:@@hello]
Foo.singleton_class.singleton_class.class_variables # => []

# Outside singleton context
class Bar
  @@hello = 123
end

Bar.class_variables # => [:@@hello]
Bar.singleton_class.class_variables # => [:@@hello]
Bar.singleton_class.singleton_class.class_variables # => []

So it seems that we need to always add to both. Can you please add a test for that?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, should we push two entries: one for the singleton class and another for the base class?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, correct. That will make the features properly find the variable both in a singleton context or in the attached class context, since it is available for both.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have updated the logic to add class variable entries to both the singleton class and the base class.

    def handle_class_variable(node, loc)
      name = node.name.to_s
      return if name == "@@"

      comments = collect_comments(node)
      owner_stack = @owner_stack.reverse

      owner = owner_stack.shift
      @index.add(Entry::ClassVariable.new(
        name,
        @uri,
        Location.from_prism_location(loc, @code_units_cache),
        comments,
        owner,
      ))

      case owner
      when Entry::SingletonClass
        owner_stack.each do |owner|
          @index.add(Entry::ClassVariable.new(
            name,
            @uri,
            Location.from_prism_location(loc, @code_units_cache),
            comments,
            owner,
          ))

          break unless owner.is_a?(Entry::SingletonClass)
        end
      when Entry::Class
        @index.add(Entry::ClassVariable.new(
          name,
          @uri,
          Location.from_prism_location(loc, @code_units_cache),
          comments,
          @index.existing_or_new_singleton_class(owner.name),
        ))
      end
    end

If the owner is a class node, I add a class variable entry to both its class and its singleton class. If the node is a singleton class, I reverse the owner stack and continue adding the class variable to the owners in the stack, stopping once I reach the second owner that is not a singleton class. Bubbling up works as expected, but I am uncertain how to implement bubbling in.

Screencast.from.15-12-24.12.23.09.AM.IST.webm

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why we need to go through the owner stack in reverse. Can we not do something like this?

owner = current_owner
return unless owner

if owner.is_a?(Entry::SingletonClass)
  # If we're inside a singleton context of a class or module, add the
  # variable to the attached namespace
  @index.add(Entry::ClassVariable.new(
    name,
    @uri, 
    Location.from_prism_location(loc, @code_units_cache), 
    comments,
    @index.attached_class(owner.name),
   ))
else
  # If we're inside a class or a module, add the variable to the singleton
  # class of that namespace
  @index.add(Entry::ClassVariable.new(
    name,
    @uri, 
    Location.from_prism_location(loc, @code_units_cache), 
    comments,
    @index.existing_or_new_singleton_class(owner.name),
   ))
end

And then you essentially need to implement Index#attached_class to do the reverse of what existing_or_new_singleton_class. Something like this

def attached_class(name)
  # Build the attached name excluding the last portion, which for singletons
  # is always `<Class:Foo>`
  parts = name.split("::")
  attached_name = parts[...-1].join("::")

  # Return the existing namespaces for the attached name
  self[attached_name]&.grep(Entry::Namespace)
end

Copy link
Contributor Author

@rogancodes rogancodes Dec 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entirely confident about the singleton context and feel a bit confused regarding how our DeclarartionListener and TypeInferrer interpret it. Consider the example below.

class Foo
  # The declaration listener interprets the owner here as "Foo".
  # The type inferrer interprets the owner here as "Foo::<Class:Foo>".
  @@aa = 1

  class << self
    # The declaration listener interprets the owner here as "Foo::<Class:Foo>".
    # The type inferrer interprets the owner here as "Foo::<Class:Foo>::<Class:<Class:Foo>>".
    @@ab = 2

    def bar
      # The declaration listener interprets the owner here as "Foo::<Class:Foo>".
      # The type inferrer interprets the owner here as "Foo::<Class:Foo>".
      @@ac = 3
    end
  end

  def foobar
    # The declaration listener interprets the owner here as "Foo".
    # The type inferrer interprets the owner here as "Foo".
    @@ad = 5
  end
end

I reversed the owner stack based on the owner names inferred by the TypeInferrer. For instance, at the line where @@ab is defined, the owner stack would appear as [Foo, Foo::Class:Foo, Foo::Class:Foo::<Class:Class:Foo>]. Given that class variables are shared across these contexts, I reversed the stack and added a class variable entry for @@ab to each owner until encountering a class entry, using an each loop in a do-while manner.

However, after reviewing your comments, I debugged the DeclarationListener and found that its inferred context differs from the TypeInferrer's inferred context. This discrepancy might be the source of confusion. This discrepancy has left me wondering, am I missing something or misunderstanding how these contexts works?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand the confusion now. The changes in the type inferrer are assuming that class variables will behave exactly like instance variables, which is likely the source of the issue.

I think we may be able to do this:

  1. In the declaration listener during indexing, only add an entry for the attached class (forget my previous comment about storing a copy in the singleton version)
  2. Then in the type inferrer, when detecting the self type for class variables, we essentially always return whatever the attached class of the current context is, since the class variables will always be associated to them. Something like this
class Foo
  # The inferred `self` here is `Foo` for class variables
  @@var = 123

  class << self
    # The inferred `self` here is also `Foo` for class variables
    @@other = 123
  end
end

I think that should lead to all of the features working as expected and it will simplify the declaration listener. Let me know if you find that this is not the right way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have made the changes as per your suggestion.

end

sig do
params(
node: T.any(
Expand Down
20 changes: 20 additions & 0 deletions lib/ruby_indexer/lib/ruby_indexer/entry.rb
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,26 @@ def initialize(target, unresolved_alias)
# Represents a global variable e.g.: $DEBUG
class GlobalVariable < Entry; end

# Represents a class variable e.g.: @@a = 1
class ClassVariable < Entry
sig { returns(T.nilable(Entry::Namespace)) }
attr_reader :owner

sig do
params(
name: String,
uri: URI::Generic,
location: Location,
comments: T.nilable(String),
owner: T.nilable(Entry::Namespace),
).void
end
def initialize(name, uri, location, comments, owner)
super(name, uri, location, comments)
@owner = owner
end
end

# Represents an instance variable e.g.: @a = 1
class InstanceVariable < Entry
sig { returns(T.nilable(Entry::Namespace)) }
Expand Down
21 changes: 21 additions & 0 deletions lib/ruby_indexer/lib/ruby_indexer/index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,17 @@ def resolve_instance_variable(variable_name, owner_name)
entries.select { |e| ancestors.include?(e.owner&.name) }
end

sig { params(variable_name: String, owner_name: String).returns(T.nilable(T::Array[Entry::ClassVariable])) }
def resolve_class_variable(variable_name, owner_name)
entries = self[variable_name]&.grep(Entry::ClassVariable)
return unless entries&.any?

ancestors = linearized_ancestors_of(owner_name)
return if ancestors.empty?

entries.select { |e| ancestors.include?(e.owner&.name) }
end

# Returns a list of possible candidates for completion of instance variables for a given owner name. The name must
# include the `@` prefix
sig { params(name: String, owner_name: String).returns(T::Array[Entry::InstanceVariable]) }
Expand All @@ -596,6 +607,16 @@ def instance_variable_completion_candidates(name, owner_name)
variables
end

sig { params(name: String, owner_name: String).returns(T::Array[Entry::ClassVariable]) }
def class_variable_completion_candidates(name, owner_name)
entries = T.cast(prefix_search(name).flatten, T::Array[Entry::ClassVariable])
ancestors = linearized_ancestors_of(owner_name)

variables = entries.select { |e| ancestors.any?(e.owner&.name) }
variables.uniq!(&:name)
variables
end

# Synchronizes a change made to the given URI. This method will ensure that new declarations are indexed, removed
# declarations removed and that the ancestor linearization cache is cleared if necessary
sig { params(uri: URI::Generic, source: String).void }
Expand Down
140 changes: 140 additions & 0 deletions lib/ruby_indexer/test/class_variables_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
# typed: true
# frozen_string_literal: true

require_relative "test_case"

module RubyIndexer
class ClassVariableTest < TestCase
def test_class_variable_and_write
index(<<~RUBY)
class Foo
@@bar &&= 1
end
RUBY

assert_entry("@@bar", Entry::ClassVariable, "/fake/path/foo.rb:1-2:1-7")

entry = T.must(@index["@@bar"]&.first)
owner = T.must(entry.owner)
assert_instance_of(Entry::Class, owner)
assert_equal("Foo", owner.name)
end

def test_class_variable_operator_write
index(<<~RUBY)
class Foo
@@bar += 1
end
RUBY

assert_entry("@@bar", Entry::ClassVariable, "/fake/path/foo.rb:1-2:1-7")
end

def test_class_variable_or_write
index(<<~RUBY)
class Foo
@@bar ||= 1
end
RUBY

assert_entry("@@bar", Entry::ClassVariable, "/fake/path/foo.rb:1-2:1-7")
end

def test_class_variable_target_node
index(<<~RUBY)
class Foo
@@foo, @@bar = 1
end
RUBY

assert_entry("@@foo", Entry::ClassVariable, "/fake/path/foo.rb:1-2:1-7")
assert_entry("@@bar", Entry::ClassVariable, "/fake/path/foo.rb:1-9:1-14")

entry = T.must(@index["@@foo"]&.first)
owner = T.must(entry.owner)
assert_instance_of(Entry::Class, owner)
assert_equal("Foo", owner.name)

entry = T.must(@index["@@bar"]&.first)
owner = T.must(entry.owner)
assert_instance_of(Entry::Class, owner)
assert_equal("Foo", owner.name)
end

def test_class_variable_write
index(<<~RUBY)
class Foo
@@bar = 1
end
RUBY

assert_entry("@@bar", Entry::ClassVariable, "/fake/path/foo.rb:1-2:1-7")
end

def test_empty_name_class_variable
index(<<~RUBY)
module Foo
@@ = 1
end
RUBY

refute_entry("@@")
end

def test_top_level_class_variable
index(<<~RUBY)
@foo = 123
RUBY

entry = T.must(@index["@foo"]&.first)
assert_nil(entry.owner)
end

def test_class_variable_inside_self_method
index(<<~RUBY)
class Foo
def self.bar
@@bar = 123
end
end
RUBY

entry = T.must(@index["@@bar"]&.first)
owner = T.must(entry.owner)
assert_instance_of(Entry::Class, owner)
assert_equal("Foo", owner.name)
end

def test_class_variable_inside_singleton_class
index(<<~RUBY)
class Foo
class << self
@@bar = 123
end
end
RUBY

entry = T.must(@index["@@bar"]&.first)
owner = T.must(entry.owner)
assert_instance_of(Entry::Class, owner)
assert_equal("Foo", owner.name)
end

def test_class_variable_in_singleton_class_method
index(<<~RUBY)
class Foo
class << self
def self.bar
@@bar = 123
end
end
end
RUBY

entry = T.must(@index["@@bar"]&.first)
owner = T.must(entry.owner)
assert_instance_of(Entry::Class, owner)
assert_equal("Foo", owner.name)
end
end
end
67 changes: 67 additions & 0 deletions lib/ruby_lsp/listeners/completion.rb
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ def initialize( # rubocop:disable Metrics/ParameterLists
:on_instance_variable_operator_write_node_enter,
:on_instance_variable_or_write_node_enter,
:on_instance_variable_target_node_enter,
:on_class_variable_and_write_node_enter,
:on_class_variable_operator_write_node_enter,
:on_class_variable_or_write_node_enter,
:on_class_variable_read_node_enter,
:on_class_variable_target_node_enter,
:on_class_variable_write_node_enter,
)
end

Expand Down Expand Up @@ -246,6 +252,36 @@ def on_instance_variable_target_node_enter(node)
handle_instance_variable_completion(node.name.to_s, node.location)
end

sig { params(node: Prism::ClassVariableAndWriteNode).void }
def on_class_variable_and_write_node_enter(node)
handle_class_variable_completion(node.name.to_s, node.name_loc)
end

sig { params(node: Prism::ClassVariableOperatorWriteNode).void }
def on_class_variable_operator_write_node_enter(node)
handle_class_variable_completion(node.name.to_s, node.name_loc)
end

sig { params(node: Prism::ClassVariableOrWriteNode).void }
def on_class_variable_or_write_node_enter(node)
handle_class_variable_completion(node.name.to_s, node.name_loc)
end

sig { params(node: Prism::ClassVariableTargetNode).void }
def on_class_variable_target_node_enter(node)
handle_class_variable_completion(node.name.to_s, node.location)
end

sig { params(node: Prism::ClassVariableReadNode).void }
def on_class_variable_read_node_enter(node)
handle_class_variable_completion(node.name.to_s, node.location)
end

sig { params(node: Prism::ClassVariableWriteNode).void }
def on_class_variable_write_node_enter(node)
handle_class_variable_completion(node.name.to_s, node.name_loc)
end

private

sig { params(name: String, range: Interface::Range).void }
Expand Down Expand Up @@ -326,6 +362,37 @@ def handle_global_variable_completion(name, location)
end
end

sig { params(name: String, location: Prism::Location).void }
def handle_class_variable_completion(name, location)
type = @type_inferrer.infer_receiver_type(@node_context)
return unless type

range = range_from_location(location)

@index.class_variable_completion_candidates(name, type.name).each do |entry|
variable_name = entry.name

label_details = Interface::CompletionItemLabelDetails.new(
description: entry.file_name,
)

@response_builder << Interface::CompletionItem.new(
label: variable_name,
label_details: label_details,
text_edit: Interface::TextEdit.new(
range: range,
new_text: variable_name,
),
kind: Constant::CompletionItemKind::FIELD,
data: {
owner_name: entry.owner&.name,
},
)
end
rescue RubyIndexer::Index::NonExistingNamespaceError
# If by any chance we haven't indexed the owner, then there's no way to find the right declaration
end

sig { params(name: String, location: Prism::Location).void }
def handle_instance_variable_completion(name, location)
# Sorbet enforces that all instance variables be declared on typed strict or higher, which means it will be able
Expand Down
Loading
Loading