Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- Fix a false positive for `RSpec/ReceiveNever` cop when `allow(...).to receive(...).never`. ([@ydah])
- Fix detection of nameless doubles with methods in `RSpec/VerifiedDoubles`. ([@ushi-as])
- Improve an offense message for `RSpec/RepeatedExample` cop. ([@ydah])
- Let `RSpec/SpecFilePathFormat` leverage ActiveSupport inflections when configured. ([@corsonknowles])

## 3.7.0 (2025-09-01)

Expand Down
6 changes: 6 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -939,7 +939,13 @@ RSpec/SpecFilePathFormat:
IgnoreMethods: false
IgnoreMetadata:
type: routing
InflectorPath: "./config/initializers/inflections.rb"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

most should never need to set this, but just in case

SupportedInflectors:
- default
- active_support
EnforcedInflector: default
VersionAdded: '2.24'
VersionChanged: "<<next>>"
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/SpecFilePathFormat

RSpec/SpecFilePathSuffix:
Expand Down
21 changes: 20 additions & 1 deletion docs/modules/ROOT/pages/cops_rspec.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -6015,7 +6015,7 @@ context 'Something', :z, variable, :a, :b
| Yes
| No
| 2.24
| -
| <<next>>
|===

Checks that spec file paths are consistent and well-formed.
Expand Down Expand Up @@ -6072,6 +6072,17 @@ my_class_spec.rb # describe MyClass, '#method'
whatever_spec.rb # describe MyClass, type: :routing do; end
----

