Skip to content

Commit 2140668

Browse files
committed
Merge branch 'develop' into copilot/update-to-ruby-4
2 parents 0402b2b + a33eddd commit 2140668

8 files changed

Lines changed: 174 additions & 10 deletions

File tree

CHANGELOG.txt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
# Changelog
22

3-
#### Unreleased
3+
#### 0.9.3 Mon Jan 12 18:12:05 PST 2026
44

5-
* Update to Ruby 4.0 - resolves gem build issues mentioned in 0.9.0
5+
* Fix sibling module/class nesting in scope parser
6+
7+
#### 0.9.2 Sun Jan 11 10:50:34 PST 2026
8+
9+
* Fix namespace class definition lookup (Foo::Bar)
610

711
#### 0.9.1 Tue Jan 6 19:32:44 PST 2026
812

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
ruby_language_server (0.9.1)
4+
ruby_language_server (0.9.3)
55
activerecord (~> 8.1)
66
amatch
77
bundler
@@ -278,7 +278,7 @@ CHECKSUMS
278278
rubocop-rake (0.7.1) sha256=3797f2b6810c3e9df7376c26d5f44f3475eda59eb1adc38e6f62ecf027cbae4d
279279
rubocop-rspec (3.8.0) sha256=28440dccb3f223a9938ca1f946bd3438275b8c6c156dab909e2cb8bc424cab33
280280
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
281-
ruby_language_server (0.9.1)
281+
ruby_language_server (0.9.3)
282282
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
283283
shellany (0.0.1) sha256=0e127a9132698766d7e752e82cdac8250b6adbd09e6c0a7fbbb6f61964fedee7
284284
simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5

lib/ruby_language_server/project_manager.rb

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -167,8 +167,15 @@ def possible_definitions(uri, position)
167167

168168
context = context_at_location(uri, position)
169169

170-
# If context has more than one element it's a method call on a receiver
170+
# If context has more than one element it could be a method call or namespace reference
171171
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)
177+
end
178+
172179
receiver = context.first
173180
# Determine if it's a class method call (Foo.method) or instance method call (foo.method)
174181
class_method_filter = name != 'initialize'
@@ -206,7 +213,13 @@ def scope_definitions_for(name, scope, uri)
206213
end
207214

208215
def project_definitions_for(name, class_method_filter = nil)
209-
scopes = RubyLanguageServer::ScopeData::Scope.where(name:)
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
210223

211224
# Filter by class_method attribute if specified
212225
scopes = scopes.where(class_method: class_method_filter) unless class_method_filter.nil?
@@ -219,6 +232,16 @@ def project_definitions_for(name, class_method_filter = nil)
219232

220233
private
221234

235+
# Check if the context represents a namespace reference (Foo::Bar) rather than a method call (Foo.bar)
236+
# Class/module lookups always start with uppercase letters, method calls never do
237+
def namespace_reference?(_uri, _position, context)
238+
return false if context.length < 2
239+
240+
# If all parts start with uppercase, it's a namespace reference (Foo::Bar)
241+
# If first part is lowercase, it's a method call (foo.bar)
242+
context.all? { |part| /\A[A-Z]/.match?(part) }
243+
end
244+
222245
# Guess if a receiver name is likely a class name based on idiomatic Ruby conventions.
223246
# This is a heuristic and not 100% accurate (e.g., FOO could be a constant holding an instance).
224247
# Returns true for names like "Foo" or "FooBar" (starts with uppercase, has lowercase letters).

lib/ruby_language_server/scope_data/base.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,10 @@ class Base < ActiveRecord::Base
2525
TYPE_VARIABLE => '^'
2626
}.freeze
2727

28-
attr_accessor :type # Type of this scope (module, class, block)
28+
# Return the class_type as a symbol for easier testing
29+
def type
30+
class_type&.to_sym
31+
end
2932

