diff --git a/CHANGELOG.md b/CHANGELOG.md index 60b8c446f..1d3c14064 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Master (Unreleased) - Mark `RSpec/IncludeExamples` as `SafeAutoCorrect: false`. ([@yujideveloper]) +- Let `RSpec/SpecFilePathFormat` leverage ActiveSupport inflections when configured. ([@corsonknowles]) ## 3.6.0 (2025-04-18) diff --git a/config/default.yml b/config/default.yml index ca2f05531..85e570118 100644 --- a/config/default.yml +++ b/config/default.yml @@ -926,6 +926,8 @@ RSpec/SpecFilePathFormat: IgnoreMethods: false IgnoreMetadata: type: routing + InflectorPath: "./config/initializers/inflections.rb" + UseActiveSupportInflections: false VersionAdded: '2.24' Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/SpecFilePathFormat diff --git a/docs/modules/ROOT/pages/cops_rspec.adoc b/docs/modules/ROOT/pages/cops_rspec.adoc index 40c7757ac..c4e73b0ed 100644 --- a/docs/modules/ROOT/pages/cops_rspec.adoc +++ b/docs/modules/ROOT/pages/cops_rspec.adoc @@ -5951,6 +5951,17 @@ my_class_spec.rb # describe MyClass, '#method' whatever_spec.rb # describe MyClass, type: :routing do; end ---- +[#_useactivesupportinflections_-true_-rspecspecfilepathformat] +==== `UseActiveSupportInflections: true` + +[source,ruby] +---- +# Enable to use ActiveSupport's inflector for custom acronyms +# like HTTP, etc. Set to false by default. +# The InflectorPath provides the path to the inflector file. +# The default is ./config/initializers/inflections.rb. +---- + [#configurable-attributes-rspecspecfilepathformat] === Configurable attributes @@ -5976,6 +5987,14 @@ whatever_spec.rb # describe MyClass, type: :routing do; end | IgnoreMetadata | `{"type" => "routing"}` | + +| InflectorPath +| `./config/initializers/inflections.rb` +| String + +| UseActiveSupportInflections +| `false` +| Boolean |=== [#references-rspecspecfilepathformat] diff --git a/lib/rubocop/cop/rspec/spec_file_path_format.rb b/lib/rubocop/cop/rspec/spec_file_path_format.rb index 21466c064..5a81c8776 100644 --- a/lib/rubocop/cop/rspec/spec_file_path_format.rb +++ b/lib/rubocop/cop/rspec/spec_file_path_format.rb @@ -32,6 +32,12 @@ module RSpec # # good # whatever_spec.rb # describe MyClass, type: :routing do; end # + # @example `UseActiveSupportInflections: true` + # # Enable to use ActiveSupport's inflector for custom acronyms + # # like HTTP, etc. Set to false by default. + # # The InflectorPath provides the path to the inflector file. + # # The default is ./config/initializers/inflections.rb. + # class SpecFilePathFormat < Base include TopLevelGroup include Namespace @@ -57,8 +63,67 @@ def on_top_level_example_group(node) end end + # For testing and debugging + def self.reset_activesupport_cache! + ActiveSupportInflector.reset_cache! + end + private + # Inflector module that uses ActiveSupport for advanced inflection rules + module ActiveSupportInflector + def self.call(string) + ActiveSupport::Inflector.underscore(string) + end + + def self.available?(cop_config) + return @available unless @available.nil? + + unless cop_config.fetch('UseActiveSupportInflections', false) + return @available = false + end + + unless File.exist?(inflector_path(cop_config)) + return @available = false + end + + @available = begin + require 'active_support/inflector' + require inflector_path(cop_config) + true + rescue LoadError, StandardError + false + end + end + + def self.inflector_path(cop_config) + cop_config.fetch('InflectorPath', + './config/initializers/inflections.rb') + end + + def self.reset_cache! + @available = nil + end + end + + # Inflector module that uses basic regex-based conversion + module DefaultInflector + def self.call(string) + string + .gsub(/([^A-Z])([A-Z]+)/, '\1_\2') + .gsub(/([A-Z])([A-Z][^A-Z\d]+)/, '\1_\2') + .downcase + end + end + + def inflector + @inflector ||= if ActiveSupportInflector.available?(cop_config) + ActiveSupportInflector + else + DefaultInflector + end + end + def ensure_correct_file_path(send_node, class_name, arguments) pattern = correct_path_pattern(class_name, arguments) return if filename_ends_with?(pattern) @@ -106,10 +171,7 @@ def expected_path(constant) end def camel_to_snake_case(string) - string - .gsub(/([^A-Z])([A-Z]+)/, '\1_\2') - .gsub(/([A-Z])([A-Z][^A-Z\d]+)/, '\1_\2') - .downcase + inflector.call(string) end def custom_transform diff --git a/spec/rubocop/cop/rspec/spec_file_path_format_spec.rb b/spec/rubocop/cop/rspec/spec_file_path_format_spec.rb index 92a36e78f..85a951818 100644 --- a/spec/rubocop/cop/rspec/spec_file_path_format_spec.rb +++ b/spec/rubocop/cop/rspec/spec_file_path_format_spec.rb @@ -281,4 +281,258 @@ class Foo RUBY end end + + # We intentionally isolate all of the plugin specs in this context + # rubocop:disable RSpec/NestedGroups + context 'when using ActiveSupport integration' do + around do |example| + described_class.reset_activesupport_cache! + example.run + described_class.reset_activesupport_cache! + end + + before do + # We cannot verify this double because it's not a Rubocop dependency + # rubocop:disable RSpec/RSpec/VerifiedDoubles + inflector = double('ActiveSupport::Inflector') + # rubocop:enable RSpec/RSpec/VerifiedDoubles + stub_const('ActiveSupport::Inflector', inflector) + + allow(File).to receive(:exist?) + .with(cop_config['InflectorPath']).and_return(file_exists) + end + + let(:file_exists) { true } + let(:cop_config) do + { + 'UseActiveSupportInflections' => true, + 'InflectorPath' => './config/initializers/inflections.rb' + } + end + + context 'when ActiveSupport inflections are available' do + before do + allow(described_class::ActiveSupportInflector) + .to receive(:available?) + .and_return(true) + allow(described_class).to receive(:require) + .with('./config/initializers/inflections.rb').and_return(true) + + allow(ActiveSupport::Inflector).to receive(:underscore) + .with('PvPClass').and_return('pvp_class') + allow(ActiveSupport::Inflector).to receive(:underscore) + .with('HTTPClient').and_return('http_client') + allow(ActiveSupport::Inflector).to receive(:underscore) + .with('HTTPSClient').and_return('https_client') + allow(ActiveSupport::Inflector).to receive(:underscore) + .with('API').and_return('api') + end + + it 'uses ActiveSupport inflections for custom acronyms' do + expect_no_offenses(<<~RUBY, 'pvp_class_spec.rb') + describe PvPClass do; end + RUBY + end + + it 'registers an offense when ActiveSupport inflections ' \ + 'suggest different path' do + expect_offense(<<~RUBY, 'pv_p_class_spec.rb') + describe PvPClass do; end + ^^^^^^^^^^^^^^^^^ Spec path should end with `pvp_class*_spec.rb`. + RUBY + end + + it 'does not register complex acronyms with method names' do + expect_no_offenses(<<~RUBY, 'pvp_class_foo_spec.rb') + describe PvPClass, 'foo' do; end + RUBY + end + + it 'does not register nested namespaces with custom acronyms' do + expect_no_offenses(<<~RUBY, 'api/http_client_spec.rb') + describe API::HTTPClient do; end + RUBY + end + end + + context 'when ActiveSupport loading raises an error' do + let(:cop_config) do + { + 'UseActiveSupportInflections' => true, + 'InflectorPath' => './config/initializers/inflections.rb' + } + end + + it 'returns false from available? when ActiveSupport cannot be loaded' do + result = described_class::ActiveSupportInflector.available?(cop_config) + expect(result).to be false + end + + it 'gracefully falls back to default behavior' do + expect_no_offenses(<<~RUBY, 'pv_p_class_spec.rb') + describe PvPClass do; end + RUBY + + expect_offense(<<~RUBY, 'pvp_class_spec.rb') + describe PvPClass do; end + ^^^^^^^^^^^^^^^^^ Spec path should end with `pv_p_class*_spec.rb`. + RUBY + end + end + + context 'when configured with custom InflectorPath' do + let(:cop_config) do + { + 'UseActiveSupportInflections' => true, + 'InflectorPath' => './config/custom_inflections.rb' + } + end + + context 'when inflector file exists' do + before do + allow(TOPLEVEL_BINDING.receiver).to receive(:require) + .and_call_original + allow(TOPLEVEL_BINDING.receiver).to receive(:require) + .with('active_support/inflector').and_return(true) + allow(TOPLEVEL_BINDING.receiver).to receive(:require) + .with(cop_config['InflectorPath']).and_return(true) + end + + it 'loads the custom inflector file when it exists' do + expect_no_offenses(<<~RUBY, 'https_client_spec.rb') + describe HTTPSClient do; end + RUBY + end + + it 'does not register with nested namespaces using ' \ + 'custom inflections' do + expect_no_offenses(<<~RUBY, 'api/https_client_spec.rb') + describe API::HTTPSClient do; end + RUBY + end + + it 'registers offense when path does not match custom inflections' do + expect_offense(<<~RUBY, 'http_s_client_spec.rb') + describe HTTPSClient do; end + ^^^^^^^^^^^^^^^^^^^^ Spec path should end with `https_client*_spec.rb`. + RUBY + end + end + + context 'when inflector file loading fails' do + before do + # Stub the global require method + allow(TOPLEVEL_BINDING.receiver).to receive(:require) + .and_call_original + allow(TOPLEVEL_BINDING.receiver).to receive(:require) + .with('./config/custom_inflections.rb') + .and_raise(LoadError, 'Cannot load file') + end + + it 'gracefully falls back and handles inflector file loading errors' do + expect_no_offenses(<<~RUBY, 'https_client_spec.rb') + describe HTTPSClient do; end + RUBY + end + end + + context 'when inflector file does not exist' do + let(:file_exists) { false } + + it 'falls back to camel_to_snake_case conversion ' \ + 'without ActiveSupport' do + expect_no_offenses(<<~RUBY, 'https_client_spec.rb') + describe HTTPSClient do; end + RUBY + end + end + end + + context 'when using default inflector path' do + before do + allow(described_class::ActiveSupportInflector) + .to receive(:available?) + .and_return(true) + end + + it 'uses default inflector path when not configured' do + allow(described_class::ActiveSupportInflector).to receive(:available?) + .and_call_original + + expect_no_offenses(<<~RUBY, 'http_client_spec.rb') + describe HTTPClient do; end + RUBY + + expect(File).to have_received(:exist?) + .with('./config/initializers/inflections.rb') + end + + context 'when inflector file does not exist' do + let(:file_exists) { false } + + it 'does not require default inflector file when it does not exist' do + allow(described_class::ActiveSupportInflector).to receive(:available?) + .and_call_original + + expect_no_offenses(<<~RUBY, 'http_client_spec.rb') + describe HTTPClient do; end + RUBY + end + end + end + + context 'when testing custom InflectorPath configuration precedence' do + let(:cop_config) do + { + 'UseActiveSupportInflections' => true, + 'InflectorPath' => '/custom/path/to/inflections.rb' + } + end + let(:file_exists) { false } + + before do + # Ensure default path is not checked when custom path is configured + allow(File).to receive(:exist?) + .with('./config/initializers/inflections.rb').and_return(false) + end + + it 'reads the InflectorPath configuration correctly and checks ' \ + 'for file existence' do + expect_no_offenses(<<~RUBY, 'http_client_spec.rb') + describe HTTPClient do; end + RUBY + + expect(File).to have_received(:exist?) + .with('/custom/path/to/inflections.rb') + end + + it 'reads the InflectorPath configuration correctly and does not ' \ + 'fall back to the default inflector path' do + expect_no_offenses(<<~RUBY, 'http_client_spec.rb') + describe HTTPClient do; end + RUBY + + expect(File).not_to have_received(:exist?) + .with('./config/initializers/inflections.rb') + end + end + + context 'when testing ActiveSupportInflector success path' do + before do + # Stub the require calls to succeed + allow(described_class::ActiveSupportInflector).to receive(:require) + .and_call_original + allow(described_class::ActiveSupportInflector).to receive(:require) + .with('active_support/inflector').and_return(true) + allow(described_class::ActiveSupportInflector).to receive(:require) + .with('./config/initializers/inflections.rb').and_return(true) + end + + it 'returns true when ActiveSupport is successfully loaded' do + result = described_class::ActiveSupportInflector.available?(cop_config) + expect(result).to be true + end + end + end + # rubocop:enable RSpec/NestedGroups end