Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new Rails/ZeitwerkFriendlyConstant cop. #748

Closed
wants to merge 1 commit into from
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Change log

## master (unreleased)
* [#x](https://github.com/rubocop/rubocop-rails/issues/x): Add new `Rails/ZeitwerkFriendlyConstant` cop. ([@bdewater][])

## 2.15.2 (2022-07-07)

Expand Down Expand Up @@ -644,3 +645,4 @@
[@kkitadate]: https://github.com/kkitadate
[@Darhazer]: https://github.com/Darhazer
[@kazarin]: https://github.com/kazarin
[@bdewater]: https://github.com/bdewater
6 changes: 6 additions & 0 deletions config/default.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1024,6 +1024,12 @@ Rails/WhereNot:
Enabled: 'pending'
VersionAdded: '2.8'

Rails/ZeitwerkFriendlyConstant:
Description: 'Define classes and file names such that it is independently loadable by Zeitwerk.'
StyleGuide: 'https://rails.rubystyle.guide/#zeitwerk-friendly-constant'
Enabled: 'pending'
VersionAdded: '<<next>>'

# Accept `redirect_to(...) and return` and similar cases.
Style/AndOr:
EnforcedStyle: conditionals
161 changes: 161 additions & 0 deletions lib/rubocop/cop/rails/zeitwerk_friendly_constant.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# frozen_string_literal: true

module RuboCop
module Cop
module Rails
# Checks that every constant defined in a file matches the
# file name such that it is independently loadable by Zeitwerk.
#
# @example
#
# Good
#
# # /some/directory/foo.rb
# module Foo
# end
#
# # /some/directory/foo.rb
# module Foo
# module Bar
# end
# end
#
# # /some/directory/foo/bar.rb
# module Foo
# module Bar
# end
# end
#
# Bad
#
# # /some/directory/foo.rb
# module Bar
# end
#
# # /some/directory/foo/bar.rb
# module Foo
# module Bar
# end
#
# module Baz
# end
# end
#
class ZeitwerkFriendlyConstant < Cop
MSG = 'Constant name does not match filename.'
CLASS_MESSAGE = 'Class name does not match filename.'
MODULE_MESSAGE = 'Module name does not match filename.'
INCOMPATIBLE_FILE_PATH_MESSAGE = 'Constant names are mutually incompatible with file path.'

CONSTANT_NAME_MATCHER = /\A[[:upper:]_]*\Z/.freeze
CONSTANT_DEFINITION_TYPES = %i[module class casgn].freeze

def relevant_file?(file)
super && (File.extname(file) == '.rb')
end

def investigate(processed_source)
return if processed_source.blank?

filename = processed_source.buffer.name
path_segments = filename.delete_suffix('.rb').split('/').map! { |dir| camelize(dir) }

common_anchors = nil

each_nested_constant(processed_source.ast) do |node, nesting|
anchors = nesting.anchors(path_segments)

if anchors.empty?
add_offense(node, message: offense_message(node))
else
common_anchors ||= anchors

if (common_anchors &= anchors).empty?
# Add an offense if there is no common anchor among constants.
add_offense(node, message: INCOMPATIBLE_FILE_PATH_MESSAGE)
end
end
end
end

Nesting = Struct.new(:namespace) do
def push(node)
self.namespace += [node]
@constants = nil
end

def constants
@constants ||= namespace.flat_map { |node| constant_name(node).split('::') }
end

# For a nesting like ["Foo", "Bar"] and path segments ["", "Some",
# "Dir", "Foo", "Bar"], return an array of all possible "anchors" of the
# nesting within the segments, if any (in this case, [3]).
def anchors(path_segments)
(1..constants.length).each_with_object([]) do |i, anchors|
anchors << i if path_segments[(path_segments.size - i)..] == constants[0, i]
end
end

def constant_name(node)
if (defined_module = node.defined_module)
defined_module.const_name
else
name = node.children[1].to_s
name = name.split('_').map(&:capitalize!).join if CONSTANT_NAME_MATCHER.match?(name)
name
end
end
end

private

# Traverse the AST from node and yield each constant, along with its
# nesting: an array of class/module names within which it is defined.
def each_nested_constant(node, nesting = Nesting.new([]), &block)
nesting.push(node) if constant_definition?(node)

any_yielded = node.child_nodes.map do |child_node|
each_nested_constant(child_node, nesting.dup, &block)
end.any?

# We only yield "leaves", i.e. constants that have no other nested
# constants within themselves. To do this we return true from this
# method if it itself has yielded, and only yield from parents if all
# recursive calls did not return true (i.e. they did not yield).
if !any_yielded && constant_definition?(node)
yield(node, nesting)
true
else
any_yielded
end
end

def constant_definition?(node)
CONSTANT_DEFINITION_TYPES.include?(node.type)
end

def offense_message(node)
case node.type
when :module
MODULE_MESSAGE
when :class
CLASS_MESSAGE
end
end

def camelize(path_segment)
path_segment.split('_').map! do |segment|
acronyms.key?(segment) ? acronyms[segment] : segment.capitalize
end.join
end

def acronyms
@acronyms ||= cop_config['Acronyms'].to_h do |acronym|
[acronym.downcase, acronym]
end
end
end
end
end
end
1 change: 1 addition & 0 deletions lib/rubocop/cop/rails_cops.rb
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,4 @@
require_relative 'rails/where_equals'
require_relative 'rails/where_exists'
require_relative 'rails/where_not'
require_relative 'rails/zeitwerk_friendly_constant'
Loading