mirror of
https://gitea.invidious.io/iv-org/shard-ameba.git
synced 2024-08-15 00:53:29 +00:00
Document entities
This commit is contained in:
parent
00c13fceee
commit
57b1095c5f
12 changed files with 346 additions and 18 deletions
|
@ -5,7 +5,7 @@ module Ameba::AST
|
|||
source = Source.new ""
|
||||
|
||||
describe "Traverse" do
|
||||
{% for name in NODE_VISITORS %}
|
||||
{% for name in NODES %}
|
||||
describe "{{name}}" do
|
||||
it "allow to visit {{name}} node" do
|
||||
visitor = Visitor.new rule, source
|
||||
|
|
30
src/ameba.cr
30
src/ameba.cr
|
@ -3,9 +3,39 @@ require "./ameba/ast/*"
|
|||
require "./ameba/rule/*"
|
||||
require "./ameba/formatter/*"
|
||||
|
||||
# Ameba's entry module.
|
||||
#
|
||||
# To run the linter with default parameters:
|
||||
#
|
||||
# ```
|
||||
# Ameba.run
|
||||
# ```
|
||||
#
|
||||
# To configure and run it:
|
||||
#
|
||||
# ```
|
||||
# config = Ameba::Config.load
|
||||
# config.formatter = formatter
|
||||
# config.files = file_paths
|
||||
#
|
||||
# Ameba.run config
|
||||
# ```
|
||||
#
|
||||
module Ameba
|
||||
extend self
|
||||
|
||||
VERSION = "0.2.0"
|
||||
|
||||
# Initializes `Ameba::Runner` and runs it.
|
||||
# Can be configured via `config` parameter.
|
||||
#
|
||||
# Examples:
|
||||
#
|
||||
# ```
|
||||
# Ameba.run
|
||||
# Ameba.run config
|
||||
# ```
|
||||
#
|
||||
def run(config = Config.load)
|
||||
Runner.new(config).run
|
||||
end
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
require "compiler/crystal/syntax/*"
|
||||
|
||||
# A module that helps to traverse Crystal AST using `Crystal::Visitor`.
|
||||
module Ameba::AST
|
||||
NODE_VISITORS = [
|
||||
# List of nodes to be visited by Ameba's rules.
|
||||
NODES = [
|
||||
Alias,
|
||||
Assign,
|
||||
Call,
|
||||
|
@ -20,19 +22,38 @@ module Ameba::AST
|
|||
Var,
|
||||
]
|
||||
|
||||
# An AST Visitor used by rules.
|
||||
#
|
||||
# ```
|
||||
# visitor = Ameba::AST::Visitor.new(rule, source)
|
||||
# ```
|
||||
#
|
||||
class Visitor < Crystal::Visitor
|
||||
# A corresponding rule that uses this visitor.
|
||||
@rule : Rule::Base
|
||||
|
||||
# A source that needs to be traversed.
|
||||
@source : Source
|
||||
|
||||
# Creates instance of this visitor.
|
||||
#
|
||||
# ```
|
||||
# visitor = Ameba::AST::Visitor.new(rule, source)
|
||||
# ```
|
||||
#
|
||||
def initialize(@rule, @source)
|
||||
@source.ast.accept self
|
||||
end
|
||||
|
||||
# A main visit method that accepts `Crystal::ASTNode`.
|
||||
# Returns true meaning all child nodes will be traversed.
|
||||
def visit(node : Crystal::ASTNode)
|
||||
true
|
||||
end
|
||||
|
||||
{% for name in NODE_VISITORS %}
|
||||
{% for name in NODES %}
|
||||
# A visit callback for `Crystal::{{name}}` node.
|
||||
# Returns true meaning that child nodes will be traversed as well.
|
||||
def visit(node : Crystal::{{name}})
|
||||
@rule.test @source, node
|
||||
true
|
||||
|
|
|
@ -1,12 +1,18 @@
|
|||
# Utility module for Ameba's rules.
|
||||
module Ameba::AST::Util
|
||||
# Returns true if current `node` is a literal, false - otherwise.
|
||||
def literal?(node)
|
||||
node.try &.class.name.ends_with? "Literal"
|
||||
end
|
||||
|
||||
# Returns true if current `node` is a string literal, false - otherwise.
|
||||
def string_literal?(node)
|
||||
node.is_a? Crystal::StringLiteral
|
||||
end
|
||||
|
||||
# Returns a source code for the current node.
|
||||
# This method uses `node.location` and `node.end_location`
|
||||
# to determine and cut a piece of source of the node.
|
||||
def node_source(node, code_lines)
|
||||
loc, end_loc = node.location, node.end_location
|
||||
|
||||
|
|
|
@ -1,29 +1,73 @@
|
|||
require "yaml"
|
||||
|
||||
# A configuration entry for `Ameba::Runner`.
|
||||
#
|
||||
# Config can be loaded from configuration YAML file and adjusted.
|
||||
#
|
||||
# ```
|
||||
# config = Config.load
|
||||
# config.formatter = my_formatter
|
||||
# ```
|
||||
#
|
||||
# By default config loads `.ameba.yml` file in a current directory.
|
||||
#
|
||||
class Ameba::Config
|
||||
setter formatter : Formatter::BaseFormatter?
|
||||
setter files : Array(String)?
|
||||
|
||||
def initialize(@config : YAML::Any)
|
||||
# Creates a new instance of `Ameba::Config` based on YAML parameters.
|
||||
#
|
||||
# `Config.load` uses this constructor to instantiate new config by YAML file.
|
||||
protected def initialize(@config : YAML::Any)
|
||||
end
|
||||
|
||||
def self.load(path = ".ameba.yml")
|
||||
content = (path && File.exists? path) ? File.read path : "{}"
|
||||
# Loads YAML configuration file by `path`.
|
||||
#
|
||||
# ```
|
||||
# config = Ameba::Config.load
|
||||
# ```
|
||||
#
|
||||
def self.load(path = nil)
|
||||
path ||= ".ameba.yml"
|
||||
content = File.exists?(path) ? File.read path : "{}"
|
||||
Config.new YAML.parse(content)
|
||||
end
|
||||
|
||||
def self.load(path : Nil)
|
||||
self.load
|
||||
end
|
||||
|
||||
# Returns a list of paths (with wildcards) to files.
|
||||
# Represents a list of sources to be inspected.
|
||||
# If files are not set, it will return default list of files.
|
||||
#
|
||||
# ```
|
||||
# config = Ameba::Config.load
|
||||
# config.files = ["**/*.cr"]
|
||||
# config.files
|
||||
# ```
|
||||
#
|
||||
def files
|
||||
@files ||= default_files
|
||||
end
|
||||
|
||||
# Returns a formatter to be used while inspecting files.
|
||||
# If formatter is not set, it will return default formatter.
|
||||
#
|
||||
# ```
|
||||
# config = Ameba::Config.load
|
||||
# config.formatter = custom_formatter
|
||||
# config.formatter
|
||||
# ```
|
||||
#
|
||||
def formatter
|
||||
@formatter ||= default_formatter
|
||||
end
|
||||
|
||||
# Returns a subconfig of a home full loaded configuration.
|
||||
# This is used to get a corresponding to a specific rule config.
|
||||
#
|
||||
# ```
|
||||
# config = Ameba::Config.load
|
||||
# config.subconfig "LineLength"
|
||||
# ```
|
||||
#
|
||||
def subconfig(name)
|
||||
@config[name]?
|
||||
end
|
||||
|
@ -36,9 +80,23 @@ class Ameba::Config
|
|||
Formatter::DotFormatter.new
|
||||
end
|
||||
|
||||
# An entity that represents a corresponding configuration for a specific Rule.
|
||||
module Rule
|
||||
# Represents a configuration of a specific Rule.
|
||||
getter config : YAML::Any?
|
||||
|
||||
# A macro that defines a dsl to define configurable properties.
|
||||
#
|
||||
# ```
|
||||
# class Configurable
|
||||
# include Ameba::Config::Rule
|
||||
#
|
||||
# prop enabled? = false
|
||||
# prop max_length = 80
|
||||
# prop wildcard = "*"
|
||||
# end
|
||||
# ```
|
||||
#
|
||||
macro prop(assign)
|
||||
# Rule configuration property.
|
||||
def {{assign.target}}
|
||||
|
@ -54,6 +112,20 @@ class Ameba::Config
|
|||
end
|
||||
end
|
||||
|
||||
# Creates an instance of a Rule configuration.
|
||||
#
|
||||
# ```
|
||||
# class Configurable
|
||||
# include Ameba::Config::Rule
|
||||
#
|
||||
# prop enabled? = false
|
||||
# prop max_length = 80
|
||||
# prop wildcard = "*"
|
||||
# end
|
||||
#
|
||||
# Configurable.new config
|
||||
# ```
|
||||
#
|
||||
def initialize(config = nil)
|
||||
@config = config.try &.subconfig(name)
|
||||
end
|
||||
|
|
|
@ -1,17 +1,29 @@
|
|||
# A module that utilizes Ameba's formatters.
|
||||
module Ameba::Formatter
|
||||
# A base formatter for all formatters. It uses `output` IO
|
||||
# to report results and also implements stub methods for
|
||||
# callbacks in `Ameba::Runner#run` method.
|
||||
class BaseFormatter
|
||||
# allow other IOs
|
||||
# TODO: allow other IOs
|
||||
getter output : IO::FileDescriptor | IO::Memory
|
||||
|
||||
def initialize(@output = STDOUT)
|
||||
end
|
||||
|
||||
# Callback that indicates when inspecting is started.
|
||||
# A list of sources to inspect is passed as an argument.
|
||||
def started(sources); end
|
||||
|
||||
# Callback that indicates when source inspection is finished.
|
||||
# A corresponding source is passed as an argument.
|
||||
def source_finished(source : Source); end
|
||||
|
||||
# Callback that indicates when source inspection is finished.
|
||||
# A corresponding source is passed as an argument.
|
||||
def source_started(source : Source); end
|
||||
|
||||
# Callback that indicates when inspection is finished.
|
||||
# A list of inspected sources is passed as an argument.
|
||||
def finished(sources); end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,19 +1,24 @@
|
|||
module Ameba::Formatter
|
||||
# A formatter that shows a progress of inspection in a terminal using dots.
|
||||
# It is similar to Crystal's dot formatter for specs.
|
||||
class DotFormatter < BaseFormatter
|
||||
@started_at : Time?
|
||||
|
||||
# Reports a message when inspection is started.
|
||||
def started(sources)
|
||||
@started_at = Time.now # Time.monotonic
|
||||
|
||||
output << started_message(sources.size)
|
||||
end
|
||||
|
||||
# Reports a result of the inspection of a corresponding source.
|
||||
def source_finished(source : Source)
|
||||
sym = source.valid? ? ".".colorize(:green) : "F".colorize(:red)
|
||||
output << sym
|
||||
output.flush
|
||||
end
|
||||
|
||||
# Reports a message when inspection is finished.
|
||||
def finished(sources)
|
||||
output << "\n\n"
|
||||
failed_sources = sources.reject { |s| s.valid? }
|
||||
|
|
|
@ -1,19 +1,63 @@
|
|||
module Ameba::Rule
|
||||
# Represents a base of all rules. In other words, all rules
|
||||
# inherits from this struct:
|
||||
#
|
||||
# ```
|
||||
# struct MyRule < Ameba::Rule::Base
|
||||
# def test(source)
|
||||
# if invalid?(source)
|
||||
# source.error self, location, "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 error.
|
||||
#
|
||||
abstract struct Base
|
||||
include Config::Rule
|
||||
|
||||
# This method is designed to test the source passed in. If source has issues
|
||||
# that are tested by this rule, it should add an error.
|
||||
abstract def test(source : Source)
|
||||
|
||||
# Enabled property indicates whether this rule enabled or not.
|
||||
# Only enabled rules will be included into the inspection.
|
||||
prop enabled? = true
|
||||
|
||||
def test(source : Source, node : Crystal::ASTNode)
|
||||
# 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 { |s| test s }
|
||||
end
|
||||
|
||||
# Returns a name of this rule, which is basically a class name.
|
||||
#
|
||||
# ```
|
||||
# struct MyRule < Ameba::Rule::Base
|
||||
# def test(source)
|
||||
# end
|
||||
# end
|
||||
#
|
||||
# MyRule.new.name # => "MyRule"
|
||||
# ```
|
||||
#
|
||||
def name
|
||||
self.class.name.gsub("Ameba::Rule::", "")
|
||||
end
|
||||
|
@ -23,6 +67,12 @@ module Ameba::Rule
|
|||
end
|
||||
end
|
||||
|
||||
# Returns a list of all available rules.
|
||||
#
|
||||
# ```
|
||||
# Ameba::Rule.rules # => [LineLength, ConstantNames, ....]
|
||||
# ```
|
||||
#
|
||||
def self.rules
|
||||
Base.subclasses
|
||||
end
|
||||
|
|
|
@ -1,19 +1,63 @@
|
|||
module Ameba
|
||||
# Represents a runner for inspecting sources files.
|
||||
# Holds a list of rules to do inspection based on,
|
||||
# list of sources to run inspection on and a formatter
|
||||
# to prepare a report.
|
||||
#
|
||||
# ```
|
||||
# config = Ameba::Config.load
|
||||
# runner = Ameba::Runner.new config
|
||||
# runner.run.success? # => true or false
|
||||
# ```
|
||||
#
|
||||
class Runner
|
||||
# A list of rules to do inspection based on.
|
||||
@rules : Array(Rule::Base)
|
||||
|
||||
# A list of sources to run inspection on.
|
||||
@sources : Array(Source)
|
||||
|
||||
# A formatter to prepare report.
|
||||
@formatter : Formatter::BaseFormatter
|
||||
|
||||
# Instantiates a runner using a `config`.
|
||||
#
|
||||
# ```
|
||||
# config = Ameba::Config.load
|
||||
# config.files = files
|
||||
# config.formatter = formatter
|
||||
#
|
||||
# Ameba::Runner.new config
|
||||
# ```
|
||||
#
|
||||
def initialize(config : Config)
|
||||
@rules = load_rules(config)
|
||||
@sources = load_sources(config)
|
||||
@formatter = config.formatter
|
||||
end
|
||||
|
||||
# Instantiates a runner using a list of sources and a formatter.
|
||||
#
|
||||
# ```
|
||||
# runner = Ameba::Runner.new sources, formatter
|
||||
# ```
|
||||
#
|
||||
def initialize(@sources, @formatter)
|
||||
@rules = load_rules nil
|
||||
end
|
||||
|
||||
# Performs the inspection. Iterates through all sources and test it using
|
||||
# list of rules. If a specific rule fails on a specific source, it adds
|
||||
# an error to that source.
|
||||
#
|
||||
# This action also notifies formatter when inspection is started/finished,
|
||||
# and when a specific source started/finished to be inspected.
|
||||
#
|
||||
# ```
|
||||
# runner = Ameba::Runner.new config
|
||||
# runner.run # => returns runner again
|
||||
# ```
|
||||
#
|
||||
def run
|
||||
@formatter.started @sources
|
||||
@sources.each do |source|
|
||||
|
@ -28,6 +72,15 @@ module Ameba
|
|||
@formatter.finished @sources
|
||||
end
|
||||
|
||||
# Indicates whether the last inspection successful or not.
|
||||
# It returns true if no issues in sources found, false otherwise.
|
||||
#
|
||||
# ```
|
||||
# runner = Ameba::Runner.new config
|
||||
# runner.run
|
||||
# runner.success? # => true or false
|
||||
# ```
|
||||
#
|
||||
def success?
|
||||
@sources.all? &.valid?
|
||||
end
|
||||
|
|
|
@ -11,27 +11,76 @@ module Ameba
|
|||
location : Crystal::Location?,
|
||||
message : String
|
||||
|
||||
getter lines : Array(String)?
|
||||
getter errors = [] of Error
|
||||
# Path to the source file.
|
||||
getter path : String?
|
||||
getter code : String
|
||||
getter ast : Crystal::ASTNode?
|
||||
|
||||
# Crystal code (content of a source file).
|
||||
getter code : String
|
||||
|
||||
# List of errors reported.
|
||||
getter errors = [] of Error
|
||||
|
||||
@lines : Array(String)?
|
||||
@ast : Crystal::ASTNode?
|
||||
|
||||
# Creates a new source by `code` and `path`.
|
||||
#
|
||||
# For example:
|
||||
#
|
||||
# ```
|
||||
# path = "./src/source.cr"
|
||||
# Ameba::Source.new File.read(path), path
|
||||
# ```
|
||||
#
|
||||
def initialize(@code : String, @path = nil)
|
||||
end
|
||||
|
||||
# Add new error to the list of errors.
|
||||
#
|
||||
# ```
|
||||
# source.error rule, location, "Line too long"
|
||||
# ```
|
||||
#
|
||||
def error(rule : Rule::Base, location, message : String)
|
||||
errors << Error.new rule, location, message
|
||||
end
|
||||
|
||||
# Indicates whether source is valid or not.
|
||||
# Returns true if the list or errors empty, false otherwise.
|
||||
#
|
||||
# ```
|
||||
# source = Ameba::Source.new code, path
|
||||
# source.valid? # => true
|
||||
# source.error rule, location, message
|
||||
# source.valid? # => false
|
||||
# ```
|
||||
#
|
||||
def valid?
|
||||
errors.empty?
|
||||
end
|
||||
|
||||
# Returns lines of code splitted by new line character.
|
||||
# Since `code` is immutable and can't be changed, this
|
||||
# method caches lines in an instance variable, so calling
|
||||
# it second time will not perform a split, but will return
|
||||
# lines instantly.
|
||||
#
|
||||
# ```
|
||||
# source = Ameba::Source.new "a = 1\nb = 2", path
|
||||
# source.lines # => ["a = 1", "b = 2"]
|
||||
# ```
|
||||
#
|
||||
def lines
|
||||
@lines ||= @code.split("\n")
|
||||
end
|
||||
|
||||
# Returns AST nodes constructed by `Crystal::Parser`.
|
||||
#
|
||||
# ```
|
||||
# source = Ameba::Source.new code, path
|
||||
# source.ast
|
||||
# ```
|
||||
#
|
||||
def ast
|
||||
@ast ||=
|
||||
Crystal::Parser.new(code)
|
||||
|
@ -39,6 +88,14 @@ module Ameba
|
|||
.parse
|
||||
end
|
||||
|
||||
# Returns a new instance of the `Crystal::Location` in current
|
||||
# source based on the line number `l` and column number `c`.
|
||||
#
|
||||
# ```
|
||||
# s = Ameba::Source.new code, path
|
||||
# s.location(3, 76)
|
||||
# ```
|
||||
#
|
||||
def location(l, c)
|
||||
Crystal::Location.new path, l, c
|
||||
end
|
||||
|
|
|
@ -1,7 +1,24 @@
|
|||
require "compiler/crystal/syntax/*"
|
||||
|
||||
module Ameba
|
||||
# Represents Crystal syntax tokenizer based on `Crystal::Lexer`.
|
||||
#
|
||||
# ```
|
||||
# source = Ameba::Source.new code, path
|
||||
# tokenizer = Ameba::Tokenizer.new(source)
|
||||
# tokenizer.run do |token|
|
||||
# puts token
|
||||
# end
|
||||
# ```
|
||||
#
|
||||
class Tokenizer
|
||||
# Instantiates Tokenizer using a `source`.
|
||||
#
|
||||
# ```
|
||||
# source = Ameba::Source.new code, path
|
||||
# Ameba::Tokenizer.new(source)
|
||||
# ```
|
||||
#
|
||||
def initialize(source)
|
||||
@lexer = Crystal::Lexer.new source.code
|
||||
@lexer.count_whitespace = true
|
||||
|
@ -10,6 +27,14 @@ module Ameba
|
|||
@lexer.filename = source.path
|
||||
end
|
||||
|
||||
# Runs the tokenizer and yields each token as a block argument.
|
||||
#
|
||||
# ```
|
||||
# Ameba::Tokenizer.new(source).run do |token|
|
||||
# puts token
|
||||
# end
|
||||
# ```
|
||||
#
|
||||
def run(&block : Crystal::Token -> _)
|
||||
run_normal_state @lexer, &block
|
||||
true
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
module Ameba
|
||||
VERSION = "0.2.0"
|
||||
end
|
Loading…
Reference in a new issue