From d757b8f1ba85039f54ffd508a1da835157c3dd81 Mon Sep 17 00:00:00 2001 From: nobuyo Date: Sat, 9 Jul 2022 16:11:13 +0900 Subject: [PATCH] [Fix #10731] Show tip for suggested extensions that are installed but not loaded in .rubocop.yml --- .rubocop_todo.yml | 1 + ..._show_tip_for_suggested_extensions_that.md | 1 + lib/rubocop/cli/command/suggest_extensions.rb | 68 ++- spec/rubocop/cli/suggest_extensions_spec.rb | 398 ++++++++++++++++++ spec/rubocop/cli_spec.rb | 303 ------------- 5 files changed, 453 insertions(+), 318 deletions(-) create mode 100644 changelog/change_show_tip_for_suggested_extensions_that.md create mode 100644 spec/rubocop/cli/suggest_extensions_spec.rb diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index e48f76c5513..d9c14e407dc 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -29,6 +29,7 @@ Metrics/ModuleLength: RSpec/AnyInstance: Exclude: - 'spec/rubocop/cli_spec.rb' + - 'spec/rubocop/cli/suggest_extensions_spec.rb' - 'spec/rubocop/cop/lint/duplicate_methods_spec.rb' - 'spec/rubocop/cop/team_spec.rb' - 'spec/rubocop/target_finder_spec.rb' diff --git a/changelog/change_show_tip_for_suggested_extensions_that.md b/changelog/change_show_tip_for_suggested_extensions_that.md new file mode 100644 index 00000000000..1a1a410aac4 --- /dev/null +++ b/changelog/change_show_tip_for_suggested_extensions_that.md @@ -0,0 +1 @@ +* [#10731](https://github.com/rubocop/rubocop/issues/10731): Show tip for suggested extensions that are installed but not loaded in .rubocop.yml. ([@nobuyo][]) diff --git a/lib/rubocop/cli/command/suggest_extensions.rb b/lib/rubocop/cli/command/suggest_extensions.rb index 9409e075b5e..6d22b770336 100644 --- a/lib/rubocop/cli/command/suggest_extensions.rb +++ b/lib/rubocop/cli/command/suggest_extensions.rb @@ -17,20 +17,10 @@ class SuggestExtensions < Base def run return if skip? || extensions.none? - puts - puts 'Tip: Based on detected gems, the following ' \ - 'RuboCop extension libraries might be helpful:' - - extensions.sort.each do |extension| - puts " * #{extension} (https://rubygems.org/gems/#{extension})" - end + print_install_suggestions if not_installed_extensions.any? + print_load_suggestions if installed_and_not_loaded_extensions.any? - puts - puts 'You can opt out of this message by adding the following to your config ' \ - '(see https://docs.rubocop.org/rubocop/extensions.html#extension-suggestions ' \ - 'for more options):' - puts ' AllCops:' - puts ' SuggestExtensions: false' + print_opt_out_instruction puts if @options[:display_time] end @@ -48,15 +38,63 @@ def skip? !INCLUDED_FORMATTERS.include?(current_formatter) end + def print_install_suggestions + puts + puts 'Tip: Based on detected gems, the following ' \ + 'RuboCop extension libraries might be helpful:' + + not_installed_extensions.sort.each do |extension| + puts " * #{extension} (https://rubygems.org/gems/#{extension})" + end + end + + def print_load_suggestions + puts + puts 'The following RuboCop extension libraries are installed but not loaded in config:' + + installed_and_not_loaded_extensions.sort.each do |extension| + puts " * #{extension}" + end + end + + def print_opt_out_instruction + puts + puts 'You can opt out of this message by adding the following to your config ' \ + '(see https://docs.rubocop.org/rubocop/extensions.html#extension-suggestions ' \ + 'for more options):' + puts ' AllCops:' + puts ' SuggestExtensions: false' + end + def current_formatter @options[:format] || @config_store.for_pwd.for_all_cops['DefaultFormatter'] || 'p' end - def extensions + def all_extensions return [] unless lockfile.dependencies.any? extensions = @config_store.for_pwd.for_all_cops['SuggestExtensions'] || {} - extensions.select { |_, v| (Array(v) & dependent_gems).any? }.keys - installed_gems + extensions.select { |_, v| (Array(v) & dependent_gems).any? }.keys + end + + def extensions + not_installed_extensions + installed_and_not_loaded_extensions + end + + def installed_extensions + all_extensions & installed_gems + end + + def not_installed_extensions + all_extensions - installed_gems + end + + def loaded_extensions + @config_store.for_pwd.loaded_features.to_a + end + + def installed_and_not_loaded_extensions + installed_extensions - loaded_extensions end def lockfile diff --git a/spec/rubocop/cli/suggest_extensions_spec.rb b/spec/rubocop/cli/suggest_extensions_spec.rb new file mode 100644 index 00000000000..5e9ab7ea3c2 --- /dev/null +++ b/spec/rubocop/cli/suggest_extensions_spec.rb @@ -0,0 +1,398 @@ +# frozen_string_literal: true + +require 'timeout' + +RSpec.describe 'RuboCop::CLI SuggestExtensions', :isolated_environment do # rubocop:disable RSpec/DescribeClass + subject(:cli) { RuboCop::CLI.new } + + include_context 'cli spec behavior' + + describe 'extension suggestions', :config do + matcher :suggest_extensions do + supports_block_expectations + attr_accessor :install_suggested, :load_suggested + + def extensions_to_install_suggest + @extensions_to_install_suggest ||= [] + end + + def extensions_to_load_suggest + @extensions_to_load_suggest ||= [] + end + + def install_suggestion_regex + Regexp.new(<<~REGEXP, Regexp::MULTILINE).freeze + Tip: Based on detected gems, the following RuboCop extension libraries might be helpful: + (?.*?) + ^$ + REGEXP + end + + def load_suggestion_regex + Regexp.new(<<~REGEXP, Regexp::MULTILINE).freeze + The following RuboCop extension libraries are installed but not loaded in config: + (?.*?) + ^$ + REGEXP + end + + def find_suggestions + actual.call + suggestions = (install_suggestion_regex.match($stdout.string) || {})[:suggestions] + self.install_suggested = suggestions ? suggestions.scan(/(?<=\* )[a-z0-9_-]+\b/.freeze) : [] + + suggestions = (load_suggestion_regex.match($stdout.string) || {})[:suggestions] + self.load_suggested = suggestions ? suggestions.scan(/(?<=\* )[a-z0-9_-]+\b/.freeze) : [] + end + + chain :to_install do |*extensions| + @extensions_to_install_suggest = extensions + end + + chain :to_load do |*extensions| + @extensions_to_load_suggest = extensions + end + + match do + find_suggestions + install_suggested == extensions_to_install_suggest && + load_suggested == extensions_to_load_suggest + end + + match_when_negated do + find_suggestions + install_suggested.none? && load_suggested.none? + end + + failure_message do + 'expected to suggest extensions to install ' \ + "[#{extensions_to_install_suggest.join(', ')}], " \ + "load [#{extensions_to_load_suggest.join(', ')}], " \ + "but got [#{install_suggested.join(', ')}], [#{load_suggested.join(', ')}]" + end + + failure_message_when_negated do + 'expected to not suggest extensions, ' \ + "but got [#{install_suggested.join(', ')}] as install suggestion, " \ + "[#{load_suggested.join(', ')}] as load suggestion" + end + end + + let(:cop_class) { RuboCop::Cop::Base } + let(:loaded_features) { %w[] } + + let(:lockfile) do + create_file('Gemfile.lock', <<~LOCKFILE) + GEM + specs: + rake (13.0.1) + rspec (3.9.0) + + PLATFORMS + ruby + + DEPENDENCIES + rake (~> 13.0) + rspec (~> 3.7) + LOCKFILE + end + + before do + create_file('example.rb', <<~RUBY) + # frozen_string_literal: true + + puts 'ok' + RUBY + + # Ensure that these specs works in CI, since the feature is generally + # disabled in when ENV['CI'] is set. + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:fetch).with('CI', nil).and_return(false) + + # Mock the lockfile to be parsed by bundler + allow(Bundler).to receive(:default_lockfile) + .and_return(lockfile ? Pathname.new(lockfile) : nil) + + allow_any_instance_of(RuboCop::Config).to receive(:loaded_features) + .and_return(loaded_features) + end + + context 'when bundler is not loaded' do + before do + hide_const('Bundler') + end + + it 'does not show the suggestion' do + expect { cli.run(['example.rb']) }.not_to suggest_extensions + expect($stderr.string.blank?).to be(true) + end + end + + context 'when there are gems to suggest' do + context 'that are not installed' do + it 'shows the suggestion' do + expect do + cli.run(['example.rb']) + end.to suggest_extensions.to_install('rubocop-rake', 'rubocop-rspec') + end + end + + context 'that are dependencies' do + let(:gemfile) do + create_file('Gemfile', <<~RUBY) + gem 'rspec' + gem 'rake' + gem 'rubocop-rspec' + gem 'rubocop-rake' + RUBY + end + + before do + create_file('Gemfile.lock', <<~TEXT) + GEM + remote: https://rubygems.org/ + specs: + rake (13.0.1) + rspec (3.9.0) + rspec-core (~> 3.9.0) + rspec-core (3.9.3) + rubocop-rake (0.5.1) + rubocop-rspec (2.0.1) + + DEPENDENCIES + rake (~> 13.0) + rspec (~> 3.7) + rubocop-rake (~> 0.5) + rubocop-rspec (~> 2.0.0) + TEXT + end + + it 'does not show the load suggestion' do + expect do + cli.run(['example.rb']) + end.to suggest_extensions.to_load('rubocop-rake', 'rubocop-rspec') + end + end + + context 'that some are dependencies' do + let(:gemfile) do + create_file('Gemfile', <<~RUBY) + gem 'rspec' + gem 'rake' + gem 'rubocop-rake' + RUBY + end + + before do + create_file('Gemfile.lock', <<~TEXT) + GEM + remote: https://rubygems.org/ + specs: + rake (13.0.1) + rspec (3.9.0) + rspec-core (~> 3.9.0) + rspec-core (3.9.3) + rubocop-rake (0.5.1) + + DEPENDENCIES + rake (~> 13.0) + rspec (~> 3.7) + rubocop-rake (~> 0.5) + TEXT + end + + it 'only suggests unused gems' do + expect do + cli.run(['example.rb']) + end.to suggest_extensions.to_install('rubocop-rspec').to_load('rubocop-rake') + end + end + + context 'that are added by dependencies' do + let(:lockfile) do + create_file('Gemfile.lock', <<~TEXT) + GEM + specs: + rake (13.0.1) + rspec (3.9.0) + shared-gem (1.0.0) + rubocop-rake (0.5.1) + rubocop-rspec (2.0.1) + + DEPENDENCIES + rake (~> 13.0) + rspec (~> 3.7) + shared-gem (~> 1.0.0) + TEXT + end + + it 'does not show the suggestion' do + expect do + cli.run(['example.rb']) + end.to suggest_extensions.to_load('rubocop-rake', 'rubocop-rspec') + end + end + + context 'that are dependencies and required in config' do + let(:lockfile) do + create_file('Gemfile.lock', <<~TEXT) + GEM + remote: https://rubygems.org/ + specs: + rake (13.0.1) + rspec (3.9.0) + rspec-core (~> 3.9.0) + rspec-core (3.9.3) + rubocop-rake (0.5.1) + rubocop-rspec (2.0.1) + + DEPENDENCIES + rake (~> 13.0) + rspec (~> 3.7) + rubocop-rake (~> 0.5) + rubocop-rspec (~> 2.0.0) + TEXT + end + + let(:gemfile) do + create_file('Gemfile', <<~RUBY) + gem 'rspec' + gem 'rake' + gem 'rubocop-rspec' + gem 'rubocop-rake' + RUBY + end + + let(:loaded_features) { %w[rubocop-rspec rubocop-rake] } + + it 'does not show the suggestion' do + expect { cli.run(['example.rb']) }.not_to suggest_extensions + end + end + end + + context 'when gems with suggestions are not primary dependencies' do + let(:lockfile) do + create_file('Gemfile.lock', <<~LOCKFILE) + GEM + specs: + shared-gem (1.0.0) + rake (13.0.1) + rspec (3.9.0) + + PLATFORMS + ruby + + DEPENDENCIES + shared-gem (~> 1.0) + LOCKFILE + end + + it 'does not show the suggestion' do + expect { cli.run(['example.rb']) }.not_to suggest_extensions + end + end + + context 'when there are multiple gems loaded that have the same suggestion' do + let(:lockfile) do + create_file('Gemfile.lock', <<~LOCKFILE) + GEM + specs: + rspec (3.9.0) + rspec-rails (4.0.1) + + PLATFORMS + ruby + + DEPENDENCIES + rspec (~> 3.9) + rspec-rails (~> 4.0) + LOCKFILE + end + + it 'shows the suggestion' do + expect { cli.run(['example.rb']) }.to suggest_extensions.to_install('rubocop-rspec') + end + end + + context 'with AllCops/SuggestExtensions: false' do + before do + create_file('.rubocop.yml', <<~YAML) + AllCops: + SuggestExtensions: false + YAML + end + + it 'does not show the suggestion' do + expect { cli.run(['example.rb']) }.not_to suggest_extensions + end + end + + context 'when an extension is disabled in AllCops/SuggestExtensions' do + before do + create_file('.rubocop.yml', <<~YAML) + AllCops: + SuggestExtensions: + rubocop-rake: false + YAML + end + + it 'show the suggestion for non-disabled extensions' do + expect { cli.run(['example.rb']) }.to suggest_extensions.to_install('rubocop-rspec') + end + end + + context 'when in CI mode' do + before { allow(ENV).to receive(:fetch).with('CI', nil).and_return(true) } + + it 'does not show the suggestion' do + expect { cli.run(['example.rb']) }.not_to suggest_extensions + end + end + + context 'when given --only' do + it 'does not show the suggestion' do + expect { cli.run(['example.rb', '--only', 'Style/Alias']) }.not_to suggest_extensions + end + end + + context 'when given --debug' do + it 'does not show the suggestion' do + expect { cli.run(['example.rb', '--debug']) }.not_to suggest_extensions + end + end + + context 'when given --list-target-files' do + it 'does not show the suggestion' do + expect { cli.run(['example.rb', '--list-target-files']) }.not_to suggest_extensions + end + end + + context 'when given --out' do + it 'does not show the suggestion' do + expect { cli.run(['example.rb', '--out', 'output.txt']) }.not_to suggest_extensions + end + end + + context 'when given --stdin' do + it 'does not show the suggestion' do + $stdin = StringIO.new('p $/') + expect { cli.run(['--stdin', 'example.rb']) }.not_to suggest_extensions + ensure + $stdin = STDIN + end + end + + context 'when given a non-supported formatter' do + it 'does not show the suggestion' do + expect { cli.run(['example.rb', '--format', 'simple']) }.not_to suggest_extensions + end + end + + context 'when given an invalid path' do + it 'does not show the suggestion' do + expect { cli.run(['example1.rb']) }.not_to suggest_extensions + end + end + end +end diff --git a/spec/rubocop/cli_spec.rb b/spec/rubocop/cli_spec.rb index ed2c41b531a..0ba13595972 100644 --- a/spec/rubocop/cli_spec.rb +++ b/spec/rubocop/cli_spec.rb @@ -1811,309 +1811,6 @@ def method(foo, bar, qux, fred, arg5, f) end #{'#' * 85} end end - describe 'extension suggestions' do - matcher :suggest_extensions do |*extensions| - supports_block_expectations - attr_accessor :suggested - - def suggestion_regex - Regexp.new(<<~REGEXP, Regexp::MULTILINE).freeze - Tip: Based on detected gems, the following RuboCop extension libraries might be helpful: - (?.*) - REGEXP - end - - def find_suggestions - actual.call - suggestions = (suggestion_regex.match($stdout.string) || {})[:suggestions] - self.suggested = suggestions ? suggestions.scan(/(?<=\* )[a-z0-9_-]+\b/.freeze) : [] - end - - match do - find_suggestions - suggested == extensions - end - - match_when_negated do - find_suggestions - suggested.none? - end - - failure_message do - "expected to suggest extensions [#{extensions.join(', ')}], " \ - "but got [#{suggested.join(', ')}]" - end - - failure_message_when_negated do - "expected to not suggest extensions, but got [#{suggested.join(', ')}]" - end - end - - let(:lockfile) do - create_file('Gemfile.lock', <<~LOCKFILE) - GEM - specs: - rake (13.0.1) - rspec (3.9.0) - - PLATFORMS - ruby - - DEPENDENCIES - rake (~> 13.0) - rspec (~> 3.7) - LOCKFILE - end - - before do - create_file('example.rb', <<~RUBY) - # frozen_string_literal: true - - puts 'ok' - RUBY - - # Ensure that these specs works in CI, since the feature is generally - # disabled in when ENV['CI'] is set. - allow(ENV).to receive(:fetch).and_call_original - allow(ENV).to receive(:fetch).with('CI', nil).and_return(false) - - # Mock the lockfile to be parsed by bundler - allow(Bundler).to receive(:default_lockfile) - .and_return(lockfile ? Pathname.new(lockfile) : nil) - end - - context 'when bundler is not loaded' do - before { hide_const('Bundler') } - - it 'does not show the suggestion' do - expect { cli.run(['example.rb']) }.not_to suggest_extensions - expect($stderr.string.blank?).to be(true) - end - end - - context 'when there are gems to suggest' do - context 'that are not loaded' do - it 'shows the suggestion' do - expect { cli.run(['example.rb']) }.to suggest_extensions('rubocop-rake', 'rubocop-rspec') - end - end - - context 'that are dependencies' do - let(:gemfile) do - create_file('Gemfile', <<~RUBY) - gem 'rspec' - gem 'rake' - gem 'rubocop-rspec' - gem 'rubocop-rake' - RUBY - end - - before do - create_file('Gemfile.lock', <<~TEXT) - GEM - remote: https://rubygems.org/ - specs: - rake (13.0.1) - rspec (3.9.0) - rspec-core (~> 3.9.0) - rspec-core (3.9.3) - rubocop-rake (0.5.1) - rubocop-rspec (2.0.1) - - DEPENDENCIES - rake (~> 13.0) - rspec (~> 3.7) - rubocop-rake (~> 0.5) - rubocop-rspec (~> 2.0.0) - TEXT - end - - it 'does not show the suggestion' do - expect { cli.run(['example.rb']) }.not_to suggest_extensions - end - end - - context 'that some are dependencies' do - let(:gemfile) do - create_file('Gemfile', <<~RUBY) - gem 'rspec' - gem 'rake' - gem 'rubocop-rake' - RUBY - end - - before do - create_file('Gemfile.lock', <<~TEXT) - GEM - remote: https://rubygems.org/ - specs: - rake (13.0.1) - rspec (3.9.0) - rspec-core (~> 3.9.0) - rspec-core (3.9.3) - rubocop-rake (0.5.1) - - DEPENDENCIES - rake (~> 13.0) - rspec (~> 3.7) - rubocop-rake (~> 0.5) - TEXT - end - - it 'only suggests unused gems' do - expect { cli.run(['example.rb']) }.to suggest_extensions('rubocop-rspec') - end - end - - context 'that are added by dependencies' do - let(:lockfile) do - create_file('Gemfile.lock', <<~TEXT) - GEM - specs: - rake (13.0.1) - rspec (3.9.0) - shared-gem (1.0.0) - rubocop-rake (0.5.1) - rubocop-rspec (2.0.1) - - DEPENDENCIES - rake (~> 13.0) - rspec (~> 3.7) - shared-gem (~> 1.0.0) - TEXT - end - - it 'does not show the suggestion' do - expect { cli.run(['example.rb']) }.not_to suggest_extensions - end - end - end - - context 'when gems with suggestions are not primary dependencies' do - let(:lockfile) do - create_file('Gemfile.lock', <<~LOCKFILE) - GEM - specs: - shared-gem (1.0.0) - rake (13.0.1) - rspec (3.9.0) - - PLATFORMS - ruby - - DEPENDENCIES - shared-gem (~> 1.0) - LOCKFILE - end - - it 'does not show the suggestion' do - expect { cli.run(['example.rb']) }.not_to suggest_extensions - end - end - - context 'when there are multiple gems loaded that have the same suggestion' do - let(:lockfile) do - create_file('Gemfile.lock', <<~LOCKFILE) - GEM - specs: - rspec (3.9.0) - rspec-rails (4.0.1) - - PLATFORMS - ruby - - DEPENDENCIES - rspec (~> 3.9) - rspec-rails (~> 4.0) - LOCKFILE - end - - it 'shows the suggestion' do - expect { cli.run(['example.rb']) }.to suggest_extensions('rubocop-rspec') - end - end - - context 'with AllCops/SuggestExtensions: false' do - before do - create_file('.rubocop.yml', <<~YAML) - AllCops: - SuggestExtensions: false - YAML - end - - it 'does not show the suggestion' do - expect { cli.run(['example.rb']) }.not_to suggest_extensions - end - end - - context 'when an extension is disabled in AllCops/SuggestExtensions' do - before do - create_file('.rubocop.yml', <<~YAML) - AllCops: - SuggestExtensions: - rubocop-rake: false - YAML - end - - it 'show the suggestion for non-disabled extensions' do - expect { cli.run(['example.rb']) }.to suggest_extensions('rubocop-rspec') - end - end - - context 'when in CI mode' do - before { allow(ENV).to receive(:fetch).with('CI', nil).and_return(true) } - - it 'does not show the suggestion' do - expect { cli.run(['example.rb']) }.not_to suggest_extensions - end - end - - context 'when given --only' do - it 'does not show the suggestion' do - expect { cli.run(['example.rb', '--only', 'Style/Alias']) }.not_to suggest_extensions - end - end - - context 'when given --debug' do - it 'does not show the suggestion' do - expect { cli.run(['example.rb', '--debug']) }.not_to suggest_extensions - end - end - - context 'when given --list-target-files' do - it 'does not show the suggestion' do - expect { cli.run(['example.rb', '--list-target-files']) }.not_to suggest_extensions - end - end - - context 'when given --out' do - it 'does not show the suggestion' do - expect { cli.run(['example.rb', '--out', 'output.txt']) }.not_to suggest_extensions - end - end - - context 'when given --stdin' do - it 'does not show the suggestion' do - $stdin = StringIO.new('p $/') - expect { cli.run(['--stdin', 'example.rb']) }.not_to suggest_extensions - ensure - $stdin = STDIN - end - end - - context 'when given a non-supported formatter' do - it 'does not show the suggestion' do - expect { cli.run(['example.rb', '--format', 'simple']) }.not_to suggest_extensions - end - end - - context 'when given an invalid path' do - it 'does not show the suggestion' do - expect { cli.run(['example1.rb']) }.not_to suggest_extensions - end - end - end - describe 'info severity' do let(:code) do <<~RUBY