diff --git a/lib/db/schema.rb b/lib/db/schema.rb index 30117de..2627e18 100644 --- a/lib/db/schema.rb +++ b/lib/db/schema.rb @@ -20,6 +20,7 @@ def write(*args) t.string :class_type, null: false t.text :parameters # JSON string of method parameters t.boolean :class_method, default: false # true for class methods (def self.method) + t.text :included_modules # JSON array of included module names end add_index :scopes, :name diff --git a/lib/ruby_language_server/completion.rb b/lib/ruby_language_server/completion.rb index 972b137..7dd5564 100644 --- a/lib/ruby_language_server/completion.rb +++ b/lib/ruby_language_server/completion.rb @@ -153,6 +153,13 @@ def scope_completions(word, scopes) scope_ids = scopes.map(&:id) word_scopes = scopes.to_a + RubyLanguageServer::ScopeData::Scope.where(parent_id: scope_ids).closest_to(word).limit(5) + + # Add methods from included modules + scopes.each do |scope| + included_module_scopes = get_included_module_scopes(scope) + word_scopes += included_module_scopes + end + scope_words = word_scopes.select(&:named_scope?).sort_by(&:depth).map { |scope| [scope.name, scope] } variable_words = RubyLanguageServer::ScopeData::Variable.where(scope_id: scope_ids).closest_to(word).limit(5).map { |variable| [variable.name, variable.scope] } words = (scope_words + variable_words).to_h @@ -164,6 +171,41 @@ def scope_completions(word, scopes) hash[w][:parameters] = scope.parsed_parameters if scope.method? && scope.parameters.present? end end + + # Get all scopes from included modules + def get_included_module_scopes(scope) + included_scopes = [] + module_names = scope.parsed_included_modules + + module_names.each do |module_name| + # Try to find the module by full path first + full_module_path = resolve_module_path(module_name, scope) + module_scope = RubyLanguageServer::ScopeData::Scope.find_by(path: full_module_path, class_type: RubyLanguageServer::ScopeData::Base::TYPE_MODULE) + + # If not found by full path, try searching by name + module_scope ||= RubyLanguageServer::ScopeData::Scope.where(name: module_name, class_type: RubyLanguageServer::ScopeData::Base::TYPE_MODULE).first + + if module_scope + # Get all methods defined in the module + included_scopes += module_scope.children.where(class_type: RubyLanguageServer::ScopeData::Base::TYPE_METHOD).to_a + end + end + + included_scopes + end + + # Resolve the full module path considering the current scope + def resolve_module_path(module_name, scope) + # If it starts with ::, it's an absolute path + return module_name.sub(/^::/, '') if module_name.start_with?('::') + + # Try to resolve relative to current scope + if scope.parent && scope.parent.path.present? + "#{scope.parent.path}::#{module_name}" + else + module_name + end + end end end end diff --git a/lib/ruby_language_server/scope_data/scope.rb b/lib/ruby_language_server/scope_data/scope.rb index 09a7b33..18d09c0 100644 --- a/lib/ruby_language_server/scope_data/scope.rb +++ b/lib/ruby_language_server/scope_data/scope.rb @@ -94,6 +94,22 @@ def set_parameters(params_array) self.parameters = params_array.to_json if params_array.present? end + # Get included modules as an array of strings + def parsed_included_modules + return [] unless included_modules.present? + + JSON.parse(included_modules) + rescue JSON::ParserError + [] + end + + # Add an included module name + def add_included_module(module_name) + current = parsed_included_modules + current << module_name unless current.include?(module_name) + self.included_modules = current.to_json + end + # Called from ScopeParser to cleanup empty blocks. def close destroy! if block_scope? && variables.none? diff --git a/lib/ruby_language_server/scope_parser_commands/ruby_commands.rb b/lib/ruby_language_server/scope_parser_commands/ruby_commands.rb index 70b7944..3a20e48 100644 --- a/lib/ruby_language_server/scope_parser_commands/ruby_commands.rb +++ b/lib/ruby_language_server/scope_parser_commands/ruby_commands.rb @@ -41,6 +41,15 @@ def on_attr_writer_command(line, args, rest) ruby_command_add_attr(line, column, names, false, true) end + def on_include_command(_line, _args, rest) + # Extract the module name(s) from the include statement + module_names = rest.flatten.select { |o| o.instance_of? String } + module_names.each do |module_name| + @current_scope.add_included_module(module_name) + @current_scope.save! + end + end + private def ruby_command_names(rest) diff --git a/spec/lib/ruby_language_server/completion_spec.rb b/spec/lib/ruby_language_server/completion_spec.rb index 3c99201..0960758 100644 --- a/spec/lib/ruby_language_server/completion_spec.rb +++ b/spec/lib/ruby_language_server/completion_spec.rb @@ -194,4 +194,69 @@ def method_mixed(required, optional = nil, *rest, keyword:, **kwargs, &block) assert_equal('arg1 (required), arg2 (required)', simple_method_item[:detail], 'should include parameter details') end end + + describe 'module inclusion' do + before do + @code_with_include = <<-SOURCE + module FooModule + def foo_method + end + + def another_foo_method(param1) + end + end + + class BarClass + include FooModule + + def bar_method + foo # completion should find foo_method from included FooModule module + end + end + SOURCE + @include_parser = RubyLanguageServer::ScopeParser.new(@code_with_include) + end + + it 'should track included modules in scope' do + all_scopes = @include_parser.root_scope.self_and_descendants + bar_class = all_scopes.find_by_path('BarClass') + + assert bar_class, 'BarClass should exist' + included_modules = bar_class.parsed_included_modules + assert_equal(['FooModule'], included_modules, 'BarClass should have FooModule in its included modules') + end + + it 'should include methods from included modules in completions' do + all_scopes = @include_parser.root_scope.self_and_descendants + bar_method_scope = all_scopes.find_by_path('BarClass#bar_method') + + assert bar_method_scope, 'bar_method scope should exist' + + # Get completions for 'foo' prefix within BarClass#bar_method + context = ['foo'] + position_scopes = @include_parser.root_scope.self_and_descendants.for_line(bar_method_scope.top_line + 1) + completions = scope_completions(context.last, position_scopes) + + # Should include foo_method from the included FooModule module + completion_names = completions.map(&:first) + assert_includes(completion_names, 'foo_method', 'Should find foo_method from included module FooModule') + assert_includes(completion_names, 'another_foo_method', 'Should find another_foo_method from included module FooModule') + end + + it 'should include method parameters from included modules' do + all_scopes = @include_parser.root_scope.self_and_descendants + bar_method_scope = all_scopes.find_by_path('BarClass#bar_method') + + # Get completions for 'another_foo' prefix + context = ['another_foo'] + completions = RubyLanguageServer::Completion.completion(context, bar_method_scope, all_scopes) + + # Find the another_foo_method completion + another_foo_item = completions[:items].find { |item| item[:label].start_with?('another_foo_method') } + + assert another_foo_item, 'another_foo_method should be in completions' + assert_equal('another_foo_method(param1)', another_foo_item[:label], 'should include parameters in label') + assert_equal('another_foo_method(${1:param1})', another_foo_item[:insertText], 'should include parameter snippet') + end + end end