Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 94 additions & 30 deletions lib/ruby_language_server/project_manager.rb
Original file line number Diff line number Diff line change
Expand Up @@ -167,34 +167,59 @@ def possible_definitions(uri, position)

context = context_at_location(uri, position)

# Get current scopes for context
current_scopes = scopes_at(uri, position)

# If context has more than one element it could be a method call or namespace reference
if context.length > 1
# Check if this is a namespace reference (Foo::Bar) vs method call (Foo.bar)
if namespace_reference?(uri, position, context)
# Join the context with :: to form the full class/module name
full_name = context.join('::')
return project_definitions_for(full_name)
# Find the rightmost class/module reference in the context (excluding the last element which is the name)
# Examples:
# - foo.Bar::Baz.something -> find Baz (index 2), build scope from Bar::Baz
# - Bar.foo.Baz.something -> find Baz (index 2), use Baz as scope
class_module_indices = []
(0...(context.length - 1)).each do |i|
class_module_indices << i if likely_class_name?(context[i])
end

receiver = context.first
# Determine if it's a class method call (Foo.method) or instance method call (foo.method)
class_method_filter = name != 'initialize'
return project_definitions_for(name, class_method_filter) if likely_class_name?(receiver)
if class_module_indices.any?
# Use the rightmost class/module and build the path from there to just before the name
rightmost_class_index = class_module_indices.last
scope_path_parts = context[rightmost_class_index..-2]

if scope_path_parts.empty?
# This shouldn't happen, but handle it gracefully
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

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

The comment states 'This shouldn't happen' but the code handles it. If this truly should never occur, consider raising an error or adding logging to track if this case is ever hit, which would help identify bugs in the namespace parsing logic.

Suggested change
# This shouldn't happen, but handle it gracefully
# This shouldn't happen, but handle it gracefully
warn "[RubyLanguageServer::ProjectManager] Unexpected empty scope_path_parts for context=#{context.inspect}, name=#{name.inspect}"

Copilot uses AI. Check for mistakes.
return project_definitions_for(name, current_scopes)
elsif scope_path_parts.length == 1
# Single class/module like Bar.something or Foo::Bar
parent_scope = find_scope_by_path(scope_path_parts.first)
else
# Multiple parts like Bar::Baz.something or after finding Bar in foo.Bar::Baz.something
scope_path = scope_path_parts.join('::')
parent_scope = find_scope_by_path(scope_path)
end

# Determine if it's a class/module lookup or a method call
# If the name also looks like a class/module name, it's a namespace lookup (Foo::Bar)
# Otherwise it's a method call (Foo.method or Foo::Bar.method)
return project_definitions_for(name, parent_scope ? [parent_scope] : []) if likely_class_name?(name) || constant_name?(name)

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

# Instance method call (e.g., foo.bar, @foo.bar, FOO.bar)
return project_definitions_for(name, false)
end

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

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

project_definitions_for(name)
project_definitions_for(name, current_scopes)
end

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

def project_definitions_for(name, class_method_filter = nil)
# Check if name contains namespace separator (e.g., "Foo::Bar")
# If so, search by path instead of name
scopes = if name.include?('::')
RubyLanguageServer::ScopeData::Scope.where(path: name)
else
RubyLanguageServer::ScopeData::Scope.where(name:)
end

# Filter by class_method attribute if specified
scopes = scopes.where(class_method: class_method_filter) unless class_method_filter.nil?

variables = RubyLanguageServer::ScopeData::Variable.constant_variables.where(name:)
(scopes + variables).reject { |scope| scope.code_file.nil? }.map do |scope|
Location.hash(scope.code_file.uri, scope.top_line, 1)
# class_method_filter is for new -> initialize
def project_definitions_for(name, parent_scopes = [], class_method_filter = nil)
results = []

if parent_scopes.empty?
# No parent scopes provided - search all top-level scopes
all_scopes = RubyLanguageServer::ScopeData::Scope.where(name: name)
all_scopes = all_scopes.where(class_method: class_method_filter) unless class_method_filter.nil?
results.concat(all_scopes.to_a)

# Also search for constants at root level
all_variables = RubyLanguageServer::ScopeData::Variable.where(name: name)
results.concat(all_variables.to_a)
else
# Start with the deepest (first) scope and search upward through parent chain
current_scope = parent_scopes.first
while current_scope
# Search for child scopes with matching name in current scope
child_scopes = current_scope.children.where(name: name)
child_scopes = child_scopes.where(class_method: class_method_filter) unless class_method_filter.nil?
results.concat(child_scopes.to_a)

# Search for variables with matching name in current scope
matching_variables = current_scope.variables.where(name: name)
results.concat(matching_variables.to_a)

