-module(elvis_config). -feature(maybe_expr, enable). -export([from_rebar/1, from_file/1, validate_config/1, default/0]). %% Getters -export([dirs/1, ignore/1, filter/1, files/1, rules/1, ruleset/1]). %% Files -export([resolve_files/1, resolve_files/2, apply_to_files/2]). %% Rules -export([merge_rules/2]). %% Options -export([config/0, output_format/0, verbose/0, no_output/0, parallel/0]). -export([set_output_format/1, set_verbose/1, set_no_output/1, set_parallel/1]). % Corresponds to the 'config' key. -opaque t() :: map(). -export_type([t/0]). -type output_format() :: plain | colors | parsable. -export_type([output_format/0]). -type fail_validation() :: {fail, [{throw, {invalid_config, Message :: string()}}]}. -export_type([fail_validation/0]). % API exports, not consumed locally. -ignore_xref([from_rebar/1, from_file/1, default/0, resolve_files/2, apply_to_files/2]). -ignore_xref([set_output_format/1, set_verbose/1, set_no_output/1, set_parallel/1]). %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %%% Public %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% fetch_elvis_config(Key, AppConfig) -> Elvis = from_static(elvis, {app, AppConfig}), _ = case lists:member(Key, elvis_control_opts()) of true -> ok; _ -> do_validate({elvis, Elvis}) end, _ = elvis_ruleset:load_custom(from_static(rulesets, {elvis, Elvis})), Elvis. from_static(Key, {Type, Config}) -> elvis_utils:debug("fetching key '~s' from '~s' configuration", [Key, Type]), case proplists:get_value(Key, Config) of undefined -> elvis_utils:debug( "no value for key '~s' found in '~s' configuration; going with default", [ Key, Type ] ), default(Key); Value -> elvis_utils:debug("value for key '~s' found in '~s' configuration", [Key, Type]), Value end. -spec config() -> [t()] | fail_validation(). config() -> try for(config) catch {invalid_config, _} = Caught -> {fail, [{throw, Caught}]} end. -spec output_format() -> output_format(). output_format() -> for(output_format). -spec verbose() -> boolean(). verbose() -> for(verbose). -spec no_output() -> boolean(). no_output() -> for(no_output). -spec parallel() -> pos_integer(). parallel() -> for(parallel). -spec set_output_format(output_format()) -> ok. set_output_format(OutputFormat) -> set_env(output_format, OutputFormat). -spec set_verbose(boolean()) -> ok. set_verbose(Verbose) -> set_env(verbose, Verbose). -spec set_no_output(boolean()) -> ok. set_no_output(NoOutput) -> set_env(no_output, NoOutput). -spec set_parallel(pos_integer()) -> ok. set_parallel(Parallel) -> set_env(parallel, Parallel). set_env(Key, Value) -> application:set_env(elvis_core, Key, Value). default(Key) -> case application:get_env(elvis_core, Key) of undefined -> elvis_utils:debug( "no value for key '~s' found in application environment; going with default", [Key] ), default_for(Key); {ok, Value} -> elvis_utils:debug("value for key '~s' (~p) found in application environment", [ Key, Value ]), Value end. for(Key) -> AppDefault = default_for(app), AppConfig = case consult_elvis_config("elvis.config") of AppDefault -> % This might happen whether we fail to parse the file or it actually is [] elvis_utils:debug("elvis.config unusable; falling back to rebar.config", []), consult_rebar_config("rebar.config"); AppConfig0 -> AppConfig0 end, % If we got this far, the configuration is valid... Elvis = fetch_elvis_config(Key, AppConfig), from_static(Key, {elvis, Elvis}). consult_elvis_config(File) -> case file:consult(File) of {ok, [AppConfig]} when is_list(AppConfig) -> elvis_utils:debug("elvis.config consultable; using it", []), AppConfig; {error, {Line, Mod, Term}} -> % In this very specific case we prefer to throw, since we make efforts % to provide a valid config., but we also need to make sure the file % is readable exit( lists:flatten( io_lib:format("elvis.config unconsultable: ~p, ~p, ~p", [Line, Mod, Term]) ) ); _ -> elvis_utils:debug("elvis.config unconsultable", []), default_for(app) end. consult_rebar_config(File) -> case file:consult(File) of {ok, AppConfig0} when is_list(AppConfig0) -> elvis_utils:debug("rebar.config consultable; using it", []), AppConfig0; _ -> elvis_utils:debug("rebar.config unconsultable", []), default_for(app) end. -spec from_rebar(File :: string()) -> [t()] | fail_validation(). from_rebar(File) -> AppConfig = consult_rebar_config(File), fetch_elvis_config_from(AppConfig). -spec from_file(File :: string()) -> [t()] | fail_validation(). from_file(File) -> AppConfig = consult_elvis_config(File), fetch_elvis_config_from(AppConfig). fetch_elvis_config_from(AppConfig) -> try fetch_elvis_config(undefined, AppConfig) of Elvis -> from_static(config, {elvis, Elvis}) catch {invalid_config, Message} -> {fail, [{throw, {invalid_config, lists:flatten(Message)}}]} end. default_for(app) -> % This is the top-level element, before 'elvis' []; default_for(elvis) -> []; default_for(config) -> []; default_for(output_format) -> colors; default_for(verbose) -> false; default_for(no_output) -> false; default_for(parallel) -> erlang:system_info(schedulers_online); default_for(rulesets) -> #{}; default_for([config, dirs]) -> []; default_for([config, filter]) -> ""; default_for([config, ignore]) -> []; default_for([config, ruleset]) -> undefined; default_for([config, rules]) -> []. -spec default() -> [t()]. default() -> [ #{ dirs => [ "apps/**/src", "src" ], filter => "*.erl", ruleset => erl_files }, #{ dirs => [ "apps/**/src", "src", "apps/**/include", "include" ], filter => "*.hrl", ruleset => hrl_files }, #{ dirs => ["."], filter => "rebar.config", ruleset => rebar_config }, #{ dirs => ["."], filter => ".gitignore", ruleset => gitignore } ]. -spec dirs(Config :: [t()] | t()) -> [string()]. dirs(Config) when is_list(Config) -> lists:flatmap(fun dirs/1, Config); dirs(#{dirs := Dirs}) -> Dirs; dirs(#{}) -> []. -spec ignore([t()] | t()) -> [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([t()] | t()) -> [string()]. filter(Config) when is_list(Config) -> lists:flatmap(fun filter/1, Config); filter(#{filter := Filter}) -> Filter; filter(#{}) -> "*.erl". -spec files(RuleGroup :: [t()] | t()) -> [elvis_file:t()]. files(RuleGroup) when is_list(RuleGroup) -> lists:map(fun files/1, RuleGroup); files(#{files := Files}) -> Files; files(#{}) -> []. -spec rules (RulesL :: [t()]) -> [[elvis_rule:t()]]; (Rules :: t()) -> [elvis_rule:t()]. rules(Rules) when is_list(Rules) -> lists:map(fun rules/1, Rules); rules(#{rules := UserRules, ruleset := Ruleset}) -> DefaultRules = elvis_ruleset:rules(Ruleset), merge_rules(UserRules, DefaultRules); rules(#{rules := Rules}) -> filter_deprecated_rules(Rules); rules(#{ruleset := Ruleset}) -> elvis_ruleset:rules(Ruleset); rules(#{}) -> []. -spec ruleset(t()) -> Ruleset :: atom() | undefined. ruleset(ElvisConfig) -> maps:get(ruleset, ElvisConfig, undefined). %% @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 [t()] type is used in elvis project -spec resolve_files(Config :: [t()] | t(), Files :: [elvis_file:t()]) -> [t()] | t(). 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), 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(t()) -> t(). 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); resolve_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 :: [t()] | t()) -> [t()] | t(). 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_rule:t()]. merge_rules(UserRules0, DefaultRules) -> UserRules = filter_deprecated_rules(UserRules0), UnduplicatedRules = % Drops repeated rules % If any default rule is in UserRules it means the user lists:filtermap( % wants to override the rule. fun(Tuple) -> Rule = elvis_rule:from_tuple(Tuple), case not is_rule_override(Rule, UserRules) andalso not elvis_rule:disabled(Rule) of true -> {true, Rule}; false -> false end 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:filtermap( fun(Tuple) -> Rule = elvis_rule:from_tuple(Tuple), case elvis_rule:disabled(Rule) of false -> {true, Rule}; true -> false end end, UserRules ), UnduplicatedRules ++ OverrideRules. filter_deprecated_rules(RuleTuples) -> lists:filter( fun(RuleTuple) -> case elvis_rule:is_valid_from_tuple(RuleTuple) of {deprecated, _} -> false; _ -> true end end, RuleTuples ). -spec is_rule_override( Rule :: elvis_rule:t(), UserRules :: [{NS :: module(), Name :: atom()}] ) -> boolean(). is_rule_override(Rule, UserRules) -> lists:any( fun(UserRule) -> elvis_rule:same(Rule, elvis_rule:from_tuple(UserRule)) end, UserRules ). -spec validate_config(term()) -> ok. validate_config(ElvisConfig) -> do_validate({config, ElvisConfig}). get_elvis_opt(OptName, Elvis) -> proplists:get_value(OptName, Elvis, default_for(OptName)). elvis_control_opts() -> [output_format, verbose, no_output, parallel]. do_validate({elvis = Option, Elvis}) -> case check_flag(validation_started(Option)) of true -> ok; _ -> maybe ok = flag(validation_started(Option)), ok ?= is_nonempty_list(elvis, Elvis), ok ?= proplist_keys_are_in( elvis, Elvis, elvis_control_opts() ++ [rulesets, config] ), OutputFormat = get_elvis_opt(output_format, Elvis), ok ?= is_one_of('elvis.output_format', OutputFormat, [colors, plain, parsable]), Verbose = get_elvis_opt(verbose, Elvis), ok ?= is_boolean('elvis.verbose', Verbose), NoOutput = get_elvis_opt(no_output, Elvis), ok ?= is_boolean('elvis.no_output', NoOutput), Parallel = get_elvis_opt(parallel, Elvis), ok ?= is_pos_integer('elvis.parallel', Parallel), CustomRulesets = get_elvis_opt(rulesets, Elvis), ok ?= are_valid_rulesets('elvis.rulesets', CustomRulesets), ElvisConfig = get_elvis_opt(config, Elvis), ok ?= is_valid_config('elvis.config', maps:keys(CustomRulesets), ElvisConfig) else {error, FormatData} -> do_validate_throw(FormatData) end end; do_validate({config = Option, ElvisConfig}) -> case check_flag(validation_started(Option)) of true -> ok; _ -> maybe ok ?= is_valid_config('elvis.config', elvis_ruleset:custom_names(), ElvisConfig) else {error, FormatData} -> do_validate_throw(FormatData) end end. -spec do_validate_throw(_) -> no_return(). do_validate_throw(FormatData) -> {Format, Data} = case is_list(FormatData) of false -> FormatData; true -> % Get only first result, for now % If we wanna be smarter we need to start concatenating readable strings [{Format0, Data0} | _] = FormatData, {Format0, Data0} end, throw({invalid_config, lists:flatten(io_lib:format(Format, Data))}). is_nonempty_list(What, List) when not is_list(List) orelse List =:= [] -> {error, {"'~s' is expected to exist and be a non-empty list.", [What]}}; is_nonempty_list(_What, _List) -> ok. proplist_keys_are_in(What, List, Keys) -> Filtered = [Element || {Element, _} <- List, not lists:member(Element, Keys)], case Filtered of [] -> ok; _ -> {error, {"in '~s', the following keys are unknown: ~s.", [ What, elvis_utils:list_to_str(Filtered) ]}} end. is_one_of(What, Value, Possibilities) -> case lists:member(Value, Possibilities) of true -> ok; _ -> {error, {"'~s' is expected to be one of the following: ~s.", [ What, elvis_utils:list_to_str(Possibilities) ]}} end. is_boolean(_What, Value) when is_boolean(Value) -> ok; is_boolean(What, _Value) -> {error, {"'~s' is expected to be a boolean.", [What]}}. is_pos_integer(_What, Value) when is_integer(Value) andalso Value > 0 -> ok; is_pos_integer(What, _Value) -> {error, {"'~s' is expected to be a positive integer.", [What]}}. are_valid_rulesets(What, CustomRulesets) -> maybe ok ?= is_map(What, CustomRulesets), ok ?= all_map_keys_are_atoms(What, CustomRulesets), ok ?= all_custom_rulesets_have_valid_rules(What, CustomRulesets), ok ?= no_default_ruleset_override(What, CustomRulesets) else {error, FormatData} -> {error, FormatData} end. is_map(_What, Value) when is_map(Value) -> ok; is_map(What, _Value) -> {error, {"'~s' is expected to be a map.", [What]}}. all_map_keys_are_atoms(What, Map) -> Filtered = [Key || Key <- maps:keys(Map), not is_atom(Key)], case Filtered of [] -> ok; _ -> {error, {"in '~s', keys are expected to be atoms.", [What]}} end. all_custom_rulesets_have_valid_rules(What, CustomRulesets) -> AccOut = maps:fold( fun(CustomRuleset, RuleTuples, AccInO) -> lists:foldl( fun(RuleTuple, AccInI) -> case elvis_rule:is_valid_from_tuple(RuleTuple) of {true, _Rule} -> AccInI; {deprecated, Msg} -> elvis_utils:warn("~s Skipping.", [Msg]), AccInI; {false, ValidError} -> [ {"in '~s', in ruleset '~s', " ++ ValidError, [What, CustomRuleset]} | AccInI ] end end, AccInO, RuleTuples ) end, [], CustomRulesets ), case AccOut of [] -> ok; _ -> {error, lists:reverse(AccOut)} end. no_default_ruleset_override(What, CustomRulesets) -> Filtered = [ CustomRuleset || CustomRuleset <- maps:keys(CustomRulesets), elvis_ruleset:is_defined(CustomRuleset) ], case Filtered of [] -> ok; _ -> {error, { "in '~s', the following rulesets are not expected to be " "named after a default ruleset: ~s.", [ What, elvis_utils:list_to_str(Filtered) ] }} end. is_valid_config(What, CustomRulesetNames, Configset0) -> maybe ok ?= is_nonempty_list(What, Configset0), Configset = wrap_in_list(Configset0), ok ?= all_configs_are_valid(What, CustomRulesetNames, Configset) else {error, FormatData} -> {error, FormatData} end. wrap_in_list(Term) when is_list(Term) -> Term; wrap_in_list(Term) -> [Term]. all_configs_are_valid(What, CustomRulesetNames, Configset) -> {_PosNumber, ValidErrors} = lists:foldl( fun(Config, {PosNumber, AccIn}) -> AccOut = case config_is_valid(CustomRulesetNames, Config) of ok -> AccIn; {error, ValidError} -> [ {"in '~s', at list position number ~w, " ++ ValidError, [ What, PosNumber ]} | AccIn ] end, {PosNumber + 1, AccOut} end, {1, []}, Configset ), case ValidErrors of [] -> ok; _ -> {error, lists:reverse(ValidErrors)} end. get_config_opt(OptName, Config) -> maps:get(OptName, Config, default_for([config, OptName])). config_is_valid(CustomRulesetNames, Config) -> maybe % We're keeping files, for the time being, because of elvis % and the fact it knows about elvis_core's internals, but we % should shortly revisit this ok ?= map_keys_are_in(Config, [dirs, filter, ignore, ruleset, rules, files]), Filter = get_config_opt(filter, Config), ok ?= is_nonempty_string(filter, Filter), Dirs = get_config_opt(dirs, Config), ok ?= all_dirs_filter_combos_are_valid(Dirs, Filter), Ignore = get_config_opt(ignore, Config), ok ?= is_list_of_ignorables(ignore, Ignore), Ruleset = get_config_opt(ruleset, Config), ok ?= defined_ruleset_is_custom_or_default(CustomRulesetNames, Ruleset), Rules = get_config_opt(rules, Config), ok ?= all_rules_are_valid(rules, Rules), ok ?= either_rules_is_nonempty_or_ruleset_is_defined(Rules, Ruleset) else {error, ValidError} -> {error, ValidError} end. map_keys_are_in(Map, _Keys) when not is_map(Map) -> {error, "element is expected to be a map."}; map_keys_are_in(Map, Keys) -> Filtered = [Key || Key <- maps:keys(Map), not lists:member(Key, Keys)], case Filtered of [] -> ok; _ -> {error, io_lib:format("the following keys are unknown: ~s.", [ elvis_utils:list_to_str(Filtered) ])} end. is_nonempty_string(What, String) -> case io_lib:char_list(String) andalso length(String) > 0 of true -> ok; _ -> {error, io_lib:format("'~s' is expected to be a non-empty string.", [What])} end. all_dirs_filter_combos_are_valid(Dirs, Filter) -> case lists:any(fun(Dir) -> filelib:wildcard(filename:join(Dir, Filter)) =/= [] end, Dirs) of true -> ok; false -> {error, io_lib:format( "no '' + '' combo in ~s + '~s' yielded any files to analyse.", [elvis_utils:list_to_str(Dirs), Filter] )} end. is_list_of_ignorables(What, List) when not is_list(List) -> {error, io_lib:format("'~s' is expected to be a list.", [What])}; is_list_of_ignorables(What, List) -> Filtered = [Element || Element <- List, not elvis_rule:is_ignorable(Element)], case Filtered of [] -> ok; _ -> {error, io_lib:format("in '~s', the following elements are not ignorable: ~s.", [ What, elvis_utils:list_to_str(Filtered) ])} end. defined_ruleset_is_custom_or_default(_CustomRulesetNames, undefined = _Ruleset) -> ok; defined_ruleset_is_custom_or_default(CustomRulesetNames, Ruleset) -> case lists:member(Ruleset, CustomRulesetNames) orelse elvis_ruleset:is_defined(Ruleset) of true -> ok; _ -> {error, io_lib:format("'~s' is expected to be either a custom or a default ruleset.", [ Ruleset ])} end. all_rules_are_valid(What, RuleTuples) when not is_list(RuleTuples) -> {error, io_lib:format("'~s' is expected to be a list.", [What])}; all_rules_are_valid(What, RuleTuples) -> AccOut = lists:foldl( fun(RuleTuple, AccInI) -> case elvis_rule:is_valid_from_tuple(RuleTuple) of {true, Rule} -> check_rule_for_options(Rule, AccInI); {deprecated, Msg} -> elvis_utils:warn("~s Skipping.", [Msg]), AccInI; {false, ValidError} -> [io_lib:format("in '~s', " ++ ValidError, [What]) | AccInI] end end, [], RuleTuples ), case AccOut of [] -> ok; _ -> {error, lists:reverse(AccOut)} end. either_rules_is_nonempty_or_ruleset_is_defined([_ | _] = _Rules, _Ruleset) -> ok; either_rules_is_nonempty_or_ruleset_is_defined(_Rules, Ruleset) when Ruleset =/= undefined -> ok; either_rules_is_nonempty_or_ruleset_is_defined(_Rules, _Ruleset) -> {error, io_lib:format( "each config section is expected to have either" " a non-empty list of 'rules' or a 'ruleset'.", [] )}. check_rule_for_options(Rule, AccInI) -> case elvis_rule:defkeys(Rule) of [] -> % No further validation possible. AccInI; DefKeysInput -> NS = elvis_rule:ns(Rule), Name = elvis_rule:name(Rule), % Bypass new/ constraints. DefKeys = [ignore | maps:keys(NS:default(Name))], case DefKeysInput -- DefKeys of [] -> AccInI; Extra -> [ io_lib:format( "in rule ~w/~w, the following options are unknown: ~s.", [NS, Name, elvis_utils:list_to_str(Extra)] ), AccInI ] end end. flag({_Option, _What} = Obj) -> _ = ensure_ets_table(), true = ets:insert(elvis_config, Obj), ok. check_flag({Option, _What} = Obj) -> try ets:lookup(elvis_config, Option) of Found -> [Obj] =:= Found catch _:badarg -> false end. validation_started(Option) -> {Option, validation_started}. ensure_ets_table() -> case ets:info(elvis_config) of undefined -> _ = ets:new(elvis_config, [public, named_table]); _ -> ok end.