src/rules/unused_configuration_options.erl

%% @doc A rule to detect unused configuration options
%%      It will find options that are no longer used around the code:
%%      - All the options from the *.config files
%%         (excepting rebar.config, elvis.config and relx.config)
%%      - The env list inside any *.app.src files
%%      <p>To avoid this warning, remove the unused parameters.</p>
%%
%%      <h3>Note</h3>
%%      <blockquote>
%%      For this rule to apply, it's assumed that configuration options for an
%%      Erlang application are only consumed within said Erlang application or
%%      the other applications in the same umbrella project.
%%      If you have a dependency that consumes an environment parameter from one
%%      of your project applications, you can add an ignore rule in rebar.config
%%      for it.
%%      </blockquote>
-module(unused_configuration_options).

-behaviour(hank_rule).

-export([analyze/2, ignored/2]).

-define(IGNORED_FILES, ["rebar.config", "elvis.config", "relx.config"]).

%% @doc Detects unused config options.
%%      It gets the options from .config and .app.src files and then:
%% <ol>
%%      <li>Builds an index with file/options.</li>
%%      <li>Gets the atoms used around the .erl and .hrl files.</li>
%%      <li>Calculates the unused atoms (options) and return the results.</li>
%% </ol>
-spec analyze(hank_rule:asts(), hank_context:t()) -> [hank_rule:result()].
analyze(FilesAndASTs, Context) ->
    % get the config options (keys) by file
    ConfigOptionsByFile =
        [{File, config_options(File, Context)}
         || {File, _AST} <- FilesAndASTs,
            filename:extension(File) == ".config" orelse filename:extension(File) == ".src",
            not is_ignored(File)],

    % get just the options (keys) to search usages
    ConfigOptions = extract_options(ConfigOptionsByFile),

    % get all the options used by the .erl/.hrl files
    Uses =
        [UsedOption
         || {File, AST} <- FilesAndASTs,
            filename:extension(File) == ".erl" orelse filename:extension(File) == ".hrl",
            UsedOption <- options_usage(AST, ConfigOptions)],

    % calculate the unused options
    UnusedOptions = ConfigOptions -- lists:usort(Uses),

    % build results
    [result(File, Option)
     || {File, Options} <- ConfigOptionsByFile,
        Option <- Options,
        lists:member(Option, UnusedOptions)].

%% @doc It receives a file path and returns a list of options
%% It's prepared for .config and .app.src files, which contain Erlang Terms
%% If the file cannot be parsed, it will be ignored (like other user's .config files)
-spec config_options(file:filename(), hank_context:t()) -> [atom()].
config_options(File, Context) ->
    case file:consult(File) of
        {ok, [ErlangTerms]} ->
            case is_app_src_file(File) of
                true ->
                    {application, _AppName, Options} = ErlangTerms,
                    EnvOptions = proplists:get_value(env, Options, []),
                    proplists:get_keys(EnvOptions);
                false ->
                    config_keys(ErlangTerms, Context)
            end;
        _ ->
            %% error parsing: ignore the file!
            []
    end.

%% @doc Get all the config keys of the project_apps only.
%% If ConfigTuples is a tuple (a one-tuple config file), it is converted into a proplist.
%% When ConfigTuples files contain more than one tuple, they are parsed as a proplist.
%% When ConfigTuples are not actually tuples, we just ignore the file.
config_keys(ConfigTuples, Context) when is_tuple(ConfigTuples) ->
    config_keys([ConfigTuples], Context);
config_keys(ConfigTuples, Context) when is_list(ConfigTuples) ->
    [Key
     || {AppName, Proplist} <- ConfigTuples,
        lists:member(AppName, hank_context:project_apps(Context)),
        Key <- proplists:get_keys(Proplist)];
config_keys(_NotTuples, _Context) ->
    [].

is_app_src_file(File) ->
    filename:extension(File) == ".src".

extract_options(OptionsByFile) ->
    lists:usort([Option || {_File, FileOptions} <- OptionsByFile, Option <- FileOptions]).

options_usage(_AST, []) ->
    [];
options_usage(AST, Options) ->
    [Option || Node <- AST, Option <- Options, hank_utils:node_has_atom(Node, Option)].

is_ignored(File) ->
    lists:member(
        filename:basename(File), ?IGNORED_FILES).

result(File, Option) ->
    #{file => File,
      line => 0,
      text => hank_utils:format_text("~tw is not used anywhere in the code", [Option]),
      pattern => Option}.

%% @doc Rule ignore specifications.
%%      Only valid in rebar.config since attributes are not allowed in config files.
%%      Example:
%%      <pre>
%%      {hank, [{ignore, [
%%          {"this_file.config", unused_configuration_options, [ignore_option]}
%%      ]}]}.
%%      </pre>
-spec ignored(hank_rule:ignore_pattern(), term()) -> boolean().
ignored(Option, Option) ->
    true;
ignored(_, _) ->
    false.