src/elvis_config.erl

-module(elvis_config).

-export([from_rebar/1, from_file/1, from_application_or_config/2, validate/1,
         normalize/1]).
%% Geters
-export([dirs/1, ignore/1, filter/1, files/1, rules/1]).
%% Files
-export([resolve_files/1, resolve_files/2, apply_to_files/2]).
%% Rules
-export([merge_rules/2]).

-export_type([config/0]).
-export_type([configs/0]).

-type config() :: map().
-type configs() :: [config()].

-define(DEFAULT_FILTER, "*.erl").

%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
%%% Public
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

-spec from_rebar(string()) -> configs().
from_rebar(Path) ->
    case file:consult(Path) of
        {ok, AppConfig} ->
            load(config, load_initial(AppConfig), []);
        {error, Reason} ->
            throw(Reason)
    end.

-spec from_file(string()) -> configs().
from_file(Path) ->
    from_file(Path, config, []).

-spec from_file(string(), atom(), term()) -> configs().
from_file(Path, Key, Default) ->
    case file:consult(Path) of
        {ok, [AppConfig]} ->
            load(Key, load_initial(AppConfig), Default);
        {error, {_Line, _Mod, _Term} = Reason} ->
            throw(Reason);
        {error, _Reason} ->
            Default
    end.

-spec from_application_or_config(atom(), term()) -> term().
from_application_or_config(Key, Default) ->
    case application:get_env(elvis_core, Key) of
        {ok, Value} ->
            Value;
        _ ->
            from_file("elvis.config", Key, Default)
    end.

-spec load(atom(), term(), term()) -> configs().
load(Key, ElvisConfig, Default) ->
    ensure_config_list(Key, proplists:get_value(Key, ElvisConfig, Default)).