[#_enforcedinflector_-active_support_-rspecspecfilepathformat]
==== `EnforcedInflector: active_support`

[source,ruby]
----
# Enable to use ActiveSupport's inflector for custom acronyms
# like HTTP, etc. Set to "default" by default.
# Configure `InflectorPath` with the path to the inflector file.
# The default is ./config/initializers/inflections.rb.
----

[#configurable-attributes-rspecspecfilepathformat]
=== Configurable attributes

Expand All @@ -6097,6 +6108,14 @@ whatever_spec.rb # describe MyClass, type: :routing do; end
| IgnoreMetadata
| `{"type" => "routing"}`
|

| InflectorPath
| `./config/initializers/inflections.rb`
| String

| EnforcedInflector
| `default`
| `<none>`
|===

[#references-rspecspecfilepathformat]
Expand Down
58 changes: 54 additions & 4 deletions lib/rubocop/cop/rspec/spec_file_path_format.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ module RSpec
# # good
# whatever_spec.rb # describe MyClass, type: :routing do; end
#
# @example `EnforcedInflector: active_support`
# # Enable to use ActiveSupport's inflector for custom acronyms
# # like HTTP, etc. Set to "default" by default.
# # Configure `InflectorPath` with the path to the inflector file.
# # The default is ./config/initializers/inflections.rb.
#
class SpecFilePathFormat < Base
include TopLevelGroup
include Namespace
Expand Down Expand Up @@ -59,6 +65,53 @@ def on_top_level_example_group(node)

private

# Inflector module that uses ActiveSupport for advanced inflection rules
module ActiveSupportInflector
def self.call(string)
ActiveSupport::Inflector.underscore(string)
end

def self.prepare_availability(config)
return if @prepared

@prepared = true

inflector_path = config.fetch('InflectorPath')

unless File.exist?(inflector_path)
raise "The configured `InflectorPath` #{inflector_path} does " \
'not exist.'
end

require 'active_support/inflector'
require inflector_path
Copy link
Collaborator

Choose a reason for hiding this comment

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

I commented on Corson’s code, and now I can add the same comment on my own code 😅 It is unlikely, but not impossible, that different folders in a project use different inflector paths. We cannot blindly use the same ActiveSupportInflector instance for all, we need to use different ones per cop_config – and probably memoize the result somewhere.

Copy link
Collaborator

@bquorning bquorning Oct 20, 2025

Choose a reason for hiding this comment

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

Hmm, the file in inflector_path is supposed to call ActiveSupport::Inflector.inflections with a block, which will configure global state

https://api.rubyonrails.org/classes/ActiveSupport/Inflector.html#method-i-inflections says

Yields a singleton instance of Inflector::Inflections so you can specify additional inflector rules.

So I am not sure how we would handle multiple inflector paths 🤷🏼 Ideas are welcome.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

So I am not sure how we would handle multiple inflector paths 🤷🏼 Ideas are welcome.

If it helps, I think the ActiveSupport inflector is in one of the most stable possible states software can be in --- it's not deprecated and doesn't look like it will ever be deprecated, and it's essentially Open-Closed -- Closed for Modification (to prevent breaking any legacy apps or forcing upgrades) and Open for Extension.

With that context, I wonder if we have an example of a real world code base that needs to support multiple inflector paths? Because all I've ever seen is that the authoritative path is used, or possibly additional files are used byt they are included into it.

I have not seen separate nesting across an application --- which I guess could happen in the case of engines being included in the same repo that don't all funnel their inflection into the base app (which I believe would be a choice folks would not necessarily need to stick to). Is that what we have in mind?

I guess what I'm suggesting rather than a technical solution is that we ship the 99.99% use case, comment and document it as needed, and let someone reach out if they want to support even further customization.

Now, that goes out the window I guess if we have a real world use case in hand to work against right now, but it would help to know the details of that example to solve for it.

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
case cop_config.fetch('EnforcedInflector')
when 'active_support'
ActiveSupportInflector.prepare_availability(cop_config)
ActiveSupportInflector
when 'default'
DefaultInflector
else
# :nocov:
:noop
# :nocov:
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)
Expand Down Expand Up @@ -106,10 +159,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
Expand Down
138 changes: 138 additions & 0 deletions spec/rubocop/cop/rspec/spec_file_path_format_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -281,4 +281,142 @@ 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|
reset_activesupport_cache!
example.run
reset_activesupport_cache!
end

def reset_activesupport_cache!
described_class::ActiveSupportInflector.instance_variable_set(
:@prepared, nil
)
end

let(:cop_config) do
{
'EnforcedInflector' => 'active_support',
'InflectorPath' => './config/initializers/inflections.rb'
}
end

context 'when ActiveSupport inflections are available' do
before do
allow(File).to receive(:exist?)
.with(cop_config['InflectorPath']).and_return(true)

allow(described_class::ActiveSupportInflector).to receive(:require)
.with('active_support/inflector')
stub_const('ActiveSupport::Inflector',
Module.new { def self.underscore(_); end })

allow(described_class::ActiveSupportInflector).to receive(:require)
.with('./config/initializers/inflections.rb')
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

describe 'errors during preparation' do
it 'shows an error when the configured inflector file does not exist' do
allow(File).to receive(:exist?)
.with(cop_config['InflectorPath']).and_return(false)

expect do
inspect_source('describe PvPClass do; end', 'pv_p_class_spec.rb')
end.to raise_error('The configured `InflectorPath` ./config' \
'/initializers/inflections.rb does not exist.')
end

it 'lets LoadError pass all the way up when ActiveSupport loading ' \
'raises an error' do
allow(File).to receive(:exist?)
.with(cop_config['InflectorPath']).and_return(true)

allow(described_class::ActiveSupportInflector).to receive(:require)
.with('active_support/inflector').and_raise(LoadError)

expect do
inspect_source('describe PvPClass do; end', 'pv_p_class_spec.rb')
end.to raise_error(LoadError)
end
end

context 'when testing custom InflectorPath configuration precedence' do
let(:cop_config) do
{
'EnforcedInflector' => 'active_support',
'InflectorPath' => '/custom/path/to/inflections.rb'
}
end

before do
allow(File).to receive(:exist?).and_call_original
# Ensure default path is not checked when custom path is configured
allow(File).to receive(:exist?)
.with('./config/initializers/inflections.rb').and_return(false)
allow(File).to receive(:exist?)
.with(cop_config['InflectorPath']).and_return(true)

allow(described_class::ActiveSupportInflector).to receive(:require)
.with('active_support/inflector')
stub_const('ActiveSupport::Inflector',
Module.new { def self.underscore(_); end })

allow(described_class::ActiveSupportInflector).to receive(:require)
.with(cop_config['InflectorPath'])
allow(ActiveSupport::Inflector).to receive(:underscore)
.and_return('')
end

it 'reads the InflectorPath configuration correctly and does not ' \
'fall back to the default inflector path', :aggregate_failures 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')
expect(File).not_to have_received(:exist?)
.with('./config/initializers/inflections.rb')
end
end
end
# rubocop:enable RSpec/NestedGroups
end