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

Conversation

rogancodes
Copy link
Contributor

@rogancodes rogancodes commented Dec 1, 2024

Motivation

closes #2897

Implementation

I’ve made separate commits for each feature, hoping it makes the review process easier.

Automated Tests

Added new tests

Manual Tests

Screencast.from.01-12-24.07.46.01.PM.IST.webm

@rogancodes rogancodes requested a review from a team as a code owner December 1, 2024 14:53
@vinistock vinistock added enhancement New feature or request server This pull request should be included in the server gem's release notes labels Dec 11, 2024
Copy link
Member

@vinistock vinistock left a comment

Choose a reason for hiding this comment

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

This is looking great. Just a small gotcha about class variables that we need to be careful about

Comment on lines +604 to +610
@index.add(Entry::ClassVariable.new(
name,
@uri,
Location.from_prism_location(loc, @code_units_cache),
comments,
owner,
))
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.

Comment on lines 589 to 590
entries = T.cast(self[variable_name], T.nilable(T::Array[Entry::ClassVariable]))
return unless entries
Copy link
Member

Choose a reason for hiding this comment

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

I think we can narrow the type with a grep, so that even if someone passes a name that doesn't belong to a class variable, it still works.

Suggested change
entries = T.cast(self[variable_name], T.nilable(T::Array[Entry::ClassVariable]))
return unless entries
entries = self[variable_name]&.grep(Entry::ClassVariable)
return unless entries&.any?

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.

end
end

def test_completion_for_class_variables_show_only_uniq_entries
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
def test_completion_for_class_variables_show_only_uniq_entries
def test_completion_for_class_variables_show_only_unique_entries

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request server This pull request should be included in the server gem's release notes
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add definition, hover and completion support for class variables
3 participants