-spec load_initial(term()) -> [term()].
load_initial(AppConfig) ->
    ElvisConfig = proplists:get_value(elvis, AppConfig, []),
    RulesetsConfig = proplists:get_value(rulesets, ElvisConfig, #{}),
    elvis_rulesets:set_rulesets(RulesetsConfig),
    ElvisConfig.

ensure_config_list(config, Config) when is_map(Config) ->
    [Config];
ensure_config_list(_Other, Config) ->
    Config.

-spec validate(Config :: configs()) -> ok.
validate([]) ->
    throw({invalid_config, empty_config});
validate(Config) ->
    lists:foreach(fun do_validate/1, Config).

do_validate(RuleGroup) ->
    case maps:is_key(src_dirs, RuleGroup) or maps:is_key(dirs, RuleGroup) of
        false ->
            throw({invalid_config, {missing_dirs, RuleGroup}});
        true ->
            ok
    end,
    case maps:is_key(dirs, RuleGroup) of
        true ->
            case maps:is_key(filter, RuleGroup) of
                false ->
                    throw({invalid_config, {missing_filter, RuleGroup}});
                true ->
                    ok
            end;
        false ->
            ok
    end,
    case maps:is_key(rules, RuleGroup) orelse maps:is_key(ruleset, RuleGroup) of
        false ->
            throw({invalid_config, {missing_rules, RuleGroup}});
        true ->
            ok
    end.

-spec normalize(configs()) -> configs().
normalize(Config) when is_list(Config) ->
    lists:map(fun do_normalize/1, Config).

%% @private
do_normalize(#{src_dirs := Dirs} = Config) ->
    %% NOTE: Provided for backwards compatibility.
    %% Rename 'src_dirs' key to 'dirs'.
    Config1 = maps:remove(src_dirs, Config),
    Config1#{dirs => Dirs};
do_normalize(Config) ->
    Config.

-spec dirs(Config :: configs() | config()) -> [string()].
dirs(Config) when is_list(Config) ->
    lists:flatmap(fun dirs/1, Config);
dirs(#{dirs := Dirs}) ->
    Dirs;
dirs(#{}) ->
    [].

-spec ignore(configs() | config()) -> [string()].
ignore(Config) when is_list(Config) ->
    lists:flatmap(fun ignore/1, Config);
ignore(#{ignore := Ignore}) ->
    lists:map(fun ignore_to_regexp/1, Ignore);
ignore(#{}) ->
    [].

-spec filter(configs() | config()) -> [string()].
filter(Config) when is_list(Config) ->
    lists:flatmap(fun filter/1, Config);
filter(#{filter := Filter}) ->
    Filter;
filter(#{}) ->
    ?DEFAULT_FILTER.

-spec files(RuleGroup :: configs() | config()) -> [elvis_file:file()].
files(RuleGroup) when is_list(RuleGroup) ->
    lists:map(fun files/1, RuleGroup);
files(#{files := Files}) ->
    Files;
files(#{}) ->
    [].

-spec rules(RulesL :: configs()) -> [[elvis_core:rule()]];
           (Rules :: config()) -> [elvis_core:rule()].
rules(Rules) when is_list(Rules) ->
    lists:map(fun rules/1, Rules);
rules(#{rules := UserRules, ruleset := RuleSet}) ->
    DefaultRules = elvis_rulesets:rules(RuleSet),
    merge_rules(UserRules, DefaultRules);
rules(#{rules := Rules}) ->
    Rules;
rules(#{ruleset := RuleSet}) ->
    elvis_rulesets:rules(RuleSet);
rules(#{}) ->
    [].

%% @doc Takes a configuration and a list of files, filtering some
%%      of them according to the 'filter' key, or if not specified
%%      uses '*.erl'.
%% @end
%% resolve_files/2 with a configs() type is used in elvis project
-spec resolve_files(Config :: configs() | config(), Files :: [elvis_file:file()]) ->
                       configs() | config().
resolve_files(Config, Files) when is_list(Config) ->
    Fun = fun(RuleGroup) -> resolve_files(RuleGroup, Files) end,
    lists:map(Fun, Config);
resolve_files(RuleGroup, Files) ->
    Filter = filter(RuleGroup),
    Dirs = dirs(RuleGroup),
    Ignore = ignore(RuleGroup),
    FilteredFiles = elvis_file:filter_files(Files, Dirs, Filter, Ignore),
    _ = case FilteredFiles of
            [] ->
                RuleSet = maps:get(ruleset, RuleGroup, undefined),
                Error =
                    elvis_result:new(warn,
                                     "Searching for files in ~p, for ruleset ~p, "
                                     "with filter ~p, yielded none. "
                                     "Update your configuration.",
                                     [Dirs, RuleSet, Filter]),
                ok = elvis_result:print_results([Error]);
            _ ->
                ok
        end,
    RuleGroup#{files => FilteredFiles}.

%% @doc Takes a configuration and finds all files according to its 'dirs'
%%      end  'filter' key, or if not specified uses '*.erl'.
%% @end
-spec resolve_files(config()) -> config().
resolve_files(#{files := _Files} = RuleGroup) ->
    RuleGroup;
resolve_files(#{dirs := Dirs} = RuleGroup) ->
    Filter = filter(RuleGroup),
    Files = elvis_file:find_files(Dirs, Filter),
    resolve_files(RuleGroup, Files).

%% @doc Takes a function and configuration and applies the function to all
%%      file in the configuration.
%% @end
-spec apply_to_files(Fun :: fun(), Config :: configs() | config()) ->
                        configs() | config().
apply_to_files(Fun, Config) when is_list(Config) ->
    ApplyFun = fun(RuleGroup) -> apply_to_files(Fun, RuleGroup) end,
    lists:map(ApplyFun, Config);
apply_to_files(Fun, #{files := Files} = RuleGroup) ->
    NewFiles = lists:map(Fun, Files),
    RuleGroup#{files => NewFiles}.

%% @doc Ensures the ignore is a regexp, this is used
%%      to allow using 'module name' atoms in the ignore
%%      list by taking advantage of the fact that erlang
%%      enforces the module and the file name to be the
%%      same.
%% @end
-spec ignore_to_regexp(string() | atom()) -> string().
ignore_to_regexp(R) when is_list(R) ->
    R;
ignore_to_regexp(A) when is_atom(A) ->
    "/" ++ atom_to_list(A) ++ "\\.erl$".

%% @doc Merge user rules (override) with elvis default rules.
-spec merge_rules(UserRules :: list(), DefaultRules :: list()) -> [elvis_core:rule()].
merge_rules(UserRules, DefaultRules) ->
    UnduplicatedRules =
        % Drops repeated rules
        lists:filter(% If any default rule is in UserRules it means the user
                     % wants to override the rule.
                     fun ({FileName, RuleName}) ->
                             not is_rule_override(FileName, RuleName, UserRules);
                         ({FileName, RuleName, _}) ->
                             not is_rule_override(FileName, RuleName, UserRules);
                         (_) ->
                             false
                     end,
                     DefaultRules),
    OverrideRules =
        % Remove the rules that the user wants to "disable" and after that,
        % remains just the rules the user wants to override.
        lists:filter(fun ({_FileName, _RuleName, OverrideOptions}) ->
                             disable /= OverrideOptions;
                         ({_FileName, _RuleName}) ->
                             true; % not disabled
                         (_) ->
                             false
                     end,
                     UserRules),
    UnduplicatedRules ++ OverrideRules.

-spec is_rule_override(FileName :: atom(),
                       RuleName :: atom(),
                       UserRules :: [elvis_core:rule()]) ->
                          boolean().
is_rule_override(FileName, RuleName, UserRules) ->
    lists:any(fun(UserRule) ->
                 case UserRule of
                     {FileName, RuleName, _} ->
                         true;
                     _ ->
                         false
                 end
              end,
              UserRules).