Skip to content
Merged
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], [@bquorning])

## 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"
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)
Copy link
Member

Choose a reason for hiding this comment

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

Cop_config may differ across directories.
Does it make sense to cache the preparations?

Copy link
Collaborator

Choose a reason for hiding this comment

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

Corson and I talked about this a couple of times earlier. The main problem is that loaded inflections are stored globally, in ActiveSupport::Inflector::Inflections.instance. So it would be very hard to separate the configurations, even if we tried.

I think that for most use cases, people will only have one inflection configuration, so it’s not a problem. But perhaps we should document the issue for those who may run into the issue in the future?

Copy link
Member

Choose a reason for hiding this comment

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

Totally reasonable. Even though the anxiety of having config race conditions never left me, we haven’t seen any real reports for … five tears?

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
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)
Copy link
Member

Choose a reason for hiding this comment

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

In addition to the excessive caching across directories, there can be another problem - multiple threads. RuboCop can run in threads? Or are those processes?

In any case, can we init the inflector once per inspection?

Or this would be too expensive if the inflection file is large?

I can think of a monorepo with many engines each with its own set of inflections

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