diff --git a/changelog/change_source_order_for_target_ruby.md b/changelog/change_source_order_for_target_ruby.md new file mode 100644 index 000000000000..ece6a74860be --- /dev/null +++ b/changelog/change_source_order_for_target_ruby.md @@ -0,0 +1 @@ +* [#12645](https://github.com/rubocop/rubocop/pull/12645): Change source order for target ruby to check gemspec after RuboCop configuration. ([@jenshenny][]) diff --git a/docs/modules/ROOT/pages/configuration.adoc b/docs/modules/ROOT/pages/configuration.adoc index a6598f327ced..a9a94b649e79 100644 --- a/docs/modules/ROOT/pages/configuration.adoc +++ b/docs/modules/ROOT/pages/configuration.adoc @@ -610,7 +610,7 @@ AllCops: Otherwise, RuboCop will then check your project for a series of files where the version may be specified already. The files that will be looked for are -`.ruby-version`, `.tool-versions`, `Gemfile.lock`, and `*.gemspec`. +`*.gemspec`, `.ruby-version`, `.tool-versions`, and `Gemfile.lock`. If Gemspec file has an array for `required_ruby_version`, the lowest version will be used. If none of the files are found a default version value will be used. diff --git a/lib/rubocop/target_ruby.rb b/lib/rubocop/target_ruby.rb index bb75d15f0c8c..564b27374c97 100644 --- a/lib/rubocop/target_ruby.rb +++ b/lib/rubocop/target_ruby.rb @@ -48,6 +48,84 @@ def find_version end end + # The target ruby version may be found in a .gemspec file. + # @api private + class GemspecFile < Source + extend NodePattern::Macros + + GEMSPEC_EXTENSION = '.gemspec' + + # @!method required_ruby_version(node) + def_node_search :required_ruby_version, <<~PATTERN + (send _ :required_ruby_version= $_) + PATTERN + + # @!method gem_requirement_versions(node) + def_node_matcher :gem_requirement_versions, <<~PATTERN + (send (const(const _ :Gem):Requirement) :new + {$str+ | (send $str :freeze)+ | (array $str+) | (array (send $str :freeze)+)} + ) + PATTERN + + def name + "`required_ruby_version` parameter (in #{gemspec_filename})" + end + + private + + def find_version + file = gemspec_filepath + return unless file && File.file?(file) + + right_hand_side = version_from_gemspec_file(file) + return if right_hand_side.nil? + + find_default_minimal_known_ruby(right_hand_side) + end + + def gemspec_filename + @gemspec_filename ||= begin + basename = Pathname.new(@config.base_dir_for_path_parameters).basename.to_s + "#{basename}#{GEMSPEC_EXTENSION}" + end + end + + def gemspec_filepath + @gemspec_filepath ||= + @config.find_file_upwards(gemspec_filename, @config.base_dir_for_path_parameters) + end + + def version_from_gemspec_file(file) + processed_source = ProcessedSource.from_file(file, DEFAULT_VERSION) + required_ruby_version(processed_source.ast).first + end + + def version_from_right_hand_side(right_hand_side) + gem_requirement_versions = gem_requirement_versions(right_hand_side) + + if right_hand_side.array_type? + version_from_array(right_hand_side) + elsif gem_requirement_versions + gem_requirement_versions.map(&:value) + else + right_hand_side.value + end + end + + def version_from_array(array) + array.children.map(&:value) + end + + def find_default_minimal_known_ruby(right_hand_side) + version = version_from_right_hand_side(right_hand_side) + requirement = Gem::Requirement.new(version) + + KNOWN_RUBIES.detect do |v| + v >= DEFAULT_VERSION && requirement.satisfied_by?(Gem::Version.new("#{v}.99")) + end + end + end + # The target ruby version may be found in a .ruby-version file. # @api private class RubyVersionFile < Source @@ -143,84 +221,6 @@ def bundler_lock_file_path end end - # The target ruby version may be found in a .gemspec file. - # @api private - class GemspecFile < Source - extend NodePattern::Macros - - GEMSPEC_EXTENSION = '.gemspec' - - # @!method required_ruby_version(node) - def_node_search :required_ruby_version, <<~PATTERN - (send _ :required_ruby_version= $_) - PATTERN - - # @!method gem_requirement_versions(node) - def_node_matcher :gem_requirement_versions, <<~PATTERN - (send (const(const _ :Gem):Requirement) :new - {$str+ | (send $str :freeze)+ | (array $str+) | (array (send $str :freeze)+)} - ) - PATTERN - - def name - "`required_ruby_version` parameter (in #{gemspec_filename})" - end - - private - - def find_version - file = gemspec_filepath - return unless file && File.file?(file) - - right_hand_side = version_from_gemspec_file(file) - return if right_hand_side.nil? - - find_default_minimal_known_ruby(right_hand_side) - end - - def gemspec_filename - @gemspec_filename ||= begin - basename = Pathname.new(@config.base_dir_for_path_parameters).basename.to_s - "#{basename}#{GEMSPEC_EXTENSION}" - end - end - - def gemspec_filepath - @gemspec_filepath ||= - @config.find_file_upwards(gemspec_filename, @config.base_dir_for_path_parameters) - end - - def version_from_gemspec_file(file) - processed_source = ProcessedSource.from_file(file, DEFAULT_VERSION) - required_ruby_version(processed_source.ast).first - end - - def version_from_right_hand_side(right_hand_side) - gem_requirement_versions = gem_requirement_versions(right_hand_side) - - if right_hand_side.array_type? - version_from_array(right_hand_side) - elsif gem_requirement_versions - gem_requirement_versions.map(&:value) - else - right_hand_side.value - end - end - - def version_from_array(array) - array.children.map(&:value) - end - - def find_default_minimal_known_ruby(right_hand_side) - version = version_from_right_hand_side(right_hand_side) - requirement = Gem::Requirement.new(version) - - KNOWN_RUBIES.detect do |v| - v >= DEFAULT_VERSION && requirement.satisfied_by?(Gem::Version.new("#{v}.99")) - end - end - end - # If all else fails, a default version will be picked. # @api private class Default < Source @@ -241,10 +241,10 @@ def self.supported_versions SOURCES = [ RuboCopConfig, + GemspecFile, RubyVersionFile, ToolVersionsFile, BundlerLockFile, - GemspecFile, Default ].freeze diff --git a/spec/rubocop/target_ruby_spec.rb b/spec/rubocop/target_ruby_spec.rb index 53b3df1007ca..cc3753349ca5 100644 --- a/spec/rubocop/target_ruby_spec.rb +++ b/spec/rubocop/target_ruby_spec.rb @@ -33,6 +33,203 @@ end context 'when TargetRubyVersion is not set' do + context 'when gemspec file is present' do + let(:base_path) { configuration.base_dir_for_path_parameters } + let(:gemspec_file_path) { File.join(base_path, 'example.gemspec') } + + context 'when file contains `required_ruby_version` as a string' do + it 'sets target_ruby from inclusive range' do + content = <<~HEREDOC + Gem::Specification.new do |s| + s.name = 'test' + s.required_ruby_version = '>= 2.7.2' + s.licenses = ['MIT'] + end + HEREDOC + + create_file(gemspec_file_path, content) + expect(target_ruby.version).to eq 2.7 + end + + it 'sets target_ruby from exclusive range' do + content = <<~HEREDOC + Gem::Specification.new do |s| + s.name = 'test' + s.required_ruby_version = '> 2.7.8' + s.licenses = ['MIT'] + end + HEREDOC + + create_file(gemspec_file_path, content) + expect(target_ruby.version).to eq 2.7 + end + + it 'sets target_ruby from approximate version' do + content = <<~HEREDOC + Gem::Specification.new do |s| + s.name = 'test' + s.required_ruby_version = '~> 2.7.0' + s.licenses = ['MIT'] + end + HEREDOC + + create_file(gemspec_file_path, content) + expect(target_ruby.version).to eq 2.7 + end + end + + context 'when file contains `required_ruby_version` as a requirement' do + it 'sets target_ruby from required_ruby_version from inclusive requirement range' do + content = <<~HEREDOC + Gem::Specification.new do |s| + s.name = 'test' + s.required_ruby_version = Gem::Requirement.new('>= 2.3.1') + s.licenses = ['MIT'] + end + HEREDOC + + create_file(gemspec_file_path, content) + expect(target_ruby.version).to eq default_version + end + + it 'sets first known ruby version that satisfies requirement' do + content = <<~HEREDOC + Gem::Specification.new do |s| + s.name = 'test' + s.required_ruby_version = Gem::Requirement.new('< 3.0.0') + s.licenses = ['MIT'] + end + HEREDOC + + create_file(gemspec_file_path, content) + expect(target_ruby.version).to eq default_version + end + + it 'sets first known ruby version that satisfies range requirement' do + content = <<~HEREDOC + Gem::Specification.new do |s| + s.name = 'test' + s.required_ruby_version = Gem::Requirement.new('>= 2.3.1', '< 3.0.0') + s.licenses = ['MIT'] + end + HEREDOC + + create_file(gemspec_file_path, content) + expect(target_ruby.version).to eq default_version + end + + it 'sets first known ruby version that satisfies range requirement in array notation' do + content = <<~HEREDOC + Gem::Specification.new do |s| + s.name = 'test' + s.required_ruby_version = Gem::Requirement.new(['>= 2.3.1', '< 3.0.0']) + s.licenses = ['MIT'] + end + HEREDOC + + create_file(gemspec_file_path, content) + expect(target_ruby.version).to eq default_version + end + + it 'sets first known ruby version that satisfies range requirement with frozen strings' do + content = <<~HEREDOC + Gem::Specification.new do |s| + s.name = 'test' + s.required_ruby_version = Gem::Requirement.new('>= 2.3.1'.freeze, '< 3.0.0'.freeze) + s.licenses = ['MIT'] + end + HEREDOC + + create_file(gemspec_file_path, content) + expect(target_ruby.version).to eq default_version + end + + it 'sets first known ruby version that satisfies range requirement in array notation with frozen strings' do + content = <<~HEREDOC + Gem::Specification.new do |s| + s.name = 'test' + s.required_ruby_version = Gem::Requirement.new(['>= 2.3.1'.freeze, '< 3.0.0'.freeze]) + s.licenses = ['MIT'] + end + HEREDOC + + create_file(gemspec_file_path, content) + expect(target_ruby.version).to eq default_version + end + end + + context 'when file contains `required_ruby_version` as an array' do + it 'sets target_ruby to the minimal version satisfying the requirements' do + content = <<~HEREDOC + Gem::Specification.new do |s| + s.name = 'test' + s.required_ruby_version = ['<=3.0.4', '>=2.7.5'] + s.licenses = ['MIT'] + end + HEREDOC + + create_file(gemspec_file_path, content) + expect(target_ruby.version).to eq 2.7 + end + + it 'sets target_ruby from required_ruby_version with many requirements' do + content = <<~HEREDOC + Gem::Specification.new do |s| + s.name = 'test' + s.required_ruby_version = ['<=3.1.0', '>2.6.8', '~>2.7.1'] + s.licenses = ['MIT'] + end + HEREDOC + + create_file(gemspec_file_path, content) + expect(target_ruby.version).to eq 2.7 + end + end + + context 'when required_ruby_version sets the target ruby' do + before do + content = <<~HEREDOC + Gem::Specification.new do |s| + s.name = 'test' + s.required_ruby_version = '>= 2.7.2' + s.licenses = ['MIT'] + end + HEREDOC + create_file(gemspec_file_path, content) + end + + it 'does not check the other sources' do + expect(described_class::RubyVersionFile).not_to receive(:new) + expect(described_class::ToolVersionsFile).not_to receive(:new) + expect(described_class::BundlerLockFile).not_to receive(:new) + expect(described_class::Default).not_to receive(:new) + target_ruby.version + end + end + + context 'when file does not contain `required_ruby_version`' do + before do + content = <<~HEREDOC + Gem::Specification.new do |s| + s.name = 'test' + s.platform = Gem::Platform::RUBY + s.licenses = ['MIT'] + s.summary = 'test tool.' + end + HEREDOC + create_file(gemspec_file_path, content) + end + + it 'checks the rest of the sources' do + expect(described_class::RubyVersionFile).to receive(:new).and_call_original + expect(described_class::ToolVersionsFile).to receive(:new).and_call_original + expect(described_class::BundlerLockFile).to receive(:new).and_call_original + expect(described_class::Default).to receive(:new).and_call_original + target_ruby.version + end + end + end + context 'when .ruby-version is present' do before do dir = configuration.base_dir_for_path_parameters @@ -295,185 +492,6 @@ expect(target_ruby.version).to eq default_version end end - - context 'gemspec file' do - context 'when file contains `required_ruby_version` as a string' do - let(:base_path) { configuration.base_dir_for_path_parameters } - let(:gemspec_file_path) { File.join(base_path, 'example.gemspec') } - - it 'sets target_ruby from inclusive range' do - content = <<~HEREDOC - Gem::Specification.new do |s| - s.name = 'test' - s.required_ruby_version = '>= 2.7.2' - s.licenses = ['MIT'] - end - HEREDOC - - create_file(gemspec_file_path, content) - expect(target_ruby.version).to eq 2.7 - end - - it 'sets target_ruby from exclusive range' do - content = <<~HEREDOC - Gem::Specification.new do |s| - s.name = 'test' - s.required_ruby_version = '> 2.7.8' - s.licenses = ['MIT'] - end - HEREDOC - - create_file(gemspec_file_path, content) - expect(target_ruby.version).to eq 2.7 - end - - it 'sets target_ruby from approximate version' do - content = <<~HEREDOC - Gem::Specification.new do |s| - s.name = 'test' - s.required_ruby_version = '~> 2.7.0' - s.licenses = ['MIT'] - end - HEREDOC - - create_file(gemspec_file_path, content) - expect(target_ruby.version).to eq 2.7 - end - end - - context 'when file contains `required_ruby_version` as a requirement' do - let(:base_path) { configuration.base_dir_for_path_parameters } - let(:gemspec_file_path) { File.join(base_path, 'example.gemspec') } - - it 'sets target_ruby from required_ruby_version from inclusive requirement range' do - content = <<~HEREDOC - Gem::Specification.new do |s| - s.name = 'test' - s.required_ruby_version = Gem::Requirement.new('>= 2.3.1') - s.licenses = ['MIT'] - end - HEREDOC - - create_file(gemspec_file_path, content) - expect(target_ruby.version).to eq default_version - end - - it 'sets first known ruby version that satisfies requirement' do - content = <<~HEREDOC - Gem::Specification.new do |s| - s.name = 'test' - s.required_ruby_version = Gem::Requirement.new('< 3.0.0') - s.licenses = ['MIT'] - end - HEREDOC - - create_file(gemspec_file_path, content) - expect(target_ruby.version).to eq default_version - end - - it 'sets first known ruby version that satisfies range requirement' do - content = <<~HEREDOC - Gem::Specification.new do |s| - s.name = 'test' - s.required_ruby_version = Gem::Requirement.new('>= 2.3.1', '< 3.0.0') - s.licenses = ['MIT'] - end - HEREDOC - - create_file(gemspec_file_path, content) - expect(target_ruby.version).to eq default_version - end - - it 'sets first known ruby version that satisfies range requirement in array notation' do - content = <<~HEREDOC - Gem::Specification.new do |s| - s.name = 'test' - s.required_ruby_version = Gem::Requirement.new(['>= 2.3.1', '< 3.0.0']) - s.licenses = ['MIT'] - end - HEREDOC - - create_file(gemspec_file_path, content) - expect(target_ruby.version).to eq default_version - end - - it 'sets first known ruby version that satisfies range requirement with frozen strings' do - content = <<~HEREDOC - Gem::Specification.new do |s| - s.name = 'test' - s.required_ruby_version = Gem::Requirement.new('>= 2.3.1'.freeze, '< 3.0.0'.freeze) - s.licenses = ['MIT'] - end - HEREDOC - - create_file(gemspec_file_path, content) - expect(target_ruby.version).to eq default_version - end - - it 'sets first known ruby version that satisfies range requirement in array notation with frozen strings' do - content = <<~HEREDOC - Gem::Specification.new do |s| - s.name = 'test' - s.required_ruby_version = Gem::Requirement.new(['>= 2.3.1'.freeze, '< 3.0.0'.freeze]) - s.licenses = ['MIT'] - end - HEREDOC - - create_file(gemspec_file_path, content) - expect(target_ruby.version).to eq default_version - end - end - - context 'when file contains `required_ruby_version` as an array' do - let(:base_path) { configuration.base_dir_for_path_parameters } - let(:gemspec_file_path) { File.join(base_path, 'example.gemspec') } - - it 'sets target_ruby to the minimal version satisfying the requirements' do - content = <<~HEREDOC - Gem::Specification.new do |s| - s.name = 'test' - s.required_ruby_version = ['<=3.0.4', '>=2.7.5'] - s.licenses = ['MIT'] - end - HEREDOC - - create_file(gemspec_file_path, content) - expect(target_ruby.version).to eq 2.7 - end - - it 'sets target_ruby from required_ruby_version with many requirements' do - content = <<~HEREDOC - Gem::Specification.new do |s| - s.name = 'test' - s.required_ruby_version = ['<=3.1.0', '>2.6.8', '~>2.7.1'] - s.licenses = ['MIT'] - end - HEREDOC - - create_file(gemspec_file_path, content) - expect(target_ruby.version).to eq 2.7 - end - end - - context 'when file does not contain `required_ruby_version`' do - let(:base_path) { configuration.base_dir_for_path_parameters } - let(:gemspec_file_path) { File.join(base_path, 'example.gemspec') } - - it 'sets default target_ruby' do - content = <<~HEREDOC - Gem::Specification.new do |s| - s.name = 'test' - s.platform = Gem::Platform::RUBY - s.licenses = ['MIT'] - s.summary = 'test tool.' - end - HEREDOC - - create_file(gemspec_file_path, content) - expect(target_ruby.version).to eq default_version - end - end - end end context 'when .ruby-version is in a parent directory' do