From f59111ff856e017fcf1f4b83c7c2228415b18307 Mon Sep 17 00:00:00 2001 From: Ryo Nakamura Date: Fri, 29 Jul 2022 15:22:55 +0900 Subject: [PATCH] Support Bundler-like namespaced feature on require config --- ...e_support_namespaced_feature_on_require.md | 1 + docs/modules/ROOT/pages/extensions.adoc | 3 + lib/rubocop.rb | 1 + lib/rubocop/config_loader_resolver.rb | 6 +- lib/rubocop/feature_loader.rb | 88 +++++++++++++++++ spec/rubocop/feature_loader_spec.rb | 97 +++++++++++++++++++ 6 files changed, 191 insertions(+), 5 deletions(-) create mode 100644 changelog/change_support_namespaced_feature_on_require.md create mode 100644 lib/rubocop/feature_loader.rb create mode 100644 spec/rubocop/feature_loader_spec.rb diff --git a/changelog/change_support_namespaced_feature_on_require.md b/changelog/change_support_namespaced_feature_on_require.md new file mode 100644 index 00000000000..7e48ca4acfb --- /dev/null +++ b/changelog/change_support_namespaced_feature_on_require.md @@ -0,0 +1 @@ +* [#10845](https://github.com/rubocop/rubocop/pull/10845): Support Bundler-like namespaced feature on require config. ([@r7kamura][]) diff --git a/docs/modules/ROOT/pages/extensions.adoc b/docs/modules/ROOT/pages/extensions.adoc index 25e88a59857..62b80cf6b3d 100644 --- a/docs/modules/ROOT/pages/extensions.adoc +++ b/docs/modules/ROOT/pages/extensions.adoc @@ -19,6 +19,9 @@ NOTE: The paths are directly passed to `Kernel.require`. If your extension file is not in `$LOAD_PATH`, you need to specify the path as relative path prefixed with `./` explicitly or absolute path. Paths starting with a `.` are resolved relative to `.rubocop.yml`. +If a path containing `-` is given, it will be used as is, but if we +cannot find the file to load, we will replace `-` with `/` and try it +again as when Bundler loads gems. == Extension Suggestions diff --git a/lib/rubocop.rb b/lib/rubocop.rb index 19eadb9ee1e..378f6049d21 100644 --- a/lib/rubocop.rb +++ b/lib/rubocop.rb @@ -690,6 +690,7 @@ require_relative 'rubocop/config_obsoletion' require_relative 'rubocop/config_store' require_relative 'rubocop/config_validator' +require_relative 'rubocop/feature_loader' require_relative 'rubocop/lockfile' require_relative 'rubocop/target_finder' require_relative 'rubocop/directive_comment' diff --git a/lib/rubocop/config_loader_resolver.rb b/lib/rubocop/config_loader_resolver.rb index 042afcf7677..fd8d9a9e20b 100644 --- a/lib/rubocop/config_loader_resolver.rb +++ b/lib/rubocop/config_loader_resolver.rb @@ -11,11 +11,7 @@ def resolve_requires(path, hash) config_dir = File.dirname(path) hash.delete('require').tap do |loaded_features| Array(loaded_features).each do |feature| - if feature.start_with?('.') - require(File.join(config_dir, feature)) - else - require(feature) - end + FeatureLoader.load(config_directory_path: config_dir, feature: feature) end end end diff --git a/lib/rubocop/feature_loader.rb b/lib/rubocop/feature_loader.rb new file mode 100644 index 00000000000..32317a18451 --- /dev/null +++ b/lib/rubocop/feature_loader.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module RuboCop + # This class handles loading files (a.k.a. features in Ruby) specified + # by `--require` command line option and `require` directive in the config. + # + # Normally, the given string is directly passed to `require`. If a string + # beginning with `.` is given, it is assumed to be relative to the given + # directory. + # + # If a string containing `-` is given, it will be used as is, but if we + # cannot find the file to load, we will replace `-` with `/` and try it + # again as when Bundler loads gems. + # + # @api private + class FeatureLoader + class << self + # @param [String] config_directory_path + # @param [String] feature + def load(config_directory_path:, feature:) + new(config_directory_path: config_directory_path, feature: feature).load + end + end + + # @param [String] config_directory_path + # @param [String] feature + def initialize(config_directory_path:, feature:) + @config_directory_path = config_directory_path + @feature = feature + end + + def load + ::Kernel.require(target) + rescue ::LoadError => e + raise if e.path != target + + begin + ::Kernel.require(namespaced_target) + rescue ::LoadError => error_for_namespaced_target + raise e if error_for_namespaced_target.path == namespaced_target + + raise error_for_namespaced_target + end + end + + private + + # @return [String] + def namespaced_feature + @feature.tr('-', '/') + end + + # @return [String] + def namespaced_target + if relative? + relative(namespaced_feature) + else + namespaced_feature + end + end + + # @param [String] + # @return [String] + def relative(feature) + ::File.join(@config_directory_path, feature) + end + + # @return [Boolean] + def relative? + @feature.start_with?('.') + end + + # @param [LoadError] error + # @return [Boolean] + def seems_cannot_load_such_file_error?(error) + error.path == target + end + + # @return [String] + def target + if relative? + relative(@feature) + else + @feature + end + end + end +end diff --git a/spec/rubocop/feature_loader_spec.rb b/spec/rubocop/feature_loader_spec.rb new file mode 100644 index 00000000000..4ac7cd102b1 --- /dev/null +++ b/spec/rubocop/feature_loader_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +RSpec.describe RuboCop::FeatureLoader do + describe '.load' do + subject(:load) do + described_class.load(config_directory_path: config_directory_path, feature: feature) + end + + let(:config_directory_path) do + 'path-to-config' + end + + let(:feature) do + 'feature' + end + + context 'with normally lodable feature' do + before do + allow(Kernel).to receive(:require) + end + + it 'loads it normally' do + expect(Kernel).to receive(:require).with('feature') + load + end + end + + context 'with dot-prefixed lodable feature' do + before do + allow(Kernel).to receive(:require) + end + + let(:feature) do + './path/to/feature' + end + + it 'loads it as relative path' do + expect(Kernel).to receive(:require).with('path-to-config/./path/to/feature') + load + end + end + + context 'with namespaced feature' do + before do + allow(Kernel).to receive(:require).with('feature-foo').and_call_original + allow(Kernel).to receive(:require).with('feature/foo') + end + + let(:feature) do + 'feature-foo' + end + + it 'loads it as namespaced feature' do + expect(Kernel).to receive(:require).with('feature/foo') + load + end + end + + context 'with dot-prefixed namespaced feature' do + before do + allow(Kernel).to receive(:require).with('path-to-config/./feature-foo').and_call_original + allow(Kernel).to receive(:require).with('path-to-config/./feature/foo') + end + + let(:feature) do + './feature-foo' + end + + it 'loads it as namespaced feature' do + expect(Kernel).to receive(:require).with('path-to-config/./feature/foo') + load + end + end + + context 'with unexpected LoadError from require' do + before do + allow(Kernel).to receive(:require).and_raise(LoadError) + end + + it 'raises LoadError' do + expect { load }.to raise_error(LoadError) + end + end + + context 'with unloadable namespaced feature' do + let(:feature) do + 'feature-foo' + end + + # In normal Ruby, the message starts with "cannot load such file", + # but in JRuby it seems to start with "no such file to load". + it 'raises LoadError with preferred message' do + expect { load }.to raise_error(LoadError, /feature-foo/) + end + end + end +end