Skip to content

Commit 7b08fe2

Browse files
committed
Enhance scope definition resolution and add tests for nested class constants
1 parent 6a5a75d commit 7b08fe2

2 files changed

Lines changed: 130 additions & 33 deletions

File tree

lib/ruby_language_server/project_manager.rb

Lines changed: 64 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ def word_at_location(uri, position)
160160

161161
def possible_definitions(uri, position)
162162
name = word_at_location(uri, position)
163-
return {} if name.blank?
163+
return [] if name.blank?
164164

165165
# Special case: "new" should find "initialize" (instance method, not class method)
166166
name = 'initialize' if name == 'new'
@@ -230,60 +230,91 @@ def possible_definitions(uri, position)
230230
results = scope_definitions_for(name, scope, uri)
231231
return results unless results.empty?
232232

233-
project_definitions_for(name, current_scopes)
233+
# Always return an array
234+
project_definitions_for(name, current_scopes) || []
234235
end
235236

236237
# Return variables found in the current scope. After all, those are the important ones.
237238
# Should probably be private...
238239
def scope_definitions_for(name, scope, uri)
239240
check_scope = scope
240-
return_array = []
241+
matches = []
241242
while check_scope
242243
check_scope.variables.each do |variable|
243-
return_array << Location.hash(uri, variable.line, 1) if variable.name == name
244+
matches << Location.hash(uri, variable.line, 1) if variable.name == name
244245
end
245246
check_scope = check_scope.parent
246247
end
247-
RubyLanguageServer.logger.debug("==============>> scope_definitions_for(#{name}, #{scope.to_json}, #{uri}: #{return_array.uniq})")
248-
return_array.uniq
248+
RubyLanguageServer.logger.debug("==============>> scope_definitions_for(#{name}, #{scope.to_json}, #{uri}: #{matches.inspect} )")
249+
matches
249250
end
250251

251252
# class_method_filter is for new -> initialize
252253
def project_definitions_for(name, parent_scopes = [], class_method_filter = nil)
253-
results = []
254-
254+
# If no parent scope, just return all matches as before
255255
if parent_scopes.empty?
256-
# No parent scopes provided - search all top-level scopes
257256
all_scopes = RubyLanguageServer::ScopeData::Scope.where(name: name)
258257
all_scopes = all_scopes.where(class_method: class_method_filter) unless class_method_filter.nil?
259-
results.concat(all_scopes.to_a)
260-
261-
# Also search for constants at root level
262258
all_variables = RubyLanguageServer::ScopeData::Variable.where(name: name)
263-
results.concat(all_variables.to_a)
264-
else
265-
# Start with the deepest (first) scope and search upward through parent chain
266-
current_scope = parent_scopes.first
267-
while current_scope
268-
# Search for child scopes with matching name in current scope
269-
child_scopes = current_scope.children.where(name: name)
270-
child_scopes = child_scopes.where(class_method: class_method_filter) unless class_method_filter.nil?
271-
results.concat(child_scopes.to_a)
272-
273-
# Search for variables with matching name in current scope
274-
matching_variables = current_scope.variables.where(name: name)
275-
results.concat(matching_variables.to_a)
276-
277-
# If we found results, stop searching (most specific scope wins)
278-
break unless results.empty?
279-
280-
# Move up to parent scope
281-
current_scope = current_scope.parent
259+
all_matches = all_scopes.to_a + all_variables.to_a
260+
return all_matches.reject { |item| item.code_file.nil? }.map do |item|
261+
line = item.respond_to?(:top_line) ? item.top_line : item.line
262+
Location.hash(item.code_file.uri, line, 1)
263+
end || []
264+
end
265+
266+
# First pass: Walk up the parent scope chain, looking ONLY for variables (closest wins)
267+
# Variables in scope chain take priority over child scopes and project-wide definitions
268+
supplied_scope = parent_scopes.first
269+
270+
current = supplied_scope
271+
while current
272+
# For constants, skip method scopes and keep searching up to class/module scopes
273+
if current.class_type == RubyLanguageServer::ScopeData::Scope::TYPE_METHOD
274+
current = current.parent
275+
next
276+
end
277+
278+
# Check variables (constants) in the current scope
279+
matching_variable = current.variables.where(name: name).reject { |item| item.code_file.nil? }.first
280+
if matching_variable
281+
line = matching_variable.respond_to?(:top_line) ? matching_variable.top_line : matching_variable.line
282+
return [Location.hash(matching_variable.code_file.uri, line, 1)]
282283
end
284+
285+
# Move up to parent scope and continue searching for variables
286+
current = current.parent
287+
end
288+
289+
# Second pass: If no variables found, check child scopes in the scope chain
290+
current = supplied_scope
291+
while current
292+
# For constants, skip method scopes and keep searching up to class/module scopes
293+
if current.class_type == RubyLanguageServer::ScopeData::Scope::TYPE_METHOD
294+
current = current.parent
295+
next
296+
end
297+
298+
# Check child scopes (classes/modules/methods) with this name (for class/module/method lookup)
299+
child_scopes = current.children.where(name: name)
300+
child_scopes = child_scopes.where(class_method: class_method_filter) unless class_method_filter.nil?
301+
unless child_scopes.empty?
302+
return child_scopes.reject { |item| item.code_file.nil? }.map do |item|
303+
line = item.respond_to?(:top_line) ? item.top_line : item.line
304+
Location.hash(item.code_file.uri, line, 1)
305+
end
306+
end
307+
308+
# Move up to parent scope and continue searching
309+
current = current.parent
283310
end
284311