# If we found results, stop searching (most specific scope wins)
break unless results.empty?

# Move up to parent scope
current_scope = current_scope.parent
end
end

# Return locations for all matching scopes and variables
results.reject { |item| item.code_file.nil? }.map do |item|
line = item.respond_to?(:top_line) ? item.top_line : item.line
Location.hash(item.code_file.uri, line, 1)
end
end

private

# Find a scope by its path (e.g., "Foo::Bar")
# Returns nil if path is nil or empty (for root scope searches)
def find_scope_by_path(path)
return nil if path.nil? || path.empty?

RubyLanguageServer::ScopeData::Scope.find_by(path: path)
end

# Check if a name looks like a constant (all uppercase)
def constant_name?(name)
# Must start with uppercase letter and contain no lowercase letters
return false unless /\A[A-Z]/.match?(name)

!/[a-z]/.match?(name)
end

# Check if the context represents a namespace reference (Foo::Bar) rather than a method call (Foo.bar)
# Class/module lookups always start with uppercase letters, method calls never do
def namespace_reference?(_uri, _position, context)
Expand Down
57 changes: 57 additions & 0 deletions spec/lib/ruby_language_server/project_manager_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -359,5 +359,62 @@ def some_method(meaningful) # line 17: parameter declaration
assert_equal 11, results.first[:range][:start][:line]
end
end

describe 'constant lookup with namespace' do
let(:file_with_namespaced_constant) do
<<~CODE_FILE
module Foo
BAR = "bar constant"

class Baz
QUUX = "quux constant"
end
end

# Using the constant
puts Foo::BAR
puts Foo::Baz::QUUX
CODE_FILE
end

before(:each) do
project_manager.update_document_content('const_uri', file_with_namespaced_constant)
project_manager.tags_for_uri('const_uri') # Force load of tags
end

it 'finds constant definition when clicking on constant in Foo::BAR' do
# Position on "BAR" in "Foo::BAR" (line 9, character 10)
# The line is: "puts Foo::BAR"
position = OpenStruct.new(line: 9, character: 10)
Comment on lines +386 to +388
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

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

The comment states the target line is line 9, but the code being tested shows puts Foo::BAR on line 14 of the heredoc (counting from line 0 for module Foo). The line number in the position should match the actual line in the test file content.

Copilot uses AI. Check for mistakes.
results = project_manager.possible_definitions('const_uri', position)

# Should find the BAR constant definition on line 1
assert_equal 1, results.length, "Expected to find 1 definition for Foo::BAR, but got #{results.length}"
assert_equal 'const_uri', results.first[:uri]
assert_equal 1, results.first[:range][:start][:line]
end

it 'finds constant definition when clicking on nested constant in Foo::Baz::QUUX' do
# Position on "QUUX" in "Foo::Baz::QUUX" (line 10, character 16)
position = OpenStruct.new(line: 10, character: 16)
Comment on lines +398 to +399
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

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

The comment indicates line 10, but puts Foo::Baz::QUUX appears on line 15 of the heredoc. This mismatch will cause the test to target the wrong line.

Suggested change
# Position on "QUUX" in "Foo::Baz::QUUX" (line 10, character 16)
position = OpenStruct.new(line: 10, character: 16)
# Position on "QUUX" in "Foo::Baz::QUUX" (line 15, character 16)
position = OpenStruct.new(line: 15, character: 16)

Copilot uses AI. Check for mistakes.
results = project_manager.possible_definitions('const_uri', position)

# Should find the QUUX constant definition on line 4
assert_equal 1, results.length, "Expected to find 1 definition for Foo::Baz::QUUX, but got #{results.length}"
assert_equal 'const_uri', results.first[:uri]
assert_equal 4, results.first[:range][:start][:line]
end

it 'finds module definition when clicking on Foo in Foo::BAR' do
# Position on "Foo" in "Foo::BAR" (line 9, character 5)
Copy link

Copilot AI Jan 29, 2026

Choose a reason for hiding this comment

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

Similar to the previous issues, this comment references line 9 but should reference line 14 where puts Foo::BAR is located in the test code.

Suggested change
# Position on "Foo" in "Foo::BAR" (line 9, character 5)
# Position on "Foo" in "Foo::BAR" (line 14, character 5)

Copilot uses AI. Check for mistakes.
position = OpenStruct.new(line: 9, character: 5)
results = project_manager.possible_definitions('const_uri', position)

# Should find the Foo module definition on line 0
assert_equal 1, results.length
assert_equal 'const_uri', results.first[:uri]
assert_equal 0, results.first[:range][:start][:line]
end
end
end
end