Skip to content

Commit 8d34acd

Browse files
committed
Handle visibility in go to definition
1 parent 95e4f88 commit 8d34acd

2 files changed

Lines changed: 164 additions & 8 deletions

File tree

lib/ruby_lsp/listeners/definition.rb

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +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)
306+
305307
decl.definitions.each do |definition|
306308
location = definition.location
307309
uri = URI(location.uri)
@@ -317,6 +319,31 @@ def handle_method_definition(message, receiver_type, inherited_only: false)
317319
end
318320
end
319321

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+
320347
#: (Prism::StringNode node, Symbol message) -> void
321348
def handle_require_definition(node, message)
322349
case message
@@ -368,12 +395,13 @@ def handle_constant_definition(value)
368395
declaration = @graph.resolve_constant(value, @node_context.nesting)
369396
return unless declaration
370397

371-
# [RUBYDEX] TODO: temporarily commented out until we have the visibility API
372-
#
373-
# We should only allow jumping to the definition of private constants if the constant is defined in the same
374-
# namespace as the reference
375-
#
376-
# return if declaration.private? && declaration.name != "#{@node_context.fully_qualified_name}::#{value}"
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
377405

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

test/requests/definition_expectations_test.rb

Lines changed: 130 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,6 @@ class A
180180
end
181181

182182
def test_jumping_to_private_constant_from_different_namespace
183-
skip("[RUBYDEX] Requires the visibility API")
184-
185183
source = <<~RUBY
186184
class A
187185
CONST = 123
@@ -201,6 +199,136 @@ class A
201199
end
202200
end
203201

202+
def test_jumping_to_private_method_with_implicit_self
203+
source = <<~RUBY
204+
class A
205+
def bar
206+
foo
207+
end
208+
209+
private
210+
211+
def foo; end
212+
end
213+
RUBY
214+
215+
with_server(source, stub_no_typechecker: true) do |server, uri|
216+
server.process_message(
217+
id: 1,
218+
method: "textDocument/definition",
219+
params: { textDocument: { uri: uri }, position: { character: 4, line: 2 } },
220+
)
221+
response = server.pop_response.response
222+
refute_empty(response)
223+
assert_equal(uri.to_s, response.first.attributes[:targetUri])
224+
end
225+
end
226+
227+
def test_does_not_jump_to_private_method_called_with_explicit_external_receiver
228+
source = <<~RUBY
229+
class A
230+
private
231+
232+
def foo; end
233+
end
234+
235+
class B
236+
def bar
237+
a = A.new
238+
a.foo
239+
end
240+
end
241+
RUBY
242+
243+
with_server(source, stub_no_typechecker: true) do |server, uri|
244+
server.process_message(
245+
id: 1,
246+
method: "textDocument/definition",
247+
params: { textDocument: { uri: uri }, position: { character: 6, line: 9 } },
248+
)
249+
assert_empty(server.pop_response.response)
250+
end
251+
end
252+
253+
def test_jumps_to_protected_method_inside_same_class
254+
source = <<~RUBY
255+
class A
256+
def bar
257+
self.foo
258+
end
259+
260+
protected
261+
262+
def foo; end
263+
end
264+
RUBY
265+
266+
with_server(source, stub_no_typechecker: true) do |server, uri|
267+
server.process_message(
268+
id: 1,
269+
method: "textDocument/definition",
270+
params: { textDocument: { uri: uri }, position: { character: 9, line: 2 } },
271+
)
272+
response = server.pop_response.response
273+
refute_empty(response)
274+
assert_equal(uri.to_s, response.first.attributes[:targetUri])
275+
end
276+
end
277+
278+
def test_jumps_to_protected_method_called_from_subclass
279+
source = <<~RUBY
280+
class A
281+
protected
282+
283+
def foo; end
284+
end
285+
286+
class B < A
287+
def bar
288+
a = A.new
289+
a.foo
290+
end
291+
end
292+
RUBY
293+
294+
with_server(source, stub_no_typechecker: true) do |server, uri|
295+
server.process_message(
296+
id: 1,
297+
method: "textDocument/definition",
298+
params: { textDocument: { uri: uri }, position: { character: 6, line: 9 } },
299+
)
300+
response = server.pop_response.response
301+
refute_empty(response)
302+
assert_equal(uri.to_s, response.first.attributes[:targetUri])
303+
end
304+
end
305+
306+
def test_does_not_jump_to_protected_method_from_unrelated_class
307+
source = <<~RUBY
308+
class A
309+
protected
310+
311+
def foo; end
312+
end
313+
314+
class C
315+
def bar
316+
a = A.new
317+
a.foo
318+
end
319+
end
320+
RUBY
321+
322+
with_server(source, stub_no_typechecker: true) do |server, uri|
323+
server.process_message(
324+
id: 1,
325+
method: "textDocument/definition",
326+
params: { textDocument: { uri: uri }, position: { character: 6, line: 9 } },
327+
)
328+
assert_empty(server.pop_response.response)
329+
end
330+
end
331+
204332
def test_definition_addons
205333
source = <<~RUBY
206334
class Target

0 commit comments

Comments
 (0)