Skip to content

Commit 60f4635

Browse files
authored
Enhance constant lookup to support namespaced constants in ProjectManager (#134)
* Enhance constant lookup to support namespaced constants in ProjectManager * cruft
1 parent ffb821d commit 60f4635

2 files changed

Lines changed: 151 additions & 30 deletions

File tree

lib/ruby_language_server/project_manager.rb

Lines changed: 94 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -167,34 +167,59 @@ def possible_definitions(uri, position)
167167

168168
context = context_at_location(uri, position)
169169

170+
# Get current scopes for context
171+
current_scopes = scopes_at(uri, position)
172+
170173
# If context has more than one element it could be a method call or namespace reference
171174
if context.length > 1
172-
# Check if this is a namespace reference (Foo::Bar) vs method call (Foo.bar)
173-
if namespace_reference?(uri, position, context)
174-
# Join the context with :: to form the full class/module name
175-
full_name = context.join('::')
176-
return project_definitions_for(full_name)
175+
# Find the rightmost class/module reference in the context (excluding the last element which is the name)
176+
# Examples:
177+
# - foo.Bar::Baz.something -> find Baz (index 2), build scope from Bar::Baz
178+
# - Bar.foo.Baz.something -> find Baz (index 2), use Baz as scope
179+
class_module_indices = []
180+
(0...(context.length - 1)).each do |i|
181+
class_module_indices << i if likely_class_name?(context[i])
177182
end
178183

179-
receiver = context.first
180-
# Determine if it's a class method call (Foo.method) or instance method call (foo.method)
181-
class_method_filter = name != 'initialize'
182-
return project_definitions_for(name, class_method_filter) if likely_class_name?(receiver)
184+
if class_module_indices.any?
185+
# Use the rightmost class/module and build the path from there to just before the name
186+
rightmost_class_index = class_module_indices.last
187+
scope_path_parts = context[rightmost_class_index..-2]
188+
189+
if scope_path_parts.empty?
190+
# This shouldn't happen, but handle it gracefully
191+
return project_definitions_for(name, current_scopes)
192+
elsif scope_path_parts.length == 1
193+
# Single class/module like Bar.something or Foo::Bar
194+
parent_scope = find_scope_by_path(scope_path_parts.first)
195+
else
196+
# Multiple parts like Bar::Baz.something or after finding Bar in foo.Bar::Baz.something
197+
scope_path = scope_path_parts.join('::')
198+
parent_scope = find_scope_by_path(scope_path)
199+
end
200+
201+
# Determine if it's a class/module lookup or a method call
202+
# If the name also looks like a class/module name, it's a namespace lookup (Foo::Bar)
203+
# Otherwise it's a method call (Foo.method or Foo::Bar.method)
204+
return project_definitions_for(name, parent_scope ? [parent_scope] : []) if likely_class_name?(name) || constant_name?(name)
183205

184-
# Class method call or MyClass.new (which finds initialize as instance method)
185-
# initialize is weird because it's defined as an instance method but called on the class via new.
206+
# Method call - determine if it's a class method or instance method
207+
class_method_filter = name != 'initialize'
208+
return project_definitions_for(name, parent_scope ? [parent_scope] : [], class_method_filter)
186209

187-
# Instance method call (e.g., foo.bar, @foo.bar, FOO.bar)
188-
return project_definitions_for(name, false)
210+
end
189211

212+
# No class/module found in chain, treat as instance method call on unknown type
213+
# Search project-wide for all instance methods with this name
214+
return project_definitions_for(name, [], false)
190215
end
191216

192217
# No receiver - search in scope chain first, then project-wide
193-
scope = scopes_at(uri, position).first
218+
scope = current_scopes.first
194219
results = scope_definitions_for(name, scope, uri)
195220
return results unless results.empty?
196221

197-
project_definitions_for(name)
222+
project_definitions_for(name, current_scopes)
198223
end
199224

200225
# Return variables found in the current scope. After all, those are the important ones.
@@ -212,26 +237,65 @@ def scope_definitions_for(name, scope, uri)
212237
return_array.uniq
213238
end
214239

215-
def project_definitions_for(name, class_method_filter = nil)
216-
# Check if name contains namespace separator (e.g., "Foo::Bar")
217-
# If so, search by path instead of name
218-
scopes = if name.include?('::')
219-
RubyLanguageServer::ScopeData::Scope.where(path: name)
220-
else
221-
RubyLanguageServer::ScopeData::Scope.where(name:)
222-
end
223-
224-
# Filter by class_method attribute if specified
225-
scopes = scopes.where(class_method: class_method_filter) unless class_method_filter.nil?
226-
227-
variables = RubyLanguageServer::ScopeData::Variable.constant_variables.where(name:)
228-
(scopes + variables).reject { |scope| scope.code_file.nil? }.map do |scope|
229-
Location.hash(scope.code_file.uri, scope.top_line, 1)
240+
# class_method_filter is for new -> initialize
241+
def project_definitions_for(name, parent_scopes = [], class_method_filter = nil)
242+
results = []
243+
244+
if parent_scopes.empty?
245+
# No parent scopes provided - search all top-level scopes
246+
all_scopes = RubyLanguageServer::ScopeData::Scope.where(name: name)
247+
all_scopes = all_scopes.where(class_method: class_method_filter) unless class_method_filter.nil?
248+
results.concat(all_scopes.to_a)
249+
250+
# Also search for constants at root level
251+
all_variables = RubyLanguageServer::ScopeData::Variable.where(name: name)
252+
results.concat(all_variables.to_a)
253+
else
254+
# Start with the deepest (first) scope and search upward through parent chain
255+
current_scope = parent_scopes.first
256+
while current_scope
257+
# Search for child scopes with matching name in current scope
258+
child_scopes = current_scope.children.where(name: name)
259+
child_scopes = child_scopes.where(class_method: class_method_filter) unless class_method_filter.nil?
260+
results.concat(child_scopes.to_a)
261+
262+
# Search for variables with matching name in current scope
263+
matching_variables = current_scope.variables.where(name: name)
264+
results.concat(matching_variables.to_a)
265+
266+
# If we found results, stop searching (most specific scope wins)
267+
break unless results.empty?
268+
269+
# Move up to parent scope
270+
current_scope = current_scope.parent
271+
end
272+
end
273+
274+
# Return locations for all matching scopes and variables
275+
results.reject { |item| item.code_file.nil? }.map do |item|
276+
line = item.respond_to?(:top_line) ? item.top_line : item.line
277+
Location.hash(item.code_file.uri, line, 1)
230278
end
231279
end
232280

233281
private
234282

283+
# Find a scope by its path (e.g., "Foo::Bar")
284+
# Returns nil if path is nil or empty (for root scope searches)
285+
def find_scope_by_path(path)
286+
return nil if path.nil? || path.empty?
287+
288+
RubyLanguageServer::ScopeData::Scope.find_by(path: path)
289+
end
290+
291+
# Check if a name looks like a constant (all uppercase)
292+
def constant_name?(name)
293+
# Must start with uppercase letter and contain no lowercase letters
294+
return false unless /\A[A-Z]/.match?(name)
295+
296+
!/[a-z]/.match?(name)
297+
end
298+
235299
# Check if the context represents a namespace reference (Foo::Bar) rather than a method call (Foo.bar)
236300
# Class/module lookups always start with uppercase letters, method calls never do
237301
def namespace_reference?(_uri, _position, context)

spec/lib/ruby_language_server/project_manager_spec.rb

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,5 +359,62 @@ def some_method(meaningful) # line 17: parameter declaration
359359
assert_equal 11, results.first[:range][:start][:line]
360360
end
361361
end
362+
363+
describe 'constant lookup with namespace' do
364+
let(:file_with_namespaced_constant) do
365+
<<~CODE_FILE
366+
module Foo
367+
BAR = "bar constant"
368+
369+
class Baz
370+
QUUX = "quux constant"
371+
end
372+
end
373+
374+
# Using the constant
375+
puts Foo::BAR
376+
puts Foo::Baz::QUUX
377+
CODE_FILE
378+
end
379+
380+
before(:each) do
381+
project_manager.update_document_content('const_uri', file_with_namespaced_constant)
382+
project_manager.tags_for_uri('const_uri') # Force load of tags
383+
end
384+
385+
it 'finds constant definition when clicking on constant in Foo::BAR' do
386+
# Position on "BAR" in "Foo::BAR" (line 9, character 10)
387+
# The line is: "puts Foo::BAR"
388+
position = OpenStruct.new(line: 9, character: 10)
389+
results = project_manager.possible_definitions('const_uri', position)
390+
391+
# Should find the BAR constant definition on line 1
392+
assert_equal 1, results.length, "Expected to find 1 definition for Foo::BAR, but got #{results.length}"
393+
assert_equal 'const_uri', results.first[:uri]
394+
assert_equal 1, results.first[:range][:start][:line]
395+
end
396+
397+
it 'finds constant definition when clicking on nested constant in Foo::Baz::QUUX' do
398+
# Position on "QUUX" in "Foo::Baz::QUUX" (line 10, character 16)
399+
position = OpenStruct.new(line: 10, character: 16)
400+
results = project_manager.possible_definitions('const_uri', position)
401+
402+
# Should find the QUUX constant definition on line 4
403+
assert_equal 1, results.length, "Expected to find 1 definition for Foo::Baz::QUUX, but got #{results.length}"
404+
assert_equal 'const_uri', results.first[:uri]
405+
assert_equal 4, results.first[:range][:start][:line]
406+
end
407+
408+
it 'finds module definition when clicking on Foo in Foo::BAR' do
409+
# Position on "Foo" in "Foo::BAR" (line 9, character 5)
410+
position = OpenStruct.new(line: 9, character: 5)
411+
results = project_manager.possible_definitions('const_uri', position)
412+
413+
# Should find the Foo module definition on line 0
414+
assert_equal 1, results.length
415+
assert_equal 'const_uri', results.first[:uri]
416+
assert_equal 0, results.first[:range][:start][:line]
417+
end
418+
end
362419
end
363420
end

0 commit comments

Comments
 (0)