Skip to content

Commit 49b0b1c

Browse files
committed
Let RSpec/SpecFilePathFormat leverage ActiveSupport inflections when defined and configured
Fix #740
1 parent b0fda47 commit 49b0b1c

File tree

4 files changed

+319
-4
lines changed

4 files changed

+319
-4
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Master (Unreleased)
44

5+
- Let `RSpec/SpecFilePathFormat` leverage ActiveSupport inflections, if defined. ([@corsonknowles])
6+
57
## 3.6.0 (2025-04-18)
68

79
- Fix false positive in `RSpec/Pending`, where it would mark the default block `it` as an offense. ([@bquorning])

config/default.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -924,6 +924,8 @@ RSpec/SpecFilePathFormat:
924924
IgnoreMethods: false
925925
IgnoreMetadata:
926926
type: routing
927+
Inflector: './config/initializers/inflections.rb'
928+
UseActiveSupportInflections: false
927929
VersionAdded: '2.24'
928930
Reference: https://www.rubydoc.info/gems/rubocop-rspec/RuboCop/Cop/RSpec/SpecFilePathFormat
929931

lib/rubocop/cop/rspec/spec_file_path_format.rb

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,22 @@ module RSpec
3232
# # good
3333
# whatever_spec.rb # describe MyClass, type: :routing do; end
3434
#
35+
# @example `UseActiveSupportInflections: true`
36+
# # When enabled, uses ActiveSupport's inflector for handling custom acronyms
37+
# # like HTTP, etc. Set to false by default.
38+
# # The InflectorPath is the path to the inflector file.
39+
# # The default is ./config/initializers/inflections.rb.
40+
#
3541
class SpecFilePathFormat < Base
3642
include TopLevelGroup
3743
include Namespace
3844
include FileHelp
3945

4046
MSG = 'Spec path should end with `%<suffix>s`.'
4147

