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 3867d5e
Show file tree
Hide file tree
Showing 6 changed files with 567 additions and 0 deletions.
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
94 changes: 94 additions & 0 deletions rubocop/custom_cops/class_template.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
# 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: 7,
alias_method: 7,
protected_instance_method: 8,
private_instance_method: 9
}.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 = category == :instance_method ? "#{acc[:function_visibility]}_#{category}".to_sym : category
acc[:expression_types] << { category:, 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
def error_message(out_of_order_expression, expression_types)
expression_types = expression_types.filter { _1 != out_of_order_expression }
out_of_order = EXPRESSION_TYPE_ORDER[out_of_order_expression]

expression_types.each_with_index do |expression, index|
# Before first expression
if index.zero? && (out_of_order < EXPRESSION_TYPE_ORDER[expression])
return "#{out_of_order_expression} should come before #{expression}."
end

# After last expression
if index == expression_types.size - 1 && (out_of_order > EXPRESSION_TYPE_ORDER[expression])
return "#{out_of_order_expression} should come after #{expression}."
end

previous_expression = expression_types[index - 1]
next unless out_of_order < EXPRESSION_TYPE_ORDER[expression] &&
out_of_order > EXPRESSION_TYPE_ORDER[previous_expression]

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

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

# include SomeModule
def_node_matcher :include_expr?, <<~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 :initialize_method?, <<~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)
return :extend if extend_expr?(expression)

return :include if include_expr?(expression)

return :constant_assignment if constant_assignment?(expression)

return :attribute_macro if attribute_macro?(expression)

return :alias_method if alias_method?(expression)

return :protected if protected?(expression)

return :private if private?(expression)

return :other_macro if other_macro?(expression)

return :self_class_block if self_class_block?(expression)

return :public_class_method if public_class_method?(expression)

return :initialization if initialize_method?(expression)

return :instance_method if instance_method?(expression)

return :alias if alias?(expression)

:unknown
end
end
end
Loading

0 comments on commit 3867d5e

Please sign in to comment.