shard-ameba/src/ameba/config.cr

351 lines
10 KiB
Crystal
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

require "yaml"
require "./glob_utils"
# 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 located in a current
# working directory.
#
# If it cannot be found until reaching the root directory, then it will be
# searched for in the users global config locations, which consists of a
# dotfile or a config file inside the XDG Base Directory specification.
#
# - `~/.ameba.yml`
# - `$XDG_CONFIG_HOME/ameba/config.yml` (expands to `~/.config/ameba/config.yml`
# if `$XDG_CONFIG_HOME` is not set)
#
# If both files exist, the dotfile will be selected.
#
# As an example, if Ameba is invoked from inside `/path/to/project/lib/utils`,
# then it will use the config as specified inside the first of the following files:
#
# - `/path/to/project/lib/utils/.ameba.yml`
# - `/path/to/project/lib/.ameba.yml`
# - `/path/to/project/.ameba.yml`
# - `/path/to/.ameba.yml`
# - `/path/.ameba.yml`
# - `/.ameba.yml`
# - `~/.ameba.yml`
# - `~/.config/ameba/config.yml`
class Ameba::Config
include GlobUtils
AVAILABLE_FORMATTERS = {
progress: Formatter::DotFormatter,
todo: Formatter::TODOFormatter,
flycheck: Formatter::FlycheckFormatter,
silent: Formatter::BaseFormatter,
disabled: Formatter::DisabledFormatter,
json: Formatter::JSONFormatter,
}
XDG_CONFIG_HOME = ENV.fetch("XDG_CONFIG_HOME", "~/.config")
FILENAME = ".ameba.yml"
DEFAULT_PATH = Path[Dir.current] / FILENAME
DEFAULT_PATHS = {
Path["~"] / FILENAME,
Path[XDG_CONFIG_HOME] / "ameba/config.yml",
}
DEFAULT_GLOBS = %w(
**/*.cr
!lib
)
getter rules : Array(Rule::Base)
property severity = Severity::Convention
# Returns a list of paths (with wildcards) to files.
# Represents a list of sources to be inspected.
# If globs are not set, it will return default list of files.
#
# ```
# config = Ameba::Config.load
# config.globs = ["**/*.cr"]
# config.globs
# ```
property globs : Array(String)
# Represents a list of paths to exclude from globs.
# Can have wildcards.
#
# ```
# config = Ameba::Config.load
# config.excluded = ["spec", "src/server/*.cr"]
# ```
property excluded : Array(String)
# Returns `true` if correctable issues should be autocorrected.
property? autocorrect = false
@rule_groups : Hash(String, Array(Rule::Base))
# 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)
@rules = Rule.rules.map &.new(config).as(Rule::Base)
@rule_groups = @rules.group_by &.group
@excluded = load_array_section(config, "Excluded")
@globs = load_array_section(config, "Globs", DEFAULT_GLOBS)
if formatter_name = load_formatter_name(config)
self.formatter = formatter_name
end
end
# Loads YAML configuration file by `path`.
#
# ```
# config = Ameba::Config.load
# ```
def self.load(path = nil, colors = true, skip_reading_config = false)
Colorize.enabled = colors
content = if skip_reading_config
"{}"
else
read_config(path) || "{}"
end
Config.new YAML.parse(content)
rescue e
raise "Unable to load config file: #{e.message}"
end
protected def self.read_config(path = nil)
if path
return File.read(path) if File.exists?(path)
raise "Config file does not exist"
end
each_config_path do |config_path|
return File.read(config_path) if File.exists?(config_path)
end
end
protected def self.each_config_path(&)
path = Path[DEFAULT_PATH].expand(home: true)
search_paths = path.parents
search_paths.reverse_each do |search_path|
yield search_path / FILENAME
end
DEFAULT_PATHS.each do |default_path|
yield default_path
end
end
def self.formatter_names
AVAILABLE_FORMATTERS.keys.join('|')
end
# Returns a list of sources matching globs and excluded sections.
#
# ```
# config = Ameba::Config.load
# config.sources # => list of default sources
# config.globs = ["**/*.cr"]
# config.excluded = ["spec"]
# config.sources # => list of sources pointing to files found by the wildcards
# ```
def sources
(find_files_by_globs(globs) - find_files_by_globs(excluded))
.map { |path| Source.new File.read(path), path }
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
# ```
property formatter : Formatter::BaseFormatter do
Formatter::DotFormatter.new
end
# Sets formatter by name.
#
# ```
# config = Ameba::Config.load
# config.formatter = :progress
# ```
def formatter=(name : String | Symbol)
unless formatter = AVAILABLE_FORMATTERS[name]?
raise "Unknown formatter `#{name}`. Use one of #{Config.formatter_names}."
end
@formatter = formatter.new
end
# Updates rule properties.
#
# ```
# config = Ameba::Config.load
# config.update_rule "MyRuleName", enabled: false
# ```
def update_rule(name, enabled = true, excluded = nil)
rule = @rules.find(&.name.==(name))
raise ArgumentError.new("Rule `#{name}` does not exist") unless rule
rule
.tap(&.enabled = enabled)
.tap(&.excluded = excluded)
end
# Updates rules properties.
#
# ```
# config = Ameba::Config.load
# config.update_rules %w[Rule1 Rule2], enabled: true
# ```
#
# also it allows to update groups of rules:
#
# ```
# config.update_rules %w[Group1 Group2], enabled: true
# ```
def update_rules(names, enabled = true, excluded = nil)
names.try &.each do |name|
if rules = @rule_groups[name]?
rules.each do |rule|
rule.enabled = enabled
rule.excluded = excluded
end
else
update_rule name, enabled, excluded
end
end
end
private def load_formatter_name(config)
name = config["Formatter"]?.try &.["Name"]?
name.try(&.to_s)
end
private def load_array_section(config, section_name, default = [] of String)
case value = config[section_name]?
when .nil? then default
when .as_s? then [value.to_s]
when .as_a? then value.as_a.map(&.as_s)
else
raise "Incorrect '#{section_name}' section in a config files"
end
end
# :nodoc:
module RuleConfig
# Define rule properties
macro properties(&block)
{% definitions = [] of NamedTuple %}
{% if (prop = block.body).is_a? Call %}
{% if (named_args = prop.named_args) && (type = named_args.select(&.name.== "as".id).first) %}
{% definitions << {var: prop.name, value: prop.args.first, type: type.value} %}
{% else %}
{% definitions << {var: prop.name, value: prop.args.first} %}
{% end %}
{% elsif block.body.is_a? Expressions %}
{% for prop in block.body.expressions %}
{% if prop.is_a? Call %}
{% if (named_args = prop.named_args) && (type = named_args.select(&.name.== "as".id).first) %}
{% definitions << {var: prop.name, value: prop.args.first, type: type.value} %}
{% else %}
{% definitions << {var: prop.name, value: prop.args.first} %}
{% end %}
{% end %}
{% end %}
{% end %}
{% properties = {} of MacroId => NamedTuple %}
{% for df in definitions %}
{% name = df[:var].id %}
{% key = name.camelcase.stringify %}
{% value = df[:value] %}
{% type = df[:type] %}
{% converter = nil %}
{% if key == "Severity" %}
{% type = Severity %}
{% converter = SeverityYamlConverter %}
{% end %}
{% unless type %}
{% if value.is_a? BoolLiteral %}
{% type = Bool %}
{% elsif value.is_a? StringLiteral %}
{% type = String %}
{% elsif value.is_a? NumberLiteral %}
{% if value.kind == :i32 %}
{% type = Int32 %}
{% elsif value.kind == :i64 %}
{% type = Int64 %}
{% elsif value.kind == :i128 %}
{% type = Int128 %}
{% elsif value.kind == :f32 %}
{% type = Float32 %}
{% elsif value.kind == :f64 %}
{% type = Float64 %}
{% end %}
{% end %}
{% end %}
{% properties[name] = {key: key, default: value, type: type, converter: converter} %}
@[YAML::Field(key: {{ key }}, converter: {{ converter }})]
{% if type == Bool %}
property? {{ name }}{{ " : #{type}".id if type }} = {{ value }}
{% else %}
property {{ name }}{{ " : #{type}".id if type }} = {{ value }}
{% end %}
{% end %}
{% unless properties["enabled".id] %}
@[YAML::Field(key: "Enabled")]
property? enabled = true
{% end %}
{% unless properties["severity".id] %}
@[YAML::Field(key: "Severity", converter: Ameba::SeverityYamlConverter)]
property severity = {{ @type }}.default_severity
{% end %}
{% unless properties["excluded".id] %}
@[YAML::Field(key: "Excluded")]
property excluded : Array(String)?
{% end %}
end
macro included
GROUP_SEVERITY = {
Documentation: Ameba::Severity::Warning,
Lint: Ameba::Severity::Warning,
Metrics: Ameba::Severity::Warning,
Performance: Ameba::Severity::Warning,
}
class_getter default_severity : Ameba::Severity do
GROUP_SEVERITY[group_name]? || Ameba::Severity::Convention
end
macro inherited
include YAML::Serializable
include YAML::Serializable::Strict
def self.new(config = nil)
if (raw = config.try &.raw).is_a?(Hash)
yaml = raw[rule_name]?.try &.to_yaml
end
from_yaml yaml || "{}"
end
end
end
end
end