diff --git a/lib/solargraph/rspec/convention.rb b/lib/solargraph/rspec/convention.rb index 34eba27..f3b4133 100644 --- a/lib/solargraph/rspec/convention.rb +++ b/lib/solargraph/rspec/convention.rb @@ -9,6 +9,7 @@ require_relative 'correctors/let_methods_corrector' require_relative 'correctors/subject_method_corrector' require_relative 'correctors/dsl_methods_corrector' +require_relative 'correctors/shared_examples_corrector' require_relative 'test_helpers' require_relative 'pin_factory' @@ -33,6 +34,15 @@ module Rspec pending ].freeze + SHARED_EXAMPLE_INCLUSION_METHODS = %w[ + include_examples + it_behaves_like + it_should_behave_like + include_context + ].freeze + + SHARED_EXAMPLE_DEFINITION_METHODS = %w[shared_examples shared_examples_for shared_context].freeze + CONTEXT_METHODS = %w[ example_group describe @@ -42,12 +52,8 @@ module Rspec fdescribe fcontext shared_examples - include_examples - it_behaves_like - it_should_behave_like shared_context - include_context - ].freeze + ].freeze + SHARED_EXAMPLE_INCLUSION_METHODS # @type [Array>] CORRECTOR_CLASSES = [ @@ -56,7 +62,8 @@ module Rspec Correctors::DslMethodsCorrector, Correctors::ExampleAndHookBlocksBindingCorrector, Correctors::LetMethodsCorrector, - Correctors::SubjectMethodCorrector + Correctors::SubjectMethodCorrector, + Correctors::SharedExamplesCorrector ].freeze # Provides completion for RSpec DSL and helper methods. diff --git a/lib/solargraph/rspec/correctors/shared_examples_corrector.rb b/lib/solargraph/rspec/correctors/shared_examples_corrector.rb new file mode 100644 index 0000000..9c3e3c9 --- /dev/null +++ b/lib/solargraph/rspec/correctors/shared_examples_corrector.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require_relative 'base' + +module Solargraph + module Rspec + module Correctors + class SharedExamplesCorrector < Base + # @param source_map [Solargraph::SourceMap] + def correct(source_map) + @shared_examples = {} + + rspec_walker.on_shared_example_definition do |shared_examples_name, location_range| + SHARED_EXAMPLE_INCLUSION_METHODS.each do |method_name| + pin = Solargraph::Pin::FactoryParameter.new( + method_name: method_name, + method_namespace: 'RSpec::Core::ExampleGroup', + method_scope: :class, + param_name: 'name', + value: shared_examples_name, + return_type: nil, + decl: :arg, + location: PinFactory.build_location(location_range, source_map.source.filename) + ) + add_pin(pin) + end + end + end + end + end + end +end diff --git a/lib/solargraph/rspec/spec_walker.rb b/lib/solargraph/rspec/spec_walker.rb index 09ae894..c93a885 100644 --- a/lib/solargraph/rspec/spec_walker.rb +++ b/lib/solargraph/rspec/spec_walker.rb @@ -23,6 +23,7 @@ def initialize(source_map:, config:) on_example_block: [], on_hook_block: [], on_blocks_in_examples: [], + on_shared_example_definition: [], after_walk: [] } end @@ -89,6 +90,14 @@ def on_blocks_in_examples(&block) @handlers[:on_blocks_in_examples] << block end + # @param block [Proc] + # @yieldparam name [String] + # @yieldparam location_range [Solargraph::Range] + # @return [void] + def on_shared_example_definition(&block) + @handlers[:on_shared_example_definition] << block + end + # @param block [Proc] # @return [void] def after_walk(&block) @@ -167,6 +176,17 @@ def walk! end end + walker.on :block do |block_ast| + next unless NodeTypes.a_shared_example_definition?(block_ast) + + name = NodeTypes.shared_example_name(block_ast) + next unless name + + @handlers[:on_shared_example_definition].each do |handler| + handler.call(name, PinFactory.build_location_range(block_ast)) + end + end + walker.walk @handlers[:after_walk].each(&:call) diff --git a/lib/solargraph/rspec/spec_walker/node_types.rb b/lib/solargraph/rspec/spec_walker/node_types.rb index 2655304..339b7e2 100644 --- a/lib/solargraph/rspec/spec_walker/node_types.rb +++ b/lib/solargraph/rspec/spec_walker/node_types.rb @@ -70,6 +70,28 @@ def self.let_method_name(block_ast) block_ast.children[0].children[2]&.children&.[](0)&.to_s # rubocop:disable Style/SafeNavigationChainLength end + + # @param block_ast [::Parser::AST::Node] + # @return [Boolean] + def self.a_shared_example_definition?(block_ast) + SHARED_EXAMPLE_DEFINITION_METHODS.include?(method_with_block_name(block_ast)) + end + + # @param block_ast [::Parser::AST::Node] + # @return [String, Symbol, nil] The name of the shared example being defined or included + def self.shared_example_name(block_ast) + return nil unless a_shared_example_definition?(block_ast) + + name_node = block_ast.children[0].children[2] + return nil unless name_node + + case name_node.type + when :str, :dstr + name_node.children[0]&.to_s + when :sym + name_node.children[0] + end + end end end end diff --git a/spec/solargraph/rspec/convention_spec.rb b/spec/solargraph/rspec/convention_spec.rb index 15ebcc6..f00fcd5 100644 --- a/spec/solargraph/rspec/convention_spec.rb +++ b/spec/solargraph/rspec/convention_spec.rb @@ -201,8 +201,7 @@ end RUBY - expect(completion_at(filename, [2, 6])).not_to include('subject') - expect(api_map.pins.any? { |pin| pin.name == 'subject' }).to be false + expect(api_map.pins.any? { |pin| pin.path == 'RSpec::ExampleGroups::TestSomeTextDescription#subject' }).to be false end # Regression test: prevents errors when described_class is manually overridden with a let @@ -217,8 +216,7 @@ end RUBY - expect(completion_at(filename, [2, 6])).not_to include('subject') - expect(api_map.pins.any? { |pin| pin.name == 'subject' }).to be false + expect(api_map.pins.any? { |pin| pin.path == 'RSpec::ExampleGroups::TestSomeTextDescription#subject' }).to be false end it 'generates modules for describe/context blocks' do @@ -352,6 +350,65 @@ def self.my_class_method expect(completion_at(filename, [7, 11])).to include('my_class_method') # inherit from parent context end + it 'resolves globally and locally defined shared examples' do + load_string filename, <<~RUBY + RSpec.shared_examples 'a global shared example' do + it 'does something globally' do + expect(true).to eq(true) + end + end + + RSpec.shared_examples :another_global_shared_example do + it 'does something globally with symbols' do + expect(true).to eq(true) + end + end + + RSpec.describe SomeNamespace::Transaction do + shared_examples 'local shared example' do + it 'does something locally' do + expect(false).to eq(false) + end + end + + shared_examples :local_symbol_example do + it 'does something locally with symbols' do + expect(42).to eq(42) + end + end + + context 'when using shared examples' do + include_examples 'a global shared example' + include_examples :another_global_shared_example + include_examples 'local shared example' + it_behaves_like :local_symbol_example + end + end + RUBY + + # Check global shared examples + pins = defintion_pins_at(filename, [26, 25]) + factory_param = pins.first + expect(factory_param).not_to be_nil + expect(factory_param.value).to eq('a global shared example') + + pins = defintion_pins_at(filename, [27, 25]) + factory_param = pins.first + expect(factory_param).not_to be_nil + expect(factory_param.value).to eq(:another_global_shared_example) + + # Check local shared examples + pins = defintion_pins_at(filename, [28, 25]) + factory_param = pins.first + expect(factory_param).not_to be_nil + expect(factory_param.value).to eq('local shared example') + + pins = defintion_pins_at(filename, [29, 25]) + factory_param = pins.first + expect(factory_param).not_to be_nil + expect(factory_param.value).to eq(:local_symbol_example) + end + it 'completes RSpec DSL methods' do pending('https://github.com/castwide/solargraph/pull/877') load_string filename, <<~RUBY diff --git a/spec/solargraph/rspec/shared_example_definition_spec.rb b/spec/solargraph/rspec/shared_example_definition_spec.rb new file mode 100644 index 0000000..7728124 --- /dev/null +++ b/spec/solargraph/rspec/shared_example_definition_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +RSpec.describe Solargraph::Rspec::Convention do + let(:api_map) { Solargraph::ApiMap.new } + let(:library) { Solargraph::Library.new } + let(:filename) { File.expand_path('spec/models/shared_examples_spec.rb') } + + describe 'shared examples support' do + it 'provides completion for shared example names in include_examples' do + load_string filename, <<~RUBY + RSpec.shared_examples 'a shared example' do + it 'does something' do + expect(true).to eq(true) + end + end + + RSpec.shared_examples 'another shared example' do + it 'does something else' do + expect(false).to eq(false) + end + end + + RSpec.describe SomeClass do + include_examples 'a shared example' + end + RUBY + + # Check that factory parameters are created for shared examples + factory_params = api_map.pins.select { |pin| pin.is_a?(Solargraph::Pin::FactoryParameter) } + + shared_example_params = factory_params.select do |pin| + pin.method_name == 'include_examples' && + pin.method_namespace == 'RSpec::Core::ExampleGroup' && + pin.method_scope == :class + end + + expect(shared_example_params.map(&:value)).to include('a shared example', 'another shared example') + end + + it 'provides completion for shared example names in it_behaves_like' do + load_string filename, <<~RUBY + RSpec.shared_examples 'behaves like something' do + it 'has behavior' do + expect(subject).to be_truthy + end + end + + RSpec.describe SomeClass do + it_behaves_like 'behaves like something' + end + RUBY + + factory_params = api_map.pins.select { |pin| pin.is_a?(Solargraph::Pin::FactoryParameter) } + + behaves_like_params = factory_params.select do |pin| + pin.method_name == 'it_behaves_like' && + pin.method_namespace == 'RSpec::Core::ExampleGroup' && + pin.method_scope == :class + end + + expect(behaves_like_params.map(&:value)).to include('behaves like something') + end + + it 'provides completion for shared example names in it_should_behave_like' do + load_string filename, <<~RUBY + RSpec.shared_examples 'legacy behavior' do + it 'works with old syntax' do + expect(1).to eq(1) + end + end + + RSpec.describe SomeClass do + it_should_behave_like 'legacy behavior' + end + RUBY + + factory_params = api_map.pins.select { |pin| pin.is_a?(Solargraph::Pin::FactoryParameter) } + + should_behave_params = factory_params.select do |pin| + pin.method_name == 'it_should_behave_like' && + pin.method_namespace == 'RSpec::Core::ExampleGroup' && + pin.method_scope == :class + end + + expect(should_behave_params.map(&:value)).to include('legacy behavior') + end + + it 'supports shared_context and include_context' do + load_string filename, <<~RUBY + RSpec.shared_context 'with setup' do + let(:value) { 42 } + end + + RSpec.describe SomeClass do + include_context 'with setup' + end + RUBY + + factory_params = api_map.pins.select { |pin| pin.is_a?(Solargraph::Pin::FactoryParameter) } + + context_params = factory_params.select do |pin| + pin.method_name == 'include_context' && + pin.method_namespace == 'RSpec::Core::ExampleGroup' && + pin.method_scope == :class + end + + expect(context_params.map(&:value)).to include('with setup') + end + + it 'handles shared examples with symbols' do + load_string filename, <<~RUBY + RSpec.shared_examples :symbol_example do + it 'works with symbols' do + expect(true).to be true + end + end + + RSpec.describe SomeClass do + include_examples :symbol_example + end + RUBY + + factory_params = api_map.pins.select { |pin| pin.is_a?(Solargraph::Pin::FactoryParameter) } + + symbol_params = factory_params.select do |pin| + pin.method_name == 'include_examples' && + pin.method_namespace == 'RSpec::Core::ExampleGroup' && + pin.method_scope == :class + end + + expect(symbol_params.map(&:value)).to include(:symbol_example) + end + + it 'works with shared_examples_for alias' do + load_string filename, <<~RUBY + RSpec.shared_examples_for 'aliased example' do + it 'uses the alias' do + expect(42).to eq(42) + end + end + + RSpec.describe SomeClass do + include_examples 'aliased example' + end + RUBY + + factory_params = api_map.pins.select { |pin| pin.is_a?(Solargraph::Pin::FactoryParameter) } + + alias_params = factory_params.select do |pin| + pin.method_name == 'include_examples' && + pin.method_namespace == 'RSpec::Core::ExampleGroup' && + pin.method_scope == :class + end + + expect(alias_params.map(&:value)).to include('aliased example') + end + end +end diff --git a/spec/support/solargraph_helpers.rb b/spec/support/solargraph_helpers.rb index c167762..95c7a84 100644 --- a/spec/support/solargraph_helpers.rb +++ b/spec/support/solargraph_helpers.rb @@ -66,6 +66,7 @@ def completion_pins_at(filename, position, map = api_map) cursor = clip.send(:cursor) word = cursor.chain.links.first.word + # puts "Completion: word=#{word}, links=#{cursor.chain.links}" Solargraph.logger.debug( "Complete: word=#{word}, links=#{cursor.chain.links}" ) @@ -77,6 +78,20 @@ def completion_at(filename, position, map = api_map) completion_pins_at(filename, position, map).map(&:name) end + def defintion_pins_at(filename, position, map = api_map) + # @type [Solargraph::SourceMap::Clip] + clip = map.clip_at(filename, position) + cursor = clip.send(:cursor) + word = cursor.chain.links.first.word + + # puts "Definition: word=#{word}, links=#{cursor.chain.links}" + Solargraph.logger.debug( + "Definition: word=#{word}, links=#{cursor.chain.links}" + ) + + clip.define + end + # Expect that a local can be inferred with +expected_type+ # # @param var_name [String]