Skip to content

Commit 5dfd9f8

Browse files
committed
Handle visibility in hover
1 parent b2a6eff commit 5dfd9f8

4 files changed

Lines changed: 181 additions & 37 deletions

File tree

lib/ruby_lsp/listeners/definition.rb

Lines changed: 3 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,8 @@ def handle_method_definition(message, receiver_type, inherited_only: false)
302302
return unless declaration
303303

304304
Array(declaration).each do |decl|
305-
next unless reachable_from_call_site?(decl, receiver_type)
305+
next if decl.is_a?(Rubydex::Method) &&
306+
!method_reachable_from_call_site?(decl, receiver_type, @graph, @node_context)
306307

307308
decl.definitions.each do |definition|
308309
location = definition.location
@@ -319,31 +320,6 @@ def handle_method_definition(message, receiver_type, inherited_only: false)
319320
end
320321
end
321322

322-
# A method is reachable from the call site when:
323-
# - it is public, or there's no concrete receiver type to compare against
324-
# - the call site is inside the receiver's own namespace (implicit/self call)
325-
# - it is protected and the call site's class is in the same hierarchy as the method's defining class
326-
#
327-
#: (Rubydex::Declaration, TypeInferrer::Type?) -> bool
328-
def reachable_from_call_site?(decl, receiver_type)
329-
return true unless receiver_type
330-
return true unless decl.is_a?(Rubydex::Method)
331-
332-
caller_namespace = @node_context.fully_qualified_name
333-
return true if caller_namespace == receiver_type.name
334-
335-
return true if decl.public?
336-
return false if decl.private?
337-
338-
owner = decl.owner
339-
return false unless owner.is_a?(Rubydex::Namespace)
340-
341-
caller_declaration = @graph[caller_namespace]
342-
return false unless caller_declaration.is_a?(Rubydex::Namespace)
343-
344-
caller_declaration.ancestors.any? { |ancestor| ancestor.name == owner.name }
345-
end
346-
347323
#: (Prism::StringNode node, Symbol message) -> void
348324
def handle_require_definition(node, message)
349325
case message
@@ -394,14 +370,7 @@ def handle_autoload_definition(node)
394370
def handle_constant_definition(value)
395371
declaration = @graph.resolve_constant(value, @node_context.nesting)
396372
return unless declaration
397-
398-
# Only allow jumping to the definition of a private constant if the constant is defined in the same namespace
399-
# as the reference. Not every declaration kind exposes visibility (e.g. unresolved namespaces), so we gate on
400-
# the Visibility module
401-
if declaration.is_a?(Rubydex::Visibility) && declaration.private? &&
402-
declaration.name != "#{@node_context.fully_qualified_name}::#{value}"
403-
return
404-
end
373+
return unless constant_reachable_from_call_site?(declaration, value, @node_context)
405374

406375
declaration.definitions.each do |definition|
407376
# If the project has Sorbet, then we only want to handle go to definition for constants defined in gems, as an

lib/ruby_lsp/listeners/hover.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,7 @@ def handle_method_hover(message, inherited_only: false)
461461
return unless methods
462462

463463
first_method = methods.first #: as !nil
464+
return unless method_reachable_from_call_site?(first_method, type, @graph, @node_context)
464465

465466
title = "#{message}#{first_method.decorated_parameters}"
466467
title << first_method.formatted_signatures
@@ -513,6 +514,7 @@ def handle_variable_hover(name)
513514
def generate_hover(name, location)
514515
declaration = @graph.resolve_constant(name, @node_context.nesting)
515516
return unless declaration
517+
return unless constant_reachable_from_call_site?(declaration, name, @node_context)
516518

517519
categorized_markdown_from_definitions(declaration.name, declaration.definitions).each do |category, content|
518520
@response_builder.push(content, category: category)

lib/ruby_lsp/requests/support/common.rb

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,46 @@ def self_receiver?(node)
7777
receiver.nil? || receiver.is_a?(Prism::SelfNode)
7878
end
7979

80+
# Returns true when a constant declaration is reachable from the call site. Private constants are only
81+
# reachable from within the namespace where they are defined.
82+
#
83+
#: (Rubydex::Declaration declaration, String value, NodeContext node_context) -> bool
84+
def constant_reachable_from_call_site?(declaration, value, node_context)
85+
return true unless declaration.is_a?(Rubydex::Visibility) && declaration.private?
86+
87+
declaration.name == "#{node_context.fully_qualified_name}::#{value}"
88+
end
89+
90+
# Returns true when a method is reachable from the call site, considering visibility and receiver type.
91+
# A method is reachable when:
92+
# - there's no concrete receiver type to compare against
93+
# - the call site is inside the receiver's own namespace (implicit/self call)
94+
# - it is public
95+
# - it is protected and the call site's class is in the same hierarchy as the method's defining class
96+
#
97+
# The `method_decl` is duck-typed to support `Rubydex::Method`, `RubyIndexer::Entry::Member` and
98+
# `RubyIndexer::Entry::MethodAlias`. All respond to `public?`, `private?` and `owner` (an object with a
99+
# `name` attribute).
100+
#
101+
#: ((Rubydex::Method | RubyIndexer::Entry::Member | RubyIndexer::Entry::MethodAlias) method_decl, TypeInferrer::Type? receiver_type, Rubydex::Graph graph, NodeContext node_context) -> bool
102+
def method_reachable_from_call_site?(method_decl, receiver_type, graph, node_context)
103+
return true unless receiver_type
104+
105+
caller_namespace = node_context.fully_qualified_name
106+
return true if caller_namespace == receiver_type.name
107+
108+
return true if method_decl.public?
109+
return false if method_decl.private?
110+
111+
owner_name = method_decl.owner&.name
112+
return false unless owner_name
113+
114+
caller_declaration = graph[caller_namespace]
115+
return false unless caller_declaration.is_a?(Rubydex::Namespace)
116+
117+
caller_declaration.ancestors.any? { |ancestor| ancestor.name == owner_name }
118+
end
119+
80120
#: (String, Enumerable[Rubydex::Definition], ?Integer?) -> Hash[Symbol, String]
81121
def categorized_markdown_from_definitions(title, definitions, max_entries = nil)
82122
markdown_title = "```ruby\n#{title}\n```"