48+
# Class variable to cache ActiveSupport availability for performance
49+
@@activesupport_available = nil
50+
4251
# @!method example_group_arguments(node)
4352
def_node_matcher :example_group_arguments, <<~PATTERN
4453
(block $(send #rspec? #ExampleGroups.all $_ $...) ...)
@@ -106,10 +115,49 @@ def expected_path(constant)
106115
end
107116

108117
def camel_to_snake_case(string)
109-
string
110-
.gsub(/([^A-Z])([A-Z]+)/, '\1_\2')
111-
.gsub(/([A-Z])([A-Z][^A-Z\d]+)/, '\1_\2')
112-
.downcase
118+
if use_activesupport_inflections? && activesupport_inflections_available?
119+
ActiveSupport::Inflector.underscore(string)
120+
else
121+
string
122+
.gsub(/([^A-Z])([A-Z]+)/, '\1_\2')
123+
.gsub(/([A-Z])([A-Z][^A-Z\d]+)/, '\1_\2')
124+
.downcase
125+
end
126+
end
127+
128+
def use_activesupport_inflections?
129+
cop_config.fetch('UseActiveSupportInflections', false)
130+
end
131+
132+
def inflector_path
133+
cop_config.fetch('InflectorPath',
134+
'./config/initializers/inflections.rb')
135+
end
136+
137+
def activesupport_inflections_available?
138+
return false unless use_activesupport_inflections?
139+
140+
self.class.activesupport_inflections_available?(inflector_path)
141+
end
142+
143+
def self.activesupport_inflections_available?(inflector_path = './config/initializers/inflections.rb')
144+
return @@activesupport_available unless @@activesupport_available.nil?
145+
146+
@@activesupport_available = begin
147+
return false unless File.exist?(inflector_path)
148+
149+
require 'active_support/inflector'
150+
require inflector_path
151+
152+
true
153+
rescue LoadError, NameError, StandardError
154+
false
155+
end
156+
end
157+
158+
# For testing and debugging
159+
def self.reset_activesupport_cache!
160+
@@activesupport_available = nil
113161
end
114162

115163
def custom_transform

spec/rubocop/cop/rspec/spec_file_path_format_spec.rb

Lines changed: 263 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,4 +281,267 @@ class Foo
281281
RUBY
282282
end
283283
end
284+
285+
context 'when ActiveSupport inflections are available' do
286+
let(:cop_config) { { 'UseActiveSupportInflections' => true } }
287+
288+
before do
289+
# Stub File.exist? to return true for the default inflector path
290+
allow(File).to receive(:exist?).with('./config/initializers/inflections.rb').and_return(true)
291+
292+
allow(described_class).to receive(:require).with('active_support/inflector').and_return(true)
293+
allow(described_class).to receive(:require).with('./config/initializers/inflections.rb').and_return(true)
294+
stub_const('ActiveSupport::Inflector', double('ActiveSupport::Inflector'))
295+
end
296+
297+
around do |example|
298+
described_class.reset_activesupport_cache!
299+
example.run
300+
described_class.reset_activesupport_cache!
301+
end
302+
303+
it 'uses ActiveSupport inflections for custom acronyms' do
304+
allow(ActiveSupport::Inflector).to receive(:underscore).with('PvPClass').and_return('pvp_class')
305+
306+
expect_no_offenses(<<~RUBY, 'pvp_class_spec.rb')
307+
describe PvPClass do; end
308+
RUBY
309+
end
310+
311+
it 'registers an offense when ActiveSupport inflections suggest different path' do
312+
allow(ActiveSupport::Inflector).to receive(:underscore).with('PvPClass').and_return('pvp_class')
313+
314+
expect_offense(<<~RUBY, 'pv_p_class_spec.rb')
315+
describe PvPClass do; end
316+
^^^^^^^^^^^^^^^^^ Spec path should end with `pvp_class*_spec.rb`.
317+
RUBY
318+
end
319+
320+
it 'does not register complex acronyms with method names' do
321+
allow(ActiveSupport::Inflector).to receive(:underscore).with('PvPClass').and_return('pvp_class')
322+
323+
expect_no_offenses(<<~RUBY, 'pvp_class_foo_spec.rb')
324+
describe PvPClass, 'foo' do; end
325+
RUBY
326+
end
327+
328+
it 'does not register nested namespaces with custom acronyms' do
329+
allow(ActiveSupport::Inflector).to receive(:underscore).with('API').and_return('api')
330+
allow(ActiveSupport::Inflector).to receive(:underscore).with('HTTPClient').and_return('http_client')
331+
332+
expect_no_offenses(<<~RUBY, 'api/http_client_spec.rb')
333+
describe API::HTTPClient do; end
334+
RUBY
335+
end
336+
end
337+
338+
context 'when ActiveSupport inflections are not available' do
339+
let(:cop_config) { { 'UseActiveSupportInflections' => true } }
340+
341+
before do
342+
# Stub File.exist? to return true, but ActiveSupport loading fails
343+
allow(File).to receive(:exist?).with('./config/initializers/inflections.rb').and_return(true)
344+
allow(described_class).to receive(:require).with('active_support/inflector').and_raise(LoadError)
345+
end
346+
347+
around do |example|
348+
described_class.reset_activesupport_cache!
349+
example.run
350+
described_class.reset_activesupport_cache!
351+
end
352+
353+
it 'falls back to default inflection behavior' do
354+
expect_no_offenses(<<~RUBY, 'pv_p_class_spec.rb')
355+
describe PvPClass do; end
356+
RUBY
357+
end
358+
359+
it 'registers offense when default inflection does not match' do
360+
expect_offense(<<~RUBY, 'pvp_class_spec.rb')
361+
describe PvPClass do; end
362+
^^^^^^^^^^^^^^^^^ Spec path should end with `pv_p_class*_spec.rb`.
363+
RUBY
364+
end
365+
end
366+
367+
context 'when ActiveSupport loading raises an error' do
368+
let(:cop_config) { { 'UseActiveSupportInflections' => true } }
369+
370+
before do
371+
# Stub File.exist? to return true, but ActiveSupport loading raises an error
372+
allow(File).to receive(:exist?).with('./config/initializers/inflections.rb').and_return(true)
373+
allow(described_class).to receive(:require).with('active_support/inflector').and_raise(
374+
StandardError, 'Something went wrong'
375+
)
376+
end
377+
378+
around do |example|
379+
described_class.reset_activesupport_cache!
380+
example.run
381+
described_class.reset_activesupport_cache!
382+
end
383+
384+
it 'gracefully falls back to default behavior' do
385+
expect_no_offenses(<<~RUBY, 'pv_p_class_spec.rb')
386+
describe PvPClass do; end
387+
RUBY
388+
end
389+
end
390+
391+
context 'when configured with custom InflectorPath' do
392+
let(:cop_config) do
393+
{
394+
'UseActiveSupportInflections' => true,
395+
'InflectorPath' => './config/custom_inflections.rb'
396+
}
397+
end
398+
399+
around do |example|
400+
described_class.reset_activesupport_cache!
401+
example.run
402+
described_class.reset_activesupport_cache!
403+
end
404+
405+
context 'when inflector file exists' do
406+
before do
407+
# Stub ActiveSupport availability
408+
allow(described_class).to receive(:require).with('active_support/inflector').and_return(true)
409+
stub_const('ActiveSupport::Inflector',
410+
double('ActiveSupport::Inflector'))
411+
412+
# Stub File.exist? to return true for our custom path
413+
allow(File).to receive(:exist?).with('./config/custom_inflections.rb').and_return(true)
414+
415+
# Stub the require call for the inflector file
416+
allow(described_class).to receive(:require).with('./config/custom_inflections.rb').and_return(true)
417+
418+
# Mock the inflector behavior with custom acronyms
419+
allow(ActiveSupport::Inflector).to receive(:underscore).with('HTTPSClient').and_return('https_client')
420+
allow(ActiveSupport::Inflector).to receive(:underscore).with('XMLParser').and_return('xml_parser')
421+
end
422+
423+
it 'loads the custom inflector file when it exists' do
424+
expect(File).to receive(:exist?).with('./config/custom_inflections.rb').and_return(true)
425+
expect(described_class).to receive(:require).with('./config/custom_inflections.rb')
426+
427+
expect_no_offenses(<<~RUBY, 'https_client_spec.rb')
428+
describe HTTPSClient do; end
429+
RUBY
430+
end
431+
432+
it 'uses custom inflections from the inflector file' do
433+
expect_no_offenses(<<~RUBY, 'https_client_spec.rb')
434+
describe HTTPSClient do; end
435+
RUBY
436+
end
437+
438+
it 'does not register with nested namespaces using custom inflections' do
439+
allow(ActiveSupport::Inflector).to receive(:underscore).with('API').and_return('api')
440+
441+
expect_no_offenses(<<~RUBY, 'api/https_client_spec.rb')
442+
describe API::HTTPSClient do; end
443+
RUBY
444+
end
445+
446+
it 'registers offense when path does not match custom inflections' do
447+
expect_offense(<<~RUBY, 'http_s_client_spec.rb')
448+
describe HTTPSClient do; end
449+
^^^^^^^^^^^^^^^^^^^^ Spec path should end with `https_client*_spec.rb`.
450+
RUBY
451+
end
452+
end
453+
454+
context 'when inflector file does not exist' do
455+
before do
456+
# Stub File.exist? to return false for our custom path
457+
allow(File).to receive(:exist?).with('./config/custom_inflections.rb').and_return(false)
458+
end
459+
460+
it 'does not try to require the inflector file when it does not exist' do
461+
expect(File).to receive(:exist?).with('./config/custom_inflections.rb').and_return(false)
462+
expect(described_class).not_to receive(:require).with('./config/custom_inflections.rb')
463+
expect(described_class).not_to receive(:require).with('active_support/inflector')
464+
465+
# Should use default regex-based conversion: HTTPSClient -> https_client
466+
expect_no_offenses(<<~RUBY, 'https_client_spec.rb')
467+
describe HTTPSClient do; end
468+
RUBY
469+
end
470+
471+
it 'falls back to default camel_to_snake_case conversion without ActiveSupport' do
472+
# Should use default regex-based conversion: HTTPSClient -> httpclient -> https_client
473+
expect_no_offenses(<<~RUBY, 'https_client_spec.rb')
474+
describe HTTPSClient do; end
475+
RUBY
476+
end
477+
end
478+
479+
context 'when inflector file loading fails' do
480+
before do
481+
# Stub ActiveSupport availability
482+
allow(described_class).to receive(:require).with('active_support/inflector').and_return(true)
483+
stub_const('ActiveSupport::Inflector',
484+
double('ActiveSupport::Inflector'))
485+
486+
# Stub File.exist? to return true but require to fail
487+
allow(File).to receive(:exist?).with('./config/custom_inflections.rb').and_return(true)
488+
allow(described_class).to receive(:require).with('./config/custom_inflections.rb').and_raise(
489+
LoadError, 'Cannot load file'
490+
)
491+
492+
# Set up fallback behavior
493+
allow(ActiveSupport::Inflector).to receive(:underscore).with('HTTPSClient').and_return('https_client')
494+
end
495+
496+
it 'gracefully handles inflector file loading errors' do
497+
expect(File).to receive(:exist?).with('./config/custom_inflections.rb').and_return(true)
498+
expect(described_class).to receive(:require).with('./config/custom_inflections.rb').and_raise(LoadError)
499+
500+
# Should still work with basic ActiveSupport functionality
501+
expect_no_offenses(<<~RUBY, 'https_client_spec.rb')
502+
describe HTTPSClient do; end
503+
RUBY
504+
end
505+
end
506+
end
507+
508+
context 'when UseActiveSupportInflections is enabled with default inflector path' do
509+
let(:cop_config) { { 'UseActiveSupportInflections' => true } }
510+
511+
around do |example|
512+
described_class.reset_activesupport_cache!
513+
example.run
514+
described_class.reset_activesupport_cache!
515+
end
516+
517+
before do
518+
# Stub ActiveSupport availability
519+
allow(described_class).to receive(:require).with('active_support/inflector').and_return(true)
520+
stub_const('ActiveSupport::Inflector', double('ActiveSupport::Inflector'))
521+
end
522+
523+
it 'uses default inflector path when not configured' do
524+
allow(File).to receive(:exist?).with('./config/initializers/inflections.rb').and_return(true)
525+
allow(described_class).to receive(:require).with('./config/initializers/inflections.rb').and_return(true)
526+
allow(ActiveSupport::Inflector).to receive(:underscore).with('HTTPClient').and_return('http_client')
527+
528+
expect(File).to receive(:exist?).with('./config/initializers/inflections.rb')
529+
530+
expect_no_offenses(<<~RUBY, 'http_client_spec.rb')
531+
describe HTTPClient do; end
532+
RUBY
533+
end
534+
535+
it 'does not require default inflector file when it does not exist' do
536+
allow(File).to receive(:exist?).with('./config/initializers/inflections.rb').and_return(false)
537+
allow(ActiveSupport::Inflector).to receive(:underscore).with('HTTPClient').and_return('http_client')
538+
539+
expect(File).to receive(:exist?).with('./config/initializers/inflections.rb').and_return(false)
540+
expect(described_class).not_to receive(:require).with('./config/initializers/inflections.rb')
541+
542+
expect_no_offenses(<<~RUBY, 'http_client_spec.rb')
543+
describe HTTPClient do; end
544+
RUBY
545+
end
546+
end
284547
end

0 commit comments

Comments
 (0)