shard-ameba/src/ameba/rule/base.cr

177 lines
4.2 KiB
Crystal

module Ameba::Rule
# List of names of the special rules, which
# behave differently than usual rules.
SPECIAL = {
Lint::Syntax.rule_name,
Lint::UnneededDisableDirective.rule_name,
}
# Represents a base of all rules. In other words, all rules
# inherits from this struct:
#
# ```
# class MyRule < Ameba::Rule::Base
# def test(source)
# if invalid?(source)
# issue_for line, column, "Something wrong."
# end
# end
#
# private def invalid?(source)
# # ...
# end
# end
# ```
#
# Enforces rules to implement an abstract `#test` method which
# is designed to test the source passed in. If source has issues
# that are tested by this rule, it should add an issue.
abstract class Base
include Config::RuleConfig
# This method is designed to test the source passed in. If source has issues
# that are tested by this rule, it should add an issue.
#
# Be default it uses a node visitor to traverse all the nodes in the source.
# NOTE: Must be overridden for other type of rules.
def test(source : Source)
AST::NodeVisitor.new self, source
end
def test(source : Source, node : Crystal::ASTNode, *opts)
# can't be abstract
end
# A convenient addition to `#test` method that does the same
# but returns a passed in `source` as an addition.
#
# ```
# source = MyRule.new.catch(source)
# source.valid?
# ```
def catch(source : Source)
source.tap { test source }
end
# Returns a name of this rule, which is basically a class name.
#
# ```
# class MyRule < Ameba::Rule::Base
# def test(source)
# end
# end
#
# MyRule.new.name # => "MyRule"
# ```
def name
{{ @type }}.rule_name
end
# Returns a group this rule belong to.
#
# ```
# class MyGroup::MyRule < Ameba::Rule::Base
# # ...
# end
#
# MyGroup::MyRule.new.group # => "MyGroup"
# ```
def group
{{ @type }}.group_name
end
# Checks whether the source is excluded from this rule.
# It searches for a path in `excluded` property which matches
# the one of the given source.
#
# ```
# my_rule.excluded?(source) # => true or false
# ```
def excluded?(source)
!!excluded.try &.any? do |path|
source.matches_path?(path) ||
Dir.glob(path).any? { |glob| source.matches_path?(glob) }
end
end
# Returns `true` if this rule is special and behaves differently than
# usual rules.
#
# ```
# my_rule.special? # => true or false
# ```
def special?
name.in?(SPECIAL)
end
def ==(other)
name == other.try(&.name)
end
def hash
name.hash
end
# Adds an issue to the *source*
macro issue_for(*args, **kwargs, &block)
source.add_issue(self, {{ *args }}, {{ **kwargs }}) {{ block }}
end
protected def self.rule_name
name.gsub("Ameba::Rule::", "").gsub("::", '/')
end
protected def self.group_name
rule_name.split('/')[0...-1].join('/')
end
protected def self.subclasses
{{ @type.subclasses }}
end
protected def self.abstract?
{{ @type.abstract? }}
end
protected def self.inherited_rules
subclasses.each_with_object([] of Base.class) do |klass, obj|
klass.abstract? ? obj.concat(klass.inherited_rules) : (obj << klass)
end
end
private macro read_type_doc(filepath = __FILE__)
{{ run("../../contrib/read_type_doc",
@type.name.split("::").last,
filepath
).chomp.stringify }}.presence
end
macro inherited
# Returns documentation for this rule, if there is any.
#
# ```
# module Ameba
# # This is a test rule.
# # Does nothing.
# class MyRule < Ameba::Rule::Base
# def test(source)
# end
# end
# end
#
# MyRule.parsed_doc # => "This is a test rule.\nDoes nothing."
# ```
class_getter parsed_doc : String? = read_type_doc
end
end
# Returns a list of all available rules.
#
# ```
# Ameba::Rule.rules # => [Rule1, Rule2, ....]
# ```
def self.rules
Base.inherited_rules
end
end