src/rules/single_use_hrl_attrs.erl

%% @doc A rule to detect hrl attributes used in just one module:
%%      Attributes supported:
%%      -define
%%      -record
%%      It will suggest to place those attributes inside the module to avoid
%%      having (and including) a hrl file.
%%
%%      <h3>Note</h3>
%%      <blockquote>
%%      This rule assumes that hrl files will not be used outside your project.
%%      If you are writing a library that requires your clients to use some of
%%      your header files and attributes, you can add an ignore rule in
%%      rebar.config for it.
%%      </blockquote>
-module(single_use_hrl_attrs).

-behaviour(hank_rule).

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

%% @doc This builds a list of header files with its attributes.
%%      Then traverse the file ASTs mapping their macros and records
%%      And checks whether they were used just once.
-spec analyze(hank_rule:asts(), hank_context:t()) -> [hank_rule:result()].
analyze(FilesAndASTs, _Context) ->
    HrlDefs = hrl_attrs(FilesAndASTs),
    AttributesUsed = lists:foldl(fun file_using/2, #{}, FilesAndASTs),
    [build_macro_result(HrlFile, MacroKey, AttributesUsed)
     || {HrlFile, #{define := Defines}} <- HrlDefs,
        MacroKey <- Defines,
        is_used_only_once(HrlFile, MacroKey, AttributesUsed)]
    ++ [build_record_result(HrlFile, RecordKey, AttributesUsed)
        || {HrlFile, #{record := Records}} <- HrlDefs,
           RecordKey <- Records,
           is_used_only_once(HrlFile, RecordKey, AttributesUsed)].

build_macro_result(HrlFile, {Macro, Line}, AttributesUsed) ->
    [File] = maps:get(Macro, AttributesUsed),
    Text =
        case Macro of
            {MacroName, none} ->
                hank_utils:format_text("?~ts is used only at ~ts", [MacroName, File]);
            {MacroName, MacroArity} ->
                hank_utils:format_text("?~ts/~tp is used only at ~ts",
                                       [MacroName, MacroArity, File])
        end,
    #{file => HrlFile,
      line => Line,
      text => Text,
      pattern => Macro}.

build_record_result(HrlFile, {Record, Line}, AttributesUsed) ->
    [File] = maps:get(Record, AttributesUsed),
    #{file => HrlFile,
      line => Line,
      text => hank_utils:format_text("#~tp is used only at ~ts", [Record, File]),
      pattern => Record}.

is_used_only_once(HrlFile, {Key, _Line}, AttributesUsed) ->
    case maps:get(Key, AttributesUsed, []) of
        [SingleFile] ->
            %% There is nothing wrong with using an attribute only in the same
            %% file where it's defined.
            SingleFile /= HrlFile;
        _ ->
            false
    end.

file_using({File, FileAST}, CurrentFiles) ->
    AddFun = fun(Files) -> lists:usort([File | Files]) end,
    FoldFun =
        fun(Node, Result) ->
           case erl_syntax:type(Node) of
               macro ->
                   Key = macro_application_name(Node),
                   maps:update_with(Key, AddFun, [File], Result);
               Attr
                   when Attr =:= record_expr;
                        Attr =:= record_access;
                        Attr =:= record_index_expr;
                        Attr =:= record_type ->
                   Key = record_name(Node, Attr),
                   maps:update_with(Key, AddFun, [File], Result);
               attribute ->
                   case hank_utils:attr_name(Node) of
                       ControlFlowAttr
                           when ControlFlowAttr == ifdef;
                                ControlFlowAttr == ifndef;
                                ControlFlowAttr == undef ->
                           Key = macro_control_flow_name(Node),
                           maps:update_with(Key, AddFun, [File], Result);
                       _ ->
                           Result
                   end;
               _ ->
                   Result
           end
        end,
    erl_syntax_lib:fold(FoldFun, CurrentFiles, erl_syntax:form_list(FileAST)).

%% @doc It collects the hrl attrs like {file, [attrs]}
hrl_attrs(FilesAndASTs) ->
    [{File, attrs(AST)} || {File, AST} <- FilesAndASTs, filename:extension(File) == ".hrl"].

%% @doc A map with #{define => [], record => []} for each hrl tree
attrs(AST) ->
    FoldFun =
        fun(Node, #{define := Defines, record := Records} = Acc) ->
           case erl_syntax:type(Node) of
               attribute ->
                   case hank_utils:attr_name(Node) of
                       define ->
                           maps:put(define,
                                    [{hank_utils:macro_definition_name(Node), line(Node)}
                                     | Defines],
                                    Acc);
                       record ->
                           maps:put(record,
                                    [{record_definition_name(Node), line(Node)} | Records],
                                    Acc);
                       _ ->
                           Acc
                   end;
               _ ->
                   Acc
           end
        end,
    erl_syntax_lib:fold(FoldFun, #{define => [], record => []}, erl_syntax:form_list(AST)).

macro_control_flow_name(Node) ->
    macro_application_name(hank_utils:macro_from_control_flow_attr(Node)).

macro_application_name(Node) ->
    {hank_utils:macro_name(Node), hank_utils:macro_arity(Node)}.

record_definition_name(Node) ->
    try erl_syntax_lib:analyze_record_attribute(Node) of
        {RecordName, _} ->
            RecordName
    catch
        _:syntax_error ->
            %% There is a macro in the record definition
            ""
    end.

record_name(Node, Type) ->
    RecordName =
        case Type of
            record_expr ->
                erl_syntax:record_expr_type(Node);
            record_index_expr ->
                erl_syntax:record_index_expr_type(Node);
            record_access ->
                erl_syntax:record_access_type(Node);
            record_type ->
                erl_syntax:record_type_name(Node)
        end,
    erl_syntax:atom_value(RecordName).

line(Node) ->
    hank_utils:node_line(Node).

%% @doc Rule ignore specifications. Example:
%%      <pre>
%%      -hank([{single_use_hrl_attrs,
%%              ["ALL",          %% Will ignore ?ALL, ?ALL() and ?ALL(X)
%%               {"ZERO", 0},    %% Will ignore ?ZERO() but not ?ZERO(X) nor ?ZERO
%%               {"ONE",  1},    %% Will ignore ?ONE(X) but not ?ONE()   nor ?ONE
%%               {"NONE", none}, %% Will ignore ?NONE but not ?NONE(X) nor ?NONE()
%%               record_name     %% Will ignore #record_name
%%              ]},
%%      </pre>
-spec ignored(hank_rule:ignore_pattern(), term()) -> boolean().
ignored({MacroName, Arity}, {MacroName, Arity}) ->
    true;
ignored({MacroName, _Arity}, MacroName) ->
    true;
ignored(RecordName, RecordName) ->
    true;
ignored(_Pattern, _IgnoreSpec) ->
    false.