src/rules/unused_record_fields.erl

%% @doc A rule to detect unused record fields.
%%      <p>The rule will detect fields that are defined as part of a record but
%%      never actually used anywhere.</p>
%%      <p>To avoid this warning, remove the unused record fields.</p>
%%      Note that for header files, this rule will fail to detect some unused
%%      fields. Particularly, in the case where you have an unused field defined
%%      in a header file and another record with the same name and the same field
%%      defined somewhere else that is used.
%%      Since determining precisely what files are included in each -include
%%      attribute is not trivial, Hank will act conservatively and not make
%%      any effort to verify where each record field that's used is defined.
%%      So, if you have a project with multiple definitions of the same record
%%      with the same field... well... as long as one of them is used, none of
%%      them will be reported as unused.
%%
%%      <h3>Note</h3>
%%      <blockquote>
%%      This rule assumes that your code will never use the underlying tuple
%%      structure of your records directly.
%%      If you do so, you can add an ignore rule in rebar.config for it.
%%      </blockquote>
%% @todo Don't count record construction as usage [https://github.com/AdRoll/rebar3_hank/issues/35]
-module(unused_record_fields).

-behaviour(hank_rule).

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

%% @private
-spec analyze(hank_rule:asts(), hank_context:t()) -> [hank_rule:result()].
analyze(FilesAndASTs, _Context) ->
    Parsed = lists:map(fun field_usage/1, FilesAndASTs),
    AllUsedRecords = [UsedRecord || #{used_records := Used} <- Parsed, UsedRecord <- Used],
    AllUsedFields = [UsedField || #{used_fields := Used} <- Parsed, UsedField <- Used],
    [Result
     || #{file := File,
          record_definitions := RecordDefinitions,
          defined_fields := DefinedFields,
          used_records := UsedRecords,
          used_fields := UsedFields}
            <- Parsed,
        Result
            <- analyze(File,
                       RecordDefinitions,
                       DefinedFields,
                       UsedRecords,
                       UsedFields,
                       AllUsedRecords,
                       AllUsedFields)].

field_usage({File, AST}) ->
    FoldFun =
        fun(Node, {Records, Usage}) ->
           case erl_syntax:type(Node) of
               attribute ->
                   case hank_utils:attr_name(Node) of
                       record ->
                           {[Node | Records], Usage};
                       _ ->
                           {Records, Usage}
                   end;
               record_expr ->
                   {Records, [Node | Usage]};
               record_access ->
                   {Records, [Node | Usage]};
               record_index_expr ->
                   {Records, [Node | Usage]};
               _ ->
                   % Ignored: record_field, typed_record_field, record_type, record_type_field
                   {Records, Usage}
           end
        end,
    {RecordDefinitions, RecordUsage} =
        erl_syntax_lib:fold(FoldFun, {[], []}, erl_syntax:form_list(AST)),
    DefinedFields =
        [{RecordName, FieldName}
         || Node <- RecordDefinitions, {RecordName, FieldName} <- analyze_record_attribute(Node)],
    {UsedRecords, UsedFields} =
        lists:foldl(fun(Node, {URs, UFs}) ->
                       case analyze_record_expr(Node) of
                           {RecordName, all_fields} ->
                               {[RecordName | URs], UFs};
                           Fields ->
                               {URs, Fields ++ UFs}
                       end
                    end,
                    {[], []},
                    RecordUsage),
    #{file => File,
      record_definitions => RecordDefinitions,
      defined_fields => DefinedFields,
      used_records => UsedRecords,
      used_fields => UsedFields}.

analyze(File,
        RecordDefinitions,
        DefinedFields,
        UsedRecords,
        UsedFields,
        AllUsedRecords,
        AllUsedFields) ->
    case filename:extension(File) of
        ".erl" ->
            analyze(File, RecordDefinitions, DefinedFields, UsedRecords, UsedFields);
        ".hrl" ->
            analyze(File, RecordDefinitions, DefinedFields, AllUsedRecords, AllUsedFields);
        _ ->
            []
    end.

analyze(File, RecordDefinitions, DefinedFields, UsedRecords, UsedFields) ->
    [result(File, RecordName, FieldName, RecordDefinitions)
     || {RecordName, FieldName} <- DefinedFields -- UsedFields,
        not lists:member(RecordName, UsedRecords)].

analyze_record_attribute(Node) ->
    try erl_syntax_lib:analyze_record_attribute(Node) of
        {RecordName, Fields} ->
            [{RecordName, FieldName} || {FieldName, _} <- Fields]
    catch
        _:syntax_error ->
            %% There is a macro in the record definition
            []
    end.

analyze_record_expr(Node) ->
    try erl_syntax_lib:analyze_record_expr(Node) of
        {record_expr, {RecordName, Fields}} ->
            [{RecordName, FieldName} || {FieldName, _} <- Fields];
        {_, {RecordName, FieldName}} ->
            [{RecordName, FieldName}]
    catch
        _:syntax_error ->
            %% Probably the record expression uses stuff like #rec{_ = '_'} or Macros
            RecordName =
                case erl_syntax:type(Node) 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)
                end,
            {erl_syntax:atom_value(RecordName), all_fields}
    end.

result(File, RecordName, FieldName, RecordDefinitions) ->
    L = case find_record_definition(RecordName, RecordDefinitions) of
            false ->
                0;
            {value, RecordDefinition} ->
                [_, RecordFields] = erl_syntax:attribute_arguments(RecordDefinition),
                case find_record_field(FieldName, erl_syntax:tuple_elements(RecordFields)) of
                    false ->
                        hank_utils:node_line(RecordDefinition);
                    {value, FieldDefinition} ->
                        hank_utils:node_line(FieldDefinition)
                end
        end,
    #{file => File,
      line => L,
      text =>
          hank_utils:format_text("Field ~tp in record ~tp is unused", [FieldName, RecordName]),
      pattern => {RecordName, FieldName}}.

find_record_definition(RecordName, Definitions) ->
    lists:search(fun(Definition) ->
                    case erl_syntax:attribute_arguments(Definition) of
                        [RN | _] ->
                            erl_syntax:type(RN) == atom
                            andalso erl_syntax:atom_value(RN) == RecordName;
                        [] ->
                            false
                    end
                 end,
                 Definitions).

find_record_field(FieldName, Definitions) ->
    lists:search(fun(Definition) ->
                    {FN, _} = erl_syntax_lib:analyze_record_field(Definition),
                    FN == FieldName
                 end,
                 Definitions).

%% @doc Rule ignore specifications. Example:
%%      <pre>
%%      -hank([{unused_record_fields,
%%               [a_record, %% Will ignore all fields in #a_record
%%                {a_record, a_field} %% Will ignore #a_record.a_field
%%               ]}]).
%%      </pre>
-spec ignored(hank_rule:ignore_pattern(), term()) -> boolean().
ignored({RecordName, FieldName}, {RecordName, FieldName}) ->
    true;
ignored({RecordName, _FieldName}, RecordName) ->
    true;
ignored(_Pattern, _IgnoreSpec) ->
    false.