Skip to content

Commit 922fa1e

Browse files
committed
Let RSpec/SpecFilePathFormat leverage ActiveSupport inflections, if defined.
Fix #740
1 parent b0fda47 commit 922fa1e

File tree

4 files changed

+318
-4
lines changed

4 files changed

+318
-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: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,20 @@ 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 PvP, HTTP, etc. Set to false by default.
38+
#
3539
class SpecFilePathFormat < Base
3640
include TopLevelGroup
3741
include Namespace
3842
include FileHelp
3943

4044
MSG = 'Spec path should end with `%<suffix>s`.'
4145

46+
# Class variable to cache ActiveSupport availability for performance
47+
@@activesupport_available = nil
48+
4249
# @!method example_group_arguments(node)
4350
def_node_matcher :example_group_arguments, <<~PATTERN
4451
(block $(send #rspec? #ExampleGroups.all $_ $...) ...)
@@ -106,10 +113,49 @@ def expected_path(constant)
106113
end
107114

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

115161
def custom_transform

spec/rubocop/cop/rspec/spec_file_path_format_spec.rb

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

0 commit comments

Comments
 (0)