285-
# Return locations for all matching scopes and variables
286-
results.reject { |item| item.code_file.nil? }.map do |item|
312+
# If nothing found in the scope chain, fall back to project-wide search
313+
all_scopes = RubyLanguageServer::ScopeData::Scope.where(name: name)
314+
all_scopes = all_scopes.where(class_method: class_method_filter) unless class_method_filter.nil?
315+
all_variables = RubyLanguageServer::ScopeData::Variable.where(name: name)
316+
all_matches = all_scopes.to_a + all_variables.to_a
317+
all_matches.reject { |item| item.code_file.nil? }.map do |item|
287318
line = item.respond_to?(:top_line) ? item.top_line : item.line
288319
Location.hash(item.code_file.uri, line, 1)
289320
end

spec/lib/ruby_language_server/project_manager_spec.rb

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,72 @@
44
require 'minitest/autorun'
55

66
describe RubyLanguageServer::ProjectManager do
7+
describe 'scope prioritization for duplicate names' do
8+
let(:file_with_nested_classes) do
9+
<<~CODE_FILE
10+
class Outer
11+
VALUE = 'outer'
12+
class Inner
13+
VALUE = 'inner'
14+
def show
15+
puts VALUE
16+
end
17+
end
18+
def after_inner
19+
puts VALUE
20+
end
21+
end
22+
CODE_FILE
23+
end
24+
25+
before(:each) do
26+
project_manager.update_document_content('dup_uri', file_with_nested_classes)
27+
project_manager.tags_for_uri('dup_uri')
28+
end
29+
30+
it 'returns the closest VALUE constant in the innermost scope' do
31+
# Dynamically find the line and character offset of 'VALUE' in the line containing 'puts VALUE'
32+
code_lines = file_with_nested_classes.lines
33+
value_line_num = code_lines.find_index { |l| l.include?('puts VALUE') }
34+
raise "No line with 'puts VALUE' found" if value_line_num.nil?
35+
36+
value_line = code_lines[value_line_num]
37+
value_char = value_line.index('VALUE')
38+
raise "'VALUE' not found in line: #{value_line.inspect}" if value_char.nil?
39+
40+
position = OpenStruct.new(line: value_line_num, character: value_char)
41+
results = project_manager.possible_definitions('dup_uri', position)
42+
assert_equal 2, results.length
43+
assert_equal 'dup_uri', results.first[:uri]
44+
assert_equal 'dup_uri', results.last[:uri]
45+
# Should find VALUE defined on line 3 (Inner::VALUE) first, then Outer::VALUE on line 1
46+
assert_equal 3, results.first[:range][:start][:line]
47+
assert_equal 1, results.last[:range][:start][:line]
48+
end
49+
50+
it 'returns the outer VALUE constant when in the outer scope' do
51+
# Dynamically find the line and character offset of 'VALUE' in the after_inner method
52+
project_manager.update_document_content('dup_uri', file_with_nested_classes)
53+
code_lines = file_with_nested_classes.lines
54+
value_line_num = code_lines.find_index { |l| l.include?('puts VALUE') && l.include?('after_inner').! }
55+
# Find the second occurrence of 'puts VALUE' (after_inner)
56+
occurrences = code_lines.each_with_index.select { |l, _i| l.include?('puts VALUE') }
57+
raise "No line with 'puts VALUE' found" if occurrences.empty?
58+
59+
# The second occurrence is for after_inner
60+
value_line_num = occurrences[1][1] if occurrences.size > 1
61+
value_line = code_lines[value_line_num]
62+
value_char = value_line.index('VALUE')
63+
raise "'VALUE' not found in line: #{value_line.inspect}" if value_char.nil?
64+
65+
position = OpenStruct.new(line: value_line_num, character: value_char)
66+
results = project_manager.possible_definitions('dup_uri', position)
67+
assert_equal 1, results.length
68+
assert_equal 'dup_uri', results.first[:uri]
69+
# Should find VALUE defined on line 1 (Outer::VALUE)
70+
assert_equal 1, results.first[:range][:start][:line]
71+
end
72+
end
773
let(:rails_file_text) do
874
<<~CODE_FILE
975
class Foo < ActiveRecord::Base

0 commit comments

Comments
 (0)