Document entities

This commit is contained in:
Vitalii Elenhaupt 2017-11-15 20:49:09 +02:00
parent 00c13fceee
commit 57b1095c5f
No known key found for this signature in database
GPG key ID: 7558EF3A4056C706
12 changed files with 346 additions and 18 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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? }

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,3 +0,0 @@
module Ameba
VERSION = "0.2.0"
end