Skip to content

Commit 9e7cf24

Browse files
committed
Add Rubocop::Cops::Rails::ZeitwerkFriendlyConstant
1 parent ada5c28 commit 9e7cf24

File tree

4 files changed

+422
-1
lines changed

4 files changed

+422
-1
lines changed

config/default.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1218,7 +1218,11 @@ Rails/WhereNotWithMultipleConditions:
12181218
Enabled: 'pending'
12191219
Severity: warning
12201220
VersionAdded: '2.17'
1221-
VersionChanged: '2.18'
1221+
1222+
Rails/ZeitwerkFriendlyConstant:
1223+
Description: 'Ensure all constants defined in each file are independently loadable by Zeitwerk.'
1224+
Enabled: 'pending'
1225+
VersionAdded: '<<next>>'
12221226

12231227
# Accept `redirect_to(...) and return` and similar cases.
12241228
Style/AndOr:
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# frozen_string_literal: true
2+
3+
module RuboCop
4+
module Cop
5+
module Rails
6+
# Ensures that every constant defined in a file matches the file name
7+
# such a way that it is independently loadable by Zeitwerk.
8+
#
9+
# @example
10+
#
11+
# Good
12+
#
13+
# # /some/directory/foo.rb
14+
# module Foo
15+
# end
16+
#
17+
# # /some/directory/foo.rb
18+
# module Foo
19+
# module Bar
20+
# end
21+
# end
22+
#
23+
# # /some/directory/foo/bar.rb
24+
# module Foo
25+
# module Bar
26+
# end
27+
# end
28+
#
29+
# Bad
30+
#
31+
# # /some/directory/foo.rb
32+
# module Bar
33+
# end
34+
#
35+
# # /some/directory/foo/bar.rb
36+
# module Foo
37+
# module Bar
38+
# end
39+
#
40+
# module Baz
41+
# end
42+
# end
43+
#
44+
class ZeitwerkFriendlyConstant < Base
45+
MSG = 'Constant name does not match filename.'
46+
CLASS_MESSAGE = 'Class name does not match filename.'
47+
MODULE_MESSAGE = 'Module name does not match filename.'
48+
INCOMPATIBLE_FILE_PATH_MESSAGE = 'Constant names are mutually incompatible with file path.'
49+
50+
CONSTANT_NAME_MATCHER = /\A[[:upper:]_]*\Z/.freeze
51+
CONSTANT_DEFINITION_TYPES = %i[module class casgn].freeze
52+
53+
def relevant_file?(file)
54+
super && (File.extname(file) == '.rb')
55+
end
56+
57+
def on_new_investigation
58+
return if processed_source.blank?
59+
60+
common_anchors = nil
61+
62+
each_nested_constant(processed_source.ast) do |node, nesting|
63+
anchors = nesting.anchors(path_segments)
64+
65+
if anchors.empty?
66+
add_offense(node, message: offense_message(node))
67+
else
68+
common_anchors ||= anchors
69+
70+
if (common_anchors &= anchors).empty?
71+
# Add an offense if there is no common anchor among constants.
72+
add_offense(node, message: INCOMPATIBLE_FILE_PATH_MESSAGE)
73+
end
74+
end
75+
end
76+
end
77+
78+
private
79+
80+
Nesting = Struct.new(:namespace) do
81+
def push(node)
82+
self.namespace += [node]
83+
@constants = nil
84+
end
85+
86+
def constants
87+
@constants ||= namespace.flat_map { |node| constant_name(node).split('::') }
88+
end
89+
90+
# For a nesting like ["Foo", "Bar"] and path segments ["", "Some",
91+
# "Dir", "Foo", "Bar"], return an array of all possible "anchors" of the
92+
# nesting within the segments, if any (in this case, [3]).
93+
def anchors(path_segments)
94+
(1..constants.length).each_with_object([]) do |i, anchors|
95+
anchors << i if path_segments[(path_segments.size - i)..] == constants[0, i]
96+
end
97+
end
98+
99+
def constant_name(node)
100+
if (defined_module = node.defined_module)
101+
defined_module.const_name
102+
else
103+
name = node.children[1].to_s
104+
name = name.split('_').map(&:capitalize!).join if CONSTANT_NAME_MATCHER.match?(name)
105+
name
106+
end
107+
end
108+
end
109+
# Traverse the AST from node and yield each constant, along with its
110+
# nesting: an array of class/module names within which it is defined.
111+
def each_nested_constant(node, nesting = Nesting.new([]), &block)
112+
nesting.push(node) if constant_definition?(node)
113+
114+
any_yielded = node.child_nodes.map do |child_node|
115+
each_nested_constant(child_node, nesting.dup, &block)
116+
end.any?
117+
118+
# We only yield "leaves", i.e. constants that have no other nested
119+
# constants within themselves. To do this we return true from this
120+
# method if it itself has yielded, and only yield from parents if all
121+
# recursive calls did not return true (i.e. they did not yield).
122+
if !any_yielded && constant_definition?(node)
123+
yield(node, nesting)
124+
true
125+
else
126+
any_yielded
127+
end
128+
end
129+
130+
def path_segments
131+
@path_segments ||= processed_source.file_path.delete_suffix('.rb').split('/').map! { |dir| camelize(dir) }
132+
end
133+
134+
def constant_definition?(node)
135+
CONSTANT_DEFINITION_TYPES.include?(node.type)
136+
end
137+
138+
def offense_message(node)
139+
case node.type
140+
when :module
141+
MODULE_MESSAGE
142+
when :class
143+
CLASS_MESSAGE
144+
end
145+
end
146+
147+
def camelize(path_segment)
148+
path_segment.split('_').map! do |segment|
149+
acronyms.key?(segment) ? acronyms[segment] : segment.capitalize
150+
end.join
151+
end
152+
153+
def acronyms
154+
@acronyms ||= cop_config['Acronyms'].to_h do |acronym|
155+
[acronym.downcase, acronym]
156+
end
157+
end
158+
end
159+
end
160+
end
161+
end

lib/rubocop/cop/rails_cops.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,4 @@
138138
require_relative 'rails/where_missing'
139139
require_relative 'rails/where_not'
140140
require_relative 'rails/where_not_with_multiple_conditions'
141+
require_relative 'rails/zeitwerk_friendly_constant'

0 commit comments

Comments
 (0)