Skip to content

Commit a8552f5

Browse files
committed
Add Rubocop::Cops::Rails::ZeitwerkFriendlyConstant
1 parent aa30af9 commit a8552f5

File tree

5 files changed

+426
-1
lines changed

5 files changed

+426
-1
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* [#1240](https://github.com/rubocop/rubocop-rails/pull/1240): Add new `ZeitwerkFriendlyConstant` cop. ([@shioyama][])

config/default.yml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1218,7 +1218,13 @@ 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>>'
1226+
Include:
1227+
- app/**/*.rb
12221228

12231229
# Accept `redirect_to(...) and return` and similar cases.
12241230
Style/AndOr:
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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+
110+
# Traverse the AST from node and yield each constant, along with its
111+
# nesting: an array of class/module names within which it is defined.
112+
def each_nested_constant(node, nesting = Nesting.new([]), &block)
113+
nesting.push(node) if constant_definition?(node)
114+
115+
any_yielded = node.child_nodes.map do |child_node|
116+
each_nested_constant(child_node, nesting.dup, &block)
117+
end.any?
118+
119+
# We only yield "leaves", i.e. constants that have no other nested
120+
# constants within themselves. To do this we return true from this
121+
# method if it itself has yielded, and only yield from parents if all
122+
# recursive calls did not return true (i.e. they did not yield).
123+
if !any_yielded && constant_definition?(node)
124+
yield(node, nesting)
125+
true
126+
else
127+
any_yielded
128+
end
129+
end
130+
131+
def path_segments
132+
@path_segments ||= processed_source.file_path.delete_suffix('.rb').split('/').map! { |dir| camelize(dir) }
133+
end
134+
135+
def constant_definition?(node)
136+
CONSTANT_DEFINITION_TYPES.include?(node.type)
137+
end
138+
139+
def offense_message(node)
140+
case node.type
141+
when :module
142+
MODULE_MESSAGE
143+
when :class
144+
CLASS_MESSAGE
145+
end
146+
end
147+
148+
def camelize(path_segment)
149+
path_segment.split('_').map! do |segment|
150+
acronyms.key?(segment) ? acronyms[segment] : segment.capitalize
151+
end.join
152+
end
153+
154+
def acronyms
155+
@acronyms ||= cop_config['Acronyms'].to_h do |acronym|
156+
[acronym.downcase, acronym]
157+
end
158+
end
159+
end
160+
end
161+
end
162+
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)