From 0a308f6500ba5eff5a630fffd30e20ad5b8298bd Mon Sep 17 00:00:00 2001
From: Dave Corson-Knowles <david.corsonknowles@gusto.com>
Date: Mon, 2 Jun 2025 16:34:05 -0700
Subject: [PATCH] Let `RSpec/SpecFilePathFormat` leverage ActiveSupport
 inflections when defined and configured

Fix #740
---
 CHANGELOG.md                                  |   1 +
 config/default.yml                            |   2 +
 docs/modules/ROOT/pages/cops_rspec.adoc       |  19 ++
 .../cop/rspec/spec_file_path_format.rb        |  70 ++++-
 .../cop/rspec/spec_file_path_format_spec.rb   | 254 ++++++++++++++++++
 5 files changed, 342 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d7652184..a43f4a5ba 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 - Mark `RSpec/IncludeExamples` as `SafeAutoCorrect: false`. ([@yujideveloper])
 - Fix a false positive for `RSpec/LeakyConstantDeclaration` when defining constants in explicit namespaces. ([@naveg])
+- 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