Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new requires_gem API for declaring which gems a Cop needs #12186

Merged
merged 6 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/new_requires_gem_api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* [#12186](https://github.com/rubocop/rubocop/pull/12186): Add new `requires_gem` API for declaring which gems a Cop needs. ([@amomchilov][])
56 changes: 55 additions & 1 deletion docs/modules/ROOT/pages/development.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,60 @@ This works because the correcting a file is implemented by repeating investigati

Note that `expect_correction` in `Cop` specs only asserts the result after one pass.

=== Limit by Ruby or Gem versions

Some cops apply changes that only apply in particular contexts, such as if the user has a minimum Ruby version. There are helpers that let you constrain your cops automatically, to only run where applicable.

==== Requiring a minimum Ruby version

If your cop uses new Ruby syntax or standard library APIs, it should only autocorrect if the user has the target Ruby version, which you set with https://www.rubydoc.info/gems/rubocop/RuboCop/Cop/TargetRubyVersion#minimum_target_ruby_version-instance_metho[`TargetRubyVersion#minimum_target_ruby_version`].

For example, the `Performance/SelectMap` cop requires Ruby 2.7, which introduced `Enumerable#filter_map`:

```ruby
module RuboCop::Cop::Performance::SelectMap < Base
extend TargetRubyVersion

minimum_target_ruby_version 2.7

# ...
end
```

This cop won't autocorrect on Ruby 2.6 or older, and it won't even report an offense (since there's no better alternative to recommend instead).

==== Requiring a gem

If your cop depends on the presence of a gem, you can declare that with https://www.rubydoc.info/gems/rubocop/RuboCop/Cop/Base#requires_gem-class_method[`RuboCop::Cop::Base.requires_gem`].

For example, to declare that `MyCop` should only apply if the bundle is using `my-gem` with a version between `1.2.3` and `4.5.6`:

```Ruby
class MyCop < Base
requires_gem "my-gem", ">= 1.2.3", "< 4.5.6"

# ...
end
```

You can specify any gem requirement using https://guides.rubygems.org/patterns/#declaring-dependencies[the same syntax as your `Gemfile`].

==== Special case: Rails

Historically, many cops in `rubocop-rails` aren't actually specific to Rails itself, but some of its components (e.g. `ActiveSupport`). These dependencies are declared with https://www.rubydoc.info/gems/rubocop-rails/RuboCop/Cop/TargetRailsVersion#minimum_target_rails_version-instance_method[`TargetRailsVersion.minimum_target_rails_version`].

For example, the `Rails/Pluck` cop requires ActiveSupport 6.0, which introduces `Enumerable#pluck`:

```ruby
module RuboCop::Cop::Rails::Pluck < Base
extend TargetRailsVersion

minimum_target_rails_version 6.0

#...
end
```

=== Run tests

RuboCop supports two parser engines: the Parser gem and Prism. By default, tests are executed with the Parser:
Expand Down Expand Up @@ -493,7 +547,7 @@ we strive to make this consistent. PRs improving RuboCop documentation are very

== Testing your cop in a real codebase

Generally, is a good practice to check if your cop is working properly over a
It's generally good practice to check if your cop is working properly over a
significant codebase (e.g. Rails or some big project you're working on) to
guarantee it's working in a range of different syntaxes.

Expand Down
17 changes: 17 additions & 0 deletions lib/rubocop/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,7 @@ def smart_loaded_path
PathUtil.smart_path(@loaded_path)
end

# @return [String, nil]
def bundler_lock_file_path
return nil unless loaded_path

Expand All @@ -286,16 +287,24 @@ def pending_cops
end
end

# Returns target's locked gem versions (i.e. from Gemfile.lock or gems.locked)
# @returns [Hash{String => Gem::Version}] The locked gem versions, keyed by the gems' names.
def gem_versions_in_target
@gem_versions_in_target ||= read_gem_versions_from_target_lockfile
end

def inspect # :nodoc:
"#<#{self.class.name}:#{object_id} @loaded_path=#{loaded_path}>"
end

private

# @return [Float, nil] The Rails version as a `major.minor` Float.
def target_rails_version_from_bundler_lock_file
@target_rails_version_from_bundler_lock_file ||= read_rails_version_from_bundler_lock_file
end

# @return [Float, nil] The Rails version as a `major.minor` Float.
def read_rails_version_from_bundler_lock_file
lock_file_path = bundler_lock_file_path
return nil unless lock_file_path
Expand All @@ -309,6 +318,14 @@ def read_rails_version_from_bundler_lock_file
end
end

# @returns [Hash{String => Gem::Version}] The locked gem versions, keyed by the gems' names.
def read_gem_versions_from_target_lockfile
lockfile_path = bundler_lock_file_path
return nil unless lockfile_path

Lockfile.new(lockfile_path).gem_versions
end

def enable_cop?(qualified_cop_name, cop_options)
# If the cop is explicitly enabled or `Lint/Syntax`, the other checks can be skipped.
return true if cop_options['Enabled'] == true || qualified_cop_name == 'Lint/Syntax'
Expand Down
37 changes: 37 additions & 0 deletions lib/rubocop/cop/base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ def self.documentation_url

def self.inherited(subclass)
super
subclass.instance_variable_set(:@gem_requirements, gem_requirements.dup)
Registry.global.enlist(subclass)
end

Expand Down Expand Up @@ -126,6 +127,29 @@ def self.support_multiple_source?
false
end

## Gem requirements

@gem_requirements = {}

class << self
attr_reader :gem_requirements

# Register a version requirement for the given gem name.
# This cop will be skipped unless the target satisfies *all* requirements.
# @param [String] gem_name
# @param [Array<String>] version_requirements The version requirements,
# using the same syntax as a Gemfile, e.g. ">= 1.2.3"
#
# If omitted, any version of the gem will be accepted.
#
# https://guides.rubygems.org/patterns/#declaring-dependencies
#
# @api public
def requires_gem(gem_name, *version_requirements)
@gem_requirements[gem_name] = Gem::Requirement.new(version_requirements)
end
end

def initialize(config = nil, options = nil)
@config = config || Config.new
@options = options || { debug: false }
Expand Down Expand Up @@ -245,6 +269,7 @@ def active_support_extensions_enabled?
end

def relevant_file?(file)
return false unless target_satisfies_all_gem_version_requirements?
return true unless @config.clusivity_config_for_badge?(self.class.badge)

file == RuboCop::AST::ProcessedSource::STRING_SOURCE_NAME ||
Expand Down Expand Up @@ -496,6 +521,18 @@ def range_for_original(range)
range.end_pos + @current_offset
)
end

def target_satisfies_all_gem_version_requirements?
self.class.gem_requirements.all? do |gem_name, version_req|
all_gem_versions_in_target = @config.gem_versions_in_target
next false unless all_gem_versions_in_target

gem_version_in_target = all_gem_versions_in_target[gem_name]
next false unless gem_version_in_target

version_req.satisfied_by?(gem_version_in_target)
end
end
end
end
end
3 changes: 3 additions & 0 deletions lib/rubocop/cop/team.rb
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,9 @@ def support_target_ruby_version?(cop)
end

def support_target_rails_version?(cop)
# In this case, the rails version was already checked by `#excluded_file?`
return true if defined?(RuboCop::Rails::TargetRailsVersion::USES_REQUIRES_GEM_API)
Comment on lines +177 to +178
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the versioning between rubocop and rubocop-rails gets a bit tricky here.

  1. This new version of rubocop needs to support older versions of rubocop-rails.
  2. Inversely, the new version of rubocop-rails needs to support older versions of rubocop.

I've tested both scenarios, and they both work well.

I added this USES_REQUIRES_GEM_API constant to detect new rubocop-rails versions, because it was more explicit/clear than checking against some specific version number.

In the future, TargetRailsVersion can just be a wrapper for requires_gem. When that happens, any cops that don't support the target's rails version would have already be filtered out by #excluded_file? called by #roundup_relevant_cops above.

For older versions of rubocop-rails, that won't be correct, and we'll still need to call their support_target_rails_version? method.


return true unless cop.class.respond_to?(:support_target_rails_version?)

cop.class.support_target_rails_version?(cop.target_rails_version)
Expand Down
38 changes: 34 additions & 4 deletions lib/rubocop/lockfile.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,23 @@ module RuboCop
# Does not actually resolve gems, just parses the lockfile.
# @api private
class Lockfile
# Gems that the bundle depends on
# @param [String, Pathname, nil] lockfile_path
def initialize(lockfile_path = nil)
lockfile_path ||= defined?(Bundler) ? Bundler.default_lockfile : nil
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I parameterized this class to let you specify your own lockfile_path, but kept the default behaviour the same as before.

This let's use it with the result from RuboCop::Config#bundler_lock_file_path.


@lockfile_path = lockfile_path
end

# Gems that the bundle directly depends on.
# @return [Array<Bundler::Dependency>, nil]
Comment on lines +15 to +16
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These methods' semantics aren't obvious (in particular, whether they include indirect dependencies or not, and what type of results they vend), so I documented them all.

The whole class is marked @api private, which get inherited by all of these methods, so there's no risk of people confusing them for public API (even if they're documented here).

def dependencies
return [] unless parser

parser.dependencies.values
end

# All activated gems, including transitive dependencies
# All activated gems, including transitive dependencies.
# @return [Array<Bundler::Dependency>, nil]
def gems
return [] unless parser

Expand All @@ -21,17 +30,38 @@ def gems
parser.dependencies.values.concat(parser.specs.flat_map(&:dependencies))
end

# Returns the locked versions of gems from this lockfile.
# @param [Boolean] include_transitive_dependencies: When false, only direct dependencies
# are returned, i.e. those listed explicitly in the `Gemfile`.
# @returns [Hash{String => Gem::Version}] The locked gem versions, keyed by the gems' names.
def gem_versions(include_transitive_dependencies: true)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pre-existing methods (#dependencies and #spec) return Gem::Dependency objects, which describe the version requirements, but not the resolved versions.

This method returns the actual resolved versions, locked by the lock file.

This is what we check gem requirements against.

return {} unless parser

all_gem_versions = parser.specs.to_h { |spec| [spec.name, spec.version] }

if include_transitive_dependencies
all_gem_versions
else
direct_dep_names = parser.dependencies.keys
all_gem_versions.slice(*direct_dep_names)
end
end

# Whether this lockfile includes the named gem, directly or indirectly.
# @param [String] name
# @return [Boolean]
def includes_gem?(name)
gems.any? { |gem| gem.name == name }
end

private

# @return [Bundler::LockfileParser, nil]
def parser
return unless defined?(Bundler) && Bundler.default_lockfile
return @parser if defined?(@parser)
return unless @lockfile_path

lockfile = Bundler.read_file(Bundler.default_lockfile)
lockfile = Bundler.read_file(@lockfile_path)
@parser = lockfile ? Bundler::LockfileParser.new(lockfile) : nil
rescue Bundler::BundlerError
nil
Expand Down
14 changes: 13 additions & 1 deletion lib/rubocop/rspec/shared_contexts.rb
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,19 @@ def source_range(range, buffer: source_buffer)
let(:config) do
hash = { 'AllCops' => all_cops_config, cop_class.cop_name => cur_cop_config }.merge!(other_cops)

RuboCop::Config.new(hash, "#{Dir.pwd}/.rubocop.yml")
config = RuboCop::Config.new(hash, "#{Dir.pwd}/.rubocop.yml")

rails_version_in_gemfile = Gem::Version.new(
rails_version || RuboCop::Config::DEFAULT_RAILS_VERSION
)

allow(config).to receive(:gem_versions_in_target).and_return(
{
'railties' => rails_version_in_gemfile
}
)

config
end

let(:cop) { cop_class.new(config, cop_options) }
Expand Down
50 changes: 50 additions & 0 deletions spec/rubocop/config_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -825,6 +825,56 @@ def cop_enabled(cop_class)
end
end

describe '#gem_versions_in_target', :isolated_environment do
['Gemfile.lock', 'gems.locked'].each do |file_name|
let(:base_path) { configuration.base_dir_for_path_parameters }
let(:lockfile_path) { File.join(base_path, file_name) }

context "and #{file_name} exists" do
it 'returns the locked gem versions' do
content =
<<~LOCKFILE
GEM
remote: https://rubygems.org/
specs:
a (1.1.1)
b (2.2.2)
c (3.3.3)
d (4.4.4)
a (= 1.1.1)
b (>= 1.1.1, < 3.3.3)
c (~> 3.3)

PLATFORMS
ruby

DEPENDENCIES
rails (= 4.1.0)

BUNDLED WITH
2.4.19
LOCKFILE

expected = {
'a' => Gem::Version.new('1.1.1'),
'b' => Gem::Version.new('2.2.2'),
'c' => Gem::Version.new('3.3.3'),
'd' => Gem::Version.new('4.4.4')
}

create_file(lockfile_path, content)
expect(configuration.gem_versions_in_target).to eq expected
end
end
end

context 'and neither Gemfile.lock nor gems.locked exist' do
it 'returns nil' do
expect(configuration.gem_versions_in_target.nil?).to be(true)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not used to RSpec, is this correct?

I originally had:

Suggested change
expect(configuration.gem_versions_in_target.nil?).to be(true)
expect(configuration.gem_versions_in_target).to be_nil

But rubocop auto-corrected to this. It feels a bit... funky

Copy link

@abzuar9658 abzuar9658 Sep 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct! expect(configuration.gem_versions_in_target).to be_nil

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm I'll try again, but the RuboCop settings on this project didn't like the use of predicate methods like be_nil

end
end
end

describe '#for_department', :restore_registry do
let(:hash) do
{
Expand Down