Skip to content

Commit

Permalink
[#499] Create a Rubocop cop for ordering instructions in class (e.g. …
Browse files Browse the repository at this point in the history
…Models) (#509)

* [#499] Implement custom Rubocop rule to enforce class template

* [#499] Replace pluck with map

* [#499] Improve error message

* [#499] Fix linter errors

* [#499] Ignore custom rubocop code while running Rubocop on generated projects
  • Loading branch information
Goose97 committed Apr 29, 2024
1 parent cbf999c commit 0102560
Show file tree
Hide file tree
Showing 7 changed files with 571 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ require:
- rubocop-rspec
- rubocop-performance
- ./rubocop/custom_cops/required_inverse_of_relations.rb
- ./rubocop/custom_cops/class_template.rb

AllCops:
Exclude:
Expand All @@ -16,6 +17,7 @@ AllCops:
- 'node_modules/**/*'
- 'config/**/*'
- 'tmp/**/*'
- 'rubocop/**/*'
TargetRubyVersion: 3
NewCops: enable

Expand Down Expand Up @@ -60,3 +62,6 @@ CustomCops/RequiredInverseOfRelations:
Include:
# Only Rails model files
- !ruby/regexp /models\//

CustomCops/ClassTemplate:
Enabled: true
2 changes: 2 additions & 0 deletions .template/addons/custom_cops/template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@
require 'fileutils'

copy_file 'rubocop/custom_cops/required_inverse_of_relations.rb', mode: :preserve
directory 'rubocop/custom_cops/class_template', mode: :preserve
copy_file 'rubocop/custom_cops/class_template.rb', mode: :preserve
117 changes: 117 additions & 0 deletions rubocop/custom_cops/class_template.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# frozen_string_literal: true

require_relative 'class_template/expression_category'

module CustomCops
class ClassTemplate < RuboCop::Cop::Base
include CustomCops::ExpressionCategory

EXPRESSION_TYPE_ORDER = {
extend: 0,
include: 1,
constant_assignment: 2,
attribute_macro: 3,
other_macro: 4,
self_class_block: 5,
public_class_method: 5,
initialization: 6,
public_instance_method: 7,
alias: 8,
alias_method: 8,
protected_instance_method: 9,
private_instance_method: 10
}.freeze

# Scan class body
def on_class(node)
expressions = top_level_expressions(node)

state = { function_visibility: :public, expression_types: [] }
expressions.each_with_object(state) { |expression, acc| process_expression(expression, acc) }

validate_expressions_order(state[:expression_types])
end

private

def top_level_expressions(class_node)
return [] unless class_node.body

# Multi-expression body
return class_node.body.children if class_node.body.type == :begin

# Single-expression body
[class_node.body]
end

def process_expression(expression, acc)
category = categorize(expression)

if %i[protected private].include?(category)
acc[:function_visibility] = category
else
category = "#{acc[:function_visibility]}_#{category}".to_sym if category == :instance_method
acc[:expression_types] << { category: category, expression: expression }
end
end

def validate_expressions_order(expressions)
expressions = expressions.filter { _1[:category] != :unknown }

expressions.each_cons(2) do |first, second|
next unless EXPRESSION_TYPE_ORDER[first[:category]] > EXPRESSION_TYPE_ORDER[second[:category]]

add_offense(
second[:expression],
message: error_message(second[:category], expressions)
)
end
end

# Find the correct spot for the out of order expression
# rubocop:disable Metrics/MethodLength
def error_message(out_of_order_expression, expressions)
expressions.filter { _1[:category] != out_of_order_expression }
.each_with_index do |expression, index|
error = if index.zero?
before_first_expression(expression, out_of_order_expression)
elsif index == expressions.size - 1
after_last_expression(expression, out_of_order_expression)
else
previous_expression = expressions[index - 1]
in_between_expressions(previous_expression, expression, out_of_order_expression)
end

return error if error
end
end
# rubocop:enable Metrics/MethodLength

def before_first_expression(current_expression, out_of_order_expression)
unless EXPRESSION_TYPE_ORDER[out_of_order_expression] < EXPRESSION_TYPE_ORDER[current_expression[:category]]
return
end

"#{out_of_order_expression} should come before `#{current_expression[:expression].source}`."
end

def after_last_expression(current_expression, out_of_order_expression)
unless EXPRESSION_TYPE_ORDER[out_of_order_expression] > EXPRESSION_TYPE_ORDER[current_expression[:category]]
return
end

"#{out_of_order_expression} should come after `#{current_expression[:expression].source}`."
end

def in_between_expressions(previous_expression, current_expression, out_of_order_expression)
unless EXPRESSION_TYPE_ORDER[out_of_order_expression] < EXPRESSION_TYPE_ORDER[current_expression[:category]] &&
EXPRESSION_TYPE_ORDER[out_of_order_expression] > EXPRESSION_TYPE_ORDER[previous_expression[:category]]
return
end

"#{out_of_order_expression} should come after " \
"`#{previous_expression[:expression].source}` " \
"and before `#{current_expression[:expression].source}`."
end
end
end
97 changes: 97 additions & 0 deletions rubocop/custom_cops/class_template/expression_category.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
# frozen_string_literal: true

require 'rubocop'

module CustomCops
module ExpressionCategory
extend RuboCop::NodePattern::Macros

ATTRIBUTE_MACROS = %i[attr_reader attr_writer attr_accessor].to_set

CATEGORIES = %i[
extend include constant_assignment attribute_macro alias_method protected private
other_macro self_class_block public_class_method initialization instance_method alias
].freeze

# extend SomeModule
def_node_matcher :extend?, <<~PATTERN
(send {nil? | self} :extend const)
PATTERN

# include SomeModule
def_node_matcher :include?, <<~PATTERN
(send {nil? | self} :include const)
PATTERN

# HELLO = 'world'.freeze
def_node_matcher :constant_assignment?, <<~PATTERN
(casgn nil? _ _)
PATTERN

# attr_reader :foo, :bar
# attr_writer :baz
def_node_matcher :attribute_macro?, <<~PATTERN
(send {nil? | self} ATTRIBUTE_MACROS sym+)
PATTERN

# validates :foo, presence: true
def_node_matcher :other_macro?, <<~PATTERN
(send {nil? | self} _ _+)
PATTERN

# class << self
# def foo
# 'bar'
# end
# end
def_node_matcher :self_class_block?, <<~PATTERN
(sclass self _)
PATTERN

# def self.foo
# "bar"
# end
def_node_matcher :public_class_method?, <<~PATTERN
(defs self _ args _)
PATTERN

# def initialize(a, b)
# end
def_node_matcher :initialization?, <<~PATTERN
(def :initialize args _)
PATTERN

# def method(a, b)
# end
def_node_matcher :instance_method?, <<~PATTERN
(def _ args _)
PATTERN

# alias foo bar
# alias :foo :bar
def_node_matcher :alias?, <<~PATTERN
(alias sym sym)
PATTERN

# alias_method :foo, :bar
# alias_method 'foo', 'bar'
def_node_matcher :alias_method?, <<~PATTERN
(send {nil? | self} :alias_method _ _)
PATTERN

# protected
def_node_matcher :protected?, <<~PATTERN
(send {nil? | self} :protected)
PATTERN

# private
def_node_matcher :private?, <<~PATTERN
(send {nil? | self} :private)
PATTERN

# Categorize the expression of the class body
def categorize(expression)
CATEGORIES.find { |category| send(:"#{category}?", expression) } || :unknown
end
end
end

0 comments on commit 0102560

Please sign in to comment.