Skip to content

Commit

Permalink
[#499] Implement custom Rubocop rule to enforce class template
Browse files Browse the repository at this point in the history
  • Loading branch information
Goose97 committed Mar 29, 2024
1 parent 688614e commit cea6883
Show file tree
Hide file tree
Showing 7 changed files with 560 additions and 1 deletion.
4 changes: 4 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 Down Expand Up @@ -60,3 +61,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
108 changes: 108 additions & 0 deletions rubocop/custom_cops/class_template.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# 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

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.map { _1[:category] })
)
end
end

# Find the correct spot for the out of order expression
# rubocop:disable Metrics/MethodLength
def error_message(out_of_order_expression, expression_types)
expression_types.filter { _1 != out_of_order_expression }
.each_with_index do |expression, index|
error = if index.zero?
before_first_expression(expression, out_of_order_expression)
elsif index == expression_types.size - 1
after_last_expression(expression, out_of_order_expression)
else
previous_expression = expression_types[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)
return unless EXPRESSION_TYPE_ORDER[out_of_order_expression] < EXPRESSION_TYPE_ORDER[current_expression]

"#{out_of_order_expression} should come before #{current_expression}."
end

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

"#{out_of_order_expression} should come after #{current_expression}."
end

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

"#{out_of_order_expression} should come after #{previous_expression} and before #{current_expression}."
end
end
end
96 changes: 96 additions & 0 deletions rubocop/custom_cops/class_template/expression_category.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# 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

def categorize(expression)
CATEGORIES.find { |category| send("#{category}?", expression) } || :unknown
end
end
end
Loading

0 comments on commit cea6883

Please sign in to comment.