From 791d94a56ac64b2db1b9ed5f55473204e8915247 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lek=C3=AB=20Mula?= Date: Wed, 25 Feb 2026 23:41:31 +0100 Subject: [PATCH 1/3] Add Rspec included modules support Credits to ShadiestGoat for https://github.com/lekemula/solargraph-rspec/pull/13 I mostly cherry-picked the changes from there and addressed my own feedback with the help of Claude Code. Co-Authored-By: Claude Sonnet 4.5 Co-authored-by: Lucy Dryaeva <48590492+ShadiestGoat@users.noreply.github.com> --- lib/solargraph/rspec/config.rb | 20 +++ lib/solargraph/rspec/convention.rb | 11 +- lib/solargraph/rspec/rspec_configure.rb | 67 ++++++++ .../rspec/spec_configuration_walker.rb | 74 ++++++++ spec/solargraph/rspec/rspec_configure_spec.rb | 162 ++++++++++++++++++ .../rspec/spec_configuration_walker_spec.rb | 66 +++++++ spec/support/solargraph_helpers.rb | 13 +- 7 files changed, 411 insertions(+), 2 deletions(-) create mode 100644 lib/solargraph/rspec/rspec_configure.rb create mode 100644 lib/solargraph/rspec/spec_configuration_walker.rb create mode 100644 spec/solargraph/rspec/rspec_configure_spec.rb create mode 100644 spec/solargraph/rspec/spec_configuration_walker_spec.rb diff --git a/lib/solargraph/rspec/config.rb b/lib/solargraph/rspec/config.rb index 8edced0..927eda3 100644 --- a/lib/solargraph/rspec/config.rb +++ b/lib/solargraph/rspec/config.rb @@ -6,6 +6,9 @@ module Rspec # rspec: # let_methods: # - let_it_be + # config_helper_files: + # - spec/spec_helper.rb + # - spec/rails_helper.rb class Config def initialize(solargraph_config = Solargraph::Workspace::Config.new('./')) @solargraph_config = solargraph_config @@ -25,8 +28,18 @@ def example_methods (Rspec::EXAMPLE_METHODS + additional_example_methods).map(&:to_sym) end + # @return [Array] + def config_helper_files + DEFAULT_CONFIG_HELPER_FILES + additional_config_helper_files + end + private + DEFAULT_CONFIG_HELPER_FILES = [ + 'spec/spec_helper.rb', + 'spec/rails_helper.rb' + ].freeze + # @return [Hash] def rspec_raw_data @rspec_raw_data ||= raw_data['rspec'] || {} @@ -46,6 +59,13 @@ def additional_example_methods (example_methods || []).map(&:to_sym) end + # @return [Array] + def additional_config_helper_files + # @type [Array, nil] + config_files = rspec_raw_data['config_helper_files'] + config_files || [] + end + # @return [Hash] def raw_data @solargraph_config.raw_data diff --git a/lib/solargraph/rspec/convention.rb b/lib/solargraph/rspec/convention.rb index 9d60106..ddeb0a0 100644 --- a/lib/solargraph/rspec/convention.rb +++ b/lib/solargraph/rspec/convention.rb @@ -11,6 +11,7 @@ require_relative 'correctors/dsl_methods_corrector' require_relative 'gems' require_relative 'pin_factory' +require_relative 'rspec_configure' module Solargraph module Rspec @@ -86,8 +87,16 @@ def global(_yard_map) root_example_group_namespace_pin: root_example_group_namespace_pin ) pins += annotation_pins + + # Add pins from RSpec.configure blocks + rspec_configure = RSpecConfigure.new( + config: config, + root_namespace_pin: root_example_group_namespace_pin + ) + pins += rspec_configure.pins + # TODO: Include gem requires conditionally based on Gemfile definition - requires = Solargraph::Rspec::Gems.gem_names + requires = Solargraph::Rspec::Gems.gem_names + rspec_configure.extra_requires if pins.any? Solargraph.logger.debug( diff --git a/lib/solargraph/rspec/rspec_configure.rb b/lib/solargraph/rspec/rspec_configure.rb new file mode 100644 index 0000000..da00a34 --- /dev/null +++ b/lib/solargraph/rspec/rspec_configure.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require_relative 'spec_configuration_walker' +require_relative 'pin_factory' + +module Solargraph + module Rspec + # Handles RSpec.configure ... config.include parsing + # Provides pins for modules included globally via RSpec.configure + class RSpecConfigure + # @param config [Config] + # @param root_namespace_pin [Solargraph::Pin::Namespace] + def initialize(config:, root_namespace_pin:) + @config = config + @root_namespace_pin = root_namespace_pin + @included_modules = nil + end + + # @return [Array] + def pins + included_modules.map do |mod| + Solargraph::Pin::Reference::Include.new( + closure: @root_namespace_pin, + name: mod.module_name, + location: Solargraph::Location.new(mod.file, Solargraph::Parser.node_range(mod.node)) + ) + end + end + + # @return [Array] + def extra_requires + included_modules.map(&:file).uniq + support_files + end + + # @return [Array] + def included_modules + @included_modules ||= parse_included_modules + end + + private + + # @return [Array] + def support_files + Dir['spec/support/**/*.rb'] + end + + # @return [Array] + def parse_included_modules + modules = [] + + @config.config_helper_files.each do |file_path| + ast = Solargraph::Parser.parse(File.read(file_path), file_path) + walker = SpecConfigurationWalker.new(ast, file_path) + modules += walker.extract_included_modules + rescue Errno::ENOENT + # Ignore missing files - they may not exist in all projects + rescue StandardError => e + Solargraph.logger.error( + "[RSpec] [RSpecConfigure] Error reading helper file '#{file_path}': #{e.message}" + ) + end + + modules + end + end + end +end diff --git a/lib/solargraph/rspec/spec_configuration_walker.rb b/lib/solargraph/rspec/spec_configuration_walker.rb new file mode 100644 index 0000000..c5c2d80 --- /dev/null +++ b/lib/solargraph/rspec/spec_configuration_walker.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require_relative 'walker' +require_relative 'spec_walker/full_constant_name' + +module Solargraph + module Rspec + # Walks AST to find RSpec.configure blocks and extract included modules + class SpecConfigurationWalker + # @param ast [::Parser::AST::Node] + # @param filename [String] The file being parsed + def initialize(ast, filename) + @ast = ast + @filename = filename + @walker = Walker.new(ast) + @included_modules = [] + end + + # Represents a module included via config.include in RSpec.configure + # @param node [::Parser::AST::Node] The AST node for the include call + # @param file [String] The file where this module is included + # @param module_name [String] The fully qualified name of the included module + class IncludedModule < Struct.new(:node, :file, :module_name) + end + + # Walk the AST and extract included modules + # @return [Array] + def extract_included_modules + @walker.on :block, [:send] do |node| + send_node = node.children[0] + send_receiver = send_node.children[0] + + # Check if this is RSpec.configure + next unless send_receiver&.type == :const + next unless send_receiver.children[1] == :RSpec + next unless send_node.children[1] == :configure + + # Ensure block has arguments (the config parameter) + next if node.children[1].children.empty? + + process_configure_block(node) + end + + @walker.walk + @included_modules + end + + private + + # Process the RSpec.configure block to find config.include calls + # @param configure_block [::Parser::AST::Node] + # @return [void] + def process_configure_block(configure_block) + # Get the config variable name (e.g., |config| or |c|) + config_name = configure_block.children[1].children[0].children[0] + + config_walker = Walker.new(configure_block) + config_walker.on :send, [:lvar, config_name] do |include_node| + # Look for config.include calls + next unless include_node.children[1] == :include + + mod_node = include_node.children[2] + next unless mod_node.is_a?(::Parser::AST::Node) + next unless mod_node.type == :const + + module_name = SpecWalker::FullConstantName.from_ast(mod_node) + @included_modules << IncludedModule.new(include_node, @filename, module_name) + end + + config_walker.walk + end + end + end +end diff --git a/spec/solargraph/rspec/rspec_configure_spec.rb b/spec/solargraph/rspec/rspec_configure_spec.rb new file mode 100644 index 0000000..dba4a48 --- /dev/null +++ b/spec/solargraph/rspec/rspec_configure_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +RSpec.describe Solargraph::Rspec::RSpecConfigure do + let(:api_map) { Solargraph::ApiMap.new } + let(:helper_file_path) { 'spec/spec_helper.rb' } + let(:spec_file_path) { File.expand_path('spec/models/transaction_spec.rb') } + + before do + # For performance reasons, avoid solargraph loading all installed gems' YARDoc and RBS gem pins. + allow(Solargraph::Rspec::Gems).to receive(:gem_names).and_return(%w[rspec]) + + # Mock Dir.glob to return no support files for simplicity + allow(Dir).to receive(:[]).and_call_original + allow(Dir).to receive(:[]).with('spec/support/**/*.rb').and_return([]) + end + + describe 'included modules from RSpec.configure' do + let(:helper_content) do + <<~RUBY + module CustomHelpers + def custom_helper_method + "helper" + end + end + + RSpec.configure do |config| + config.include CustomHelpers + end + RUBY + end + + before do + # Mock File.read for helper file + allow(File).to receive(:read).and_call_original + allow(File).to receive(:read).with(helper_file_path).and_return(helper_content) + end + + it 'includes methods from modules in RSpec.configure in example blocks' do + # Create a source for the helper file with the module definition + helper_source = parse_string helper_file_path, helper_content + + spec_source = parse_string spec_file_path, <<~RUBY + RSpec.describe Transaction do + it 'should have access to custom helpers' do + custom_helper_ + end + end + RUBY + + load_sources(helper_source, spec_source) + + expect(completion_at(spec_file_path, [2, 19])).to include('custom_helper_method') + end + + it 'includes methods from modules in RSpec.configure in let blocks' do + helper_source = parse_string helper_file_path, helper_content + + spec_source = parse_string spec_file_path, <<~RUBY + RSpec.describe Transaction do + let(:value) { custom_helper_ } + end + RUBY + + load_sources(helper_source, spec_source) + + expect(completion_at(spec_file_path, [1, 28])).to include('custom_helper_method') + end + + it 'includes methods from modules in RSpec.configure in hook blocks' do + helper_source = parse_string helper_file_path, helper_content + + spec_source = parse_string spec_file_path, <<~RUBY + RSpec.describe Transaction do + before do + custom_helper_ + end + end + RUBY + + load_sources(helper_source, spec_source) + + expect(completion_at(spec_file_path, [2, 19])).to include('custom_helper_method') + end + + it 'includes methods from modules in RSpec.configure in nested context blocks' do + helper_source = parse_string helper_file_path, helper_content + + spec_source = parse_string spec_file_path, <<~RUBY + RSpec.describe Transaction do + context 'nested context' do + it 'should have access' do + custom_helper_ + end + end + end + RUBY + + load_sources(helper_source, spec_source) + + expect(completion_at(spec_file_path, [3, 21])).to include('custom_helper_method') + end + + context 'when helper file does not exist' do + it 'does not raise an error' do + # Don't create a helper source - RSpecConfigure should handle missing files gracefully + spec_source = parse_string spec_file_path, <<~RUBY + RSpec.describe Transaction do + it 'test' do + end + end + RUBY + + expect do + load_sources(spec_source) + end.not_to raise_error + end + end + + context 'with multiple included modules' do + let(:multi_helper_content) do + <<~RUBY + module FirstModule + def first_method + end + end + + module SecondModule + def second_method + end + end + + RSpec.configure do |config| + config.include FirstModule + config.include SecondModule + end + RUBY + end + + before do + allow(File).to receive(:read).with(helper_file_path).and_return(multi_helper_content) + end + + it 'includes methods from all configured modules' do + helper_source = parse_string helper_file_path, multi_helper_content + + spec_source = parse_string spec_file_path, <<~RUBY + RSpec.describe Transaction do + it 'has all methods' do + first_ + second_ + end + end + RUBY + + load_sources(helper_source, spec_source) + + expect(completion_at(spec_file_path, [2, 11])).to include('first_method') + expect(completion_at(spec_file_path, [3, 12])).to include('second_method') + end + end + end +end diff --git a/spec/solargraph/rspec/spec_configuration_walker_spec.rb b/spec/solargraph/rspec/spec_configuration_walker_spec.rb new file mode 100644 index 0000000..4b4a225 --- /dev/null +++ b/spec/solargraph/rspec/spec_configuration_walker_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +RSpec.describe Solargraph::Rspec::SpecConfigurationWalker do + describe '#extract_included_modules' do + it 'extracts modules from RSpec.configure blocks' do + ast = Solargraph::Parser.parse(%( + RSpec.configure do |config| + config.include ModuleName + config.example OtherVal + config.include(SubMod::Module) + end + ), 'spec_helper.rb') + + walker = described_class.new(ast, 'spec_helper.rb') + modules = walker.extract_included_modules + + expect(modules.map(&:module_name)).to eq(%w[ModuleName SubMod::Module]) + expect(modules.map(&:file)).to all(eq('spec_helper.rb')) + end + + it 'handles multiple RSpec.configure blocks' do + ast = Solargraph::Parser.parse(%( + RSpec.configure do |config| + config.include FirstModule + end + + RSpec.configure do |c| + c.include SecondModule + end + ), 'spec_helper.rb') + + walker = described_class.new(ast, 'spec_helper.rb') + modules = walker.extract_included_modules + + expect(modules.map(&:module_name)).to eq(%w[FirstModule SecondModule]) + end + + it 'ignores non-include calls' do + ast = Solargraph::Parser.parse(%( + RSpec.configure do |config| + config.expect_with :rspec + config.mock_with :rspec + config.include OnlyThisOne + end + ), 'spec_helper.rb') + + walker = described_class.new(ast, 'spec_helper.rb') + modules = walker.extract_included_modules + + expect(modules.map(&:module_name)).to eq(%w[OnlyThisOne]) + end + + it 'returns empty array when no includes found' do + ast = Solargraph::Parser.parse(%( + RSpec.configure do |config| + config.expect_with :rspec + end + ), 'spec_helper.rb') + + walker = described_class.new(ast, 'spec_helper.rb') + modules = walker.extract_included_modules + + expect(modules).to be_empty + end + end +end diff --git a/spec/support/solargraph_helpers.rb b/spec/support/solargraph_helpers.rb index 063b924..3b7269e 100644 --- a/spec/support/solargraph_helpers.rb +++ b/spec/support/solargraph_helpers.rb @@ -2,11 +2,22 @@ module SolargraphHelpers def load_string(filename, str) - source = Solargraph::Source.load_string(str, filename) + source = parse_string(filename, str) api_map.map(source) # api_map should be defined in the spec source end + # Util method to parse (but NOT load) a string + # This method is mostly here for heredocs + # + # @param filename [String] + # @param str [String] The source code + # + # @return [Solargraph::Source] + def parse_string(filename, str) + Solargraph::Source.load_string(str, filename) + end + def load_sources(*sources) source_maps = sources.map { |s| Solargraph::SourceMap.map(s) } bench = Solargraph::Bench.new(source_maps: source_maps) From db9a7709641ae792d591a241b5c4f20f2b3a996c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lek=C3=AB=20Mula?= Date: Fri, 27 Feb 2026 22:50:18 +0100 Subject: [PATCH 2/3] Update Changelog --- CHANGELOG.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbba659..1b98805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,37 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- Add Rspec included modules support by @ShadiestGoat in #32 + +### Changed +- Chore: Test solargraph prereleases (#31) +- Chore: More reliable closure and location updates by @apiology in #30 +- Chore: Less limiting of gem pin processing in specs by @apiology in #29 + + +## [0.5.5] - 2025-10-05 + ### Fixed - Go-to-defintion/Hover for RSpec DSL methods (eg. context or it) +### Other Changes +- Chore: Add rubocop-yard by @lekemula in #20 +- Chore: Add CI solargraph typecheck step by @lekemula in #21 +- Chore: Adjust specs/CI for next solargraph release after 0.56.2 by @lekemula in #22 +- Chore: Improve CI speed by @lekemula in #23 +- Chore: Improve tests speed by @lekemula in #24 +- Chore: Update testing steps in developer documentation by @apiology in #25 +- Chore: Move 3rd party gems to Appraisals by @lekemula in #27 + ### Changed - Update testing steps in developer documentation +## [0.5.4] - 2025-09-06 + +### Fixed +- Fix breaking of solargraph rubocop diagnostics by @lekemula in #19 + ## [0.5.3] - 2025-09-02 ### Fixed From b2b2420d04aac709b74f2978ecfdd5a5cdc0aaef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lek=C3=AB=20Mula?= Date: Fri, 27 Feb 2026 22:56:12 +0100 Subject: [PATCH 3/3] Update README --- README.md | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e028b24..fe2cb7d 100644 --- a/README.md +++ b/README.md @@ -77,27 +77,25 @@ Add `solargraph-rspec` to your `.solargraph.yml` as a plugin. ``` ### Configuration -If you have your own custom `let`-like memoized methods, you can add them to your `.solargraph.yml` file like this: +You can customize the plugin behavior in your `.solargraph.yml`: ```yaml # .solargraph.yml # ... rspec: + # Custom let-like memoized methods (e.g., from rspec-given, test-prof) let_methods: - let_it_be -``` - -If you have your own custom `example`-like methods like `it`, you can add them to your `.solargraph.yml` file like this: -```yaml -# .solargraph.yml -# ... -rspec: + # Custom example-like methods (e.g., from rspec-given) example_methods: - my_it -``` -This is useful if you use gems like [rspec-given](https://github.com/rspec-given/rspec-given) which introduces its own `let` and `example` methods. + # RSpec helper files to analyze for included modules (shared contexts, custom matchers) + config_helper_files: + - spec/spec_helper.rb + - spec/rails_helper.rb +``` ### Gem completions