test/requests/hover_expectations_test.rb

Lines changed: 136 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -267,7 +267,7 @@ class A
267267
private_constant(:CONST)
268268
end
269269
270-
A::CONST
270+
A::CONST # invalid private reference
271271
RUBY
272272

273273
with_server(source, stub_no_typechecker: true) do |server, uri|
@@ -277,8 +277,141 @@ class A
277277
params: { textDocument: { uri: uri }, position: { character: 3, line: 5 } },
278278
)
279279

280-
# TODO: once we have visibility exposed from Rubydex, let's show that the constant is private
281-
assert_match("A::CONST", server.pop_response.response.contents.value)
280+
assert_nil(server.pop_response.response)
281+
end
282+
end
283+
284+
def test_hovering_over_private_method_with_implicit_self
285+
source = <<~RUBY
286+
class A
287+
def bar
288+
foo
289+
end
290+
291+
private
292+
293+
# foo docs
294+
def foo; end
295+
end
296+
RUBY
297+
298+
with_server(source, stub_no_typechecker: true) do |server, uri|
299+
server.process_message(
300+
id: 1,
301+
method: "textDocument/hover",
302+
params: { textDocument: { uri: uri }, position: { character: 4, line: 2 } },
303+
)
304+
305+
assert_match("foo docs", server.pop_response.response.contents.value)
306+
end
307+
end
308+
309+
def test_does_not_hover_over_private_method_called_with_explicit_external_receiver
310+
source = <<~RUBY
311+
class A
312+
private
313+
314+
# foo docs
315+
def foo; end
316+
end
317+
318+
class B
319+
def bar
320+
a = A.new
321+
a.foo
322+
end
323+
end
324+
RUBY
325+
326+
with_server(source, stub_no_typechecker: true) do |server, uri|
327+
server.process_message(
328+
id: 1,
329+
method: "textDocument/hover",
330+
params: { textDocument: { uri: uri }, position: { character: 6, line: 10 } },
331+
)
332+
333+
assert_nil(server.pop_response.response)
334+
end
335+
end
336+
337+
def test_hovers_protected_method_inside_same_class
338+
source = <<~RUBY
339+
class A
340+
def bar
341+
self.foo
342+
end
343+
344+
protected
345+
346+
# foo docs
347+
def foo; end
348+
end
349+
RUBY
350+
351+
with_server(source, stub_no_typechecker: true) do |server, uri|
352+
server.process_message(
353+
id: 1,
354+
method: "textDocument/hover",
355+
params: { textDocument: { uri: uri }, position: { character: 9, line: 2 } },
356+
)
357+
358+
assert_match("foo docs", server.pop_response.response.contents.value)
359+
end
360+
end
361+
362+
def test_hovers_protected_method_called_from_subclass
363+
source = <<~RUBY
364+
class A
365+
protected
366+
367+
# foo docs
368+
def foo; end
369+
end
370+
371+
class B < A
372+
def bar
373+
a = A.new
374+
a.foo
375+
end
376+
end
377+
RUBY
378+
379+
with_server(source, stub_no_typechecker: true) do |server, uri|
380+
server.process_message(
381+
id: 1,
382+
method: "textDocument/hover",
383+
params: { textDocument: { uri: uri }, position: { character: 6, line: 10 } },
384+
)
385+
386+
assert_match("foo docs", server.pop_response.response.contents.value)
387+
end
388+
end
389+
390+
def test_does_not_hover_protected_method_from_unrelated_class
391+
source = <<~RUBY
392+
class A
393+
protected
394+
395+
# foo docs
396+
def foo; end
397+
end
398+
399+
class C
400+
def bar
401+
a = A.new
402+
a.foo
403+
end
404+
end
405+
RUBY
406+
407+
with_server(source, stub_no_typechecker: true) do |server, uri|
408+
server.process_message(
409+
id: 1,
410+
method: "textDocument/hover",
411+
params: { textDocument: { uri: uri }, position: { character: 6, line: 10 } },
412+
)
413+
414+
assert_nil(server.pop_response.response)
282415
end
283416
end
284417

0 commit comments

Comments
 (0)