3033
# bar should be closer to Bar than Far. Adding the UPPER version accomplishes this.
3134
scope :with_distance_from, lambda { |word|

lib/ruby_language_server/scope_parser.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ def visit_class_node(node)
4141
end_line = node.location.end_line
4242
column = node.location.start_column
4343

44-
scope = push_scope(ScopeData::Scope::TYPE_CLASS, name, line, column, end_line)
44+
scope = push_scope(ScopeData::Scope::TYPE_CLASS, name, line, column, end_line, false)
4545

4646
# Handle superclass
4747
if node.superclass
@@ -60,7 +60,7 @@ def visit_module_node(node)
6060
end_line = node.location.end_line
6161
column = node.location.start_column
6262

63-
push_scope(ScopeData::Scope::TYPE_MODULE, name, line, column, end_line)
63+
push_scope(ScopeData::Scope::TYPE_MODULE, name, line, column, end_line, false)
6464
super
6565
pop_scope
6666
end
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
module RubyLanguageServer
4-
VERSION = '0.9.1'
4+
VERSION = '0.9.3'
55
end

spec/lib/ruby_language_server/project_manager_spec.rb

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,54 @@ def other_method
241241
assert_equal 0, other_class_result[:range][:start][:line]
242242
end
243243

244+
describe 'namespaced class lookup' do
245+
let(:file_with_namespaced_class) do
246+
<<~CODE_FILE
247+
module Foo
248+
class Bar
249+
def import
250+
puts "import method"
251+
end
252+
end
253+
end
254+
255+
# In a spec file:
256+
describe Foo::Bar, "#import" do
257+
# When clicking on Bar in Foo::Bar
258+
end
259+
CODE_FILE
260+
end
261+
262+
before(:each) do
263+
project_manager.update_document_content('namespace_uri', file_with_namespaced_class)
264+
project_manager.tags_for_uri('namespace_uri') # Force load of tags
265+
end
266+
267+
it 'finds namespaced class definition when clicking on the class name' do
268+
# Position on "Bar" in "Foo::Bar" (line 9, around character 16-18)
269+
# The line is: "describe Foo::Bar, \"#import\" do"
270+
# Character 16 is on 'B' of Bar
271+
position = OpenStruct.new(line: 9, character: 16)
272+
results = project_manager.possible_definitions('namespace_uri', position)
273+
274+
# Should find the Bar class definition on line 1 (0-indexed)
275+
assert_equal 1, results.length
276+
assert_equal 'namespace_uri', results.first[:uri]
277+
assert_equal 1, results.first[:range][:start][:line]
278+
end
279+
280+
it 'finds namespaced class definition when clicking on the module name' do
281+
# Position on "Foo" in "Foo::Bar" (line 9, around character 11)
282+
position = OpenStruct.new(line: 9, character: 11)
283+
results = project_manager.possible_definitions('namespace_uri', position)
284+
285+
# Should find the Foo module definition on line 0
286+
assert_equal 1, results.length
287+
assert_equal 'namespace_uri', results.first[:uri]
288+
assert_equal 0, results.first[:range][:start][:line]
289+
end
290+
end
291+
244292
describe 'parameter vs method name resolution' do
245293
let(:file_with_param_shadowing) do
246294
<<~CODE_FILE

spec/lib/ruby_language_server/scope_parser_spec.rb

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,5 +167,91 @@ def some_method
167167
assert_equal('item', scope_parser.root_scope.self_and_descendants.last.variables.first.name)
168168
end
169169
end
170+
171+
describe 'sibling modules' do
172+
before do
173+
@parser = RubyLanguageServer::ScopeParser.new(<<-RUBY)
174+
module Foo
175+
module Bar
176+
end
177+
module Baz
178+
end
179+
end
180+
RUBY
181+
end
182+
183+
it 'places sibling modules at the same level' do
184+
foo = @parser.root_scope.children.first
185+
assert_equal('Foo', foo.name)
186+
187+
children = foo.children
188+
assert_equal(2, children.size, "Foo should have 2 children, but has #{children.size}")
189+
190+
bar = children.detect { |c| c.name == 'Bar' }
191+
baz = children.detect { |c| c.name == 'Baz' }
192+
193+
refute_nil(bar, "Bar should be a child of Foo")
194+
refute_nil(baz, "Baz should be a child of Foo")
195+
assert_equal(0, bar.children.size, "Bar should have no children")
196+
end
197+
end
198+
199+
describe 'sibling classes' do
200+
before do
201+
@parser = RubyLanguageServer::ScopeParser.new(<<-RUBY)
202+
module Foo
203+
class Bar
204+
end
205+
class Baz
206+
end
207+
end
208+
RUBY
209+
end
210+
211+
it 'places sibling classes at the same level' do
212+
foo = @parser.root_scope.children.first
213+
assert_equal('Foo', foo.name)
214+
215+
children = foo.children
216+
assert_equal(2, children.size, "Foo should have 2 children, but has #{children.size}")
217+
218+
bar = children.detect { |c| c.name == 'Bar' }
219+
baz = children.detect { |c| c.name == 'Baz' }
220+
221+
refute_nil(bar, "Bar should be a child of Foo")
222+
refute_nil(baz, "Baz should be a child of Foo")
223+
assert_equal(0, bar.children.size, "Bar should have no children")
224+
end
225+
end
226+
227+
describe 'mixed siblings' do
228+
before do
229+
@parser = RubyLanguageServer::ScopeParser.new(<<-RUBY)
230+
module Foo
231+
class Bar
232+
end
233+
module Baz
234+
end
235+
end
236+
RUBY
237+
end
238+
239+
it 'places sibling class and module at the same level' do
240+
foo = @parser.root_scope.children.first
241+
assert_equal('Foo', foo.name)
242+
243+
children = foo.children
244+
assert_equal(2, children.size, "Foo should have 2 children, but has #{children.size}")
245+
246+
bar = children.detect { |c| c.name == 'Bar' }
247+
baz = children.detect { |c| c.name == 'Baz' }
248+
249+
refute_nil(bar, "Bar should be a child of Foo")
250+
refute_nil(baz, "Baz should be a child of Foo")
251+
assert_equal(:class, bar.type, "Bar should be a class")
252+
assert_equal(:module, baz.type, "Baz should be a module")
253+
assert_equal(0, bar.children.size, "Bar should have no children")
254+
end
255+
end
170256
end
171257
end

0 commit comments

Comments
 (0)