src/hank.erl

%%% @doc The Erlang Dead Code Cleaner
-module(hank).

%% It's dynamically called through rpc:pmap/3
-ignore_xref([get_ast/1]).

-export([analyze/5]).
-export([get_ast/1]).

-type ms() :: non_neg_integer().
-type stats() ::
    #{ignored := non_neg_integer(),
      parsing := ms(),
      analyzing => ms(),
      total => ms()}.
-type parsing_style() :: parallel | sequential.

-export_type([stats/0, parsing_style/0]).

%% @doc Runs a list of rules over a list of files and returns all the
%%      dead code pieces it can find.
-spec analyze([file:filename()],
              [hank_rule:ignore_spec()],
              [hank_rule:t()],
              parsing_style(),
              hank_context:t()) ->
                 #{results := [hank_rule:result()],
                   unused_ignores := [hank_rule:ignore_spec()],
                   stats := stats()}.
analyze(Files, IgnoreSpecsFromState, Rules, ParsingStyle, Context) ->
    StartMs = erlang:monotonic_time(millisecond),
    {ParsingNanos, ASTs} = timer:tc(fun() -> get_asts(Files, ParsingStyle) end),
    FilesAndASTs = lists:zip(Files, ASTs),
    HankAttributes =
        [{File, Attribute} || {File, AST} <- FilesAndASTs, Attribute <- hank_attributes(AST)],
    IgnoreRulesFromAST =
        [{File, IgnoreRule, IgnoreSpecs}
         || {File, Attribute} <- HankAttributes,
            not lists:member({File, all, []}, IgnoreSpecsFromState),
            {IgnoreRule, IgnoreSpecs} <- normalize_ignored_rules(Attribute, Rules)],
    IgnoreRulesFromConfig =
        [{File, Rule, Options}
         || {File, IgnoreRule, Options} <- IgnoreSpecsFromState,
            Rule <- Rules,
            IgnoreRule == all orelse IgnoreRule == Rule],
    WhollyIgnoredFiles =
        lists:usort([File || {File, all, _Options} <- IgnoreSpecsFromState]
                    ++ [File || {File, ignore} <- HankAttributes]),
    IgnoreRules = IgnoreRulesFromAST ++ IgnoreRulesFromConfig,
    erlang:yield(),
    {AnalyzingNanos, AllResults} =
        timer:tc(fun() -> analyze(Rules, FilesAndASTs, Context) end),
    {Results, Ignored} = remove_ignored_results(AllResults, IgnoreRules),
    UnusedIgnores = unused_ignore_specs(WhollyIgnoredFiles, IgnoreRules, Ignored),
    TotalMs = erlang:monotonic_time(millisecond) - StartMs,
    #{results => Results,
      unused_ignores => UnusedIgnores,
      stats =>
          #{ignored => length(Ignored),
            parsing => ParsingNanos div 1000,
            analyzing => AnalyzingNanos div 1000,
            total => TotalMs}}.

-spec get_asts([file:filename()], parsing_style()) -> [erl_syntax:forms()].
get_asts(Files, parallel) ->
    rpc:pmap({?MODULE, get_ast}, [], Files);
get_asts(Files, sequential) ->
    lists:map(fun get_ast/1, Files).

%% @hidden Only used through rpc:pmap/3
-spec get_ast(file:filename()) -> erl_syntax:forms().
get_ast(File) ->
    case ktn_dodger:parse_file(File, [no_fail, parse_macro_definitions]) of
        {ok, AST} ->
            AST;
        {error, OpenError} ->
            erlang:error({cant_parse, File, OpenError})
    end.

hank_attributes(AST) ->
    FoldFun =
        fun(Node, Acc) ->
           case erl_syntax:type(Node) of
               attribute ->
                   try erl_syntax_lib:analyze_wild_attribute(Node) of
                       {hank, Something} ->
                           [Something | Acc];
                       _ ->
                           Acc
                   catch
                       _:_ ->
                           Acc
                   end;
               _ ->
                   Acc
           end
        end,
    erl_syntax_lib:fold(FoldFun, [], erl_syntax:form_list(AST)).

normalize_ignored_rules(ignore, Rules) ->
    lists:map(fun normalize_ignored_rule/1, Rules);
normalize_ignored_rules(RulesToIgnore, _) ->
    lists:map(fun normalize_ignored_rule/1, RulesToIgnore).

normalize_ignored_rule(Rule) when is_atom(Rule) ->
    {Rule, all};
normalize_ignored_rule({Rule, Specs}) ->
    {Rule, Specs}.

remove_ignored_results(AllResults, IgnoreRules) ->
    remove_ignored_results(AllResults, IgnoreRules, {[], []}).

remove_ignored_results([], _, {FilteredResults, IgnoredResults}) ->
    {lists:reverse(FilteredResults), lists:reverse(IgnoredResults)};
remove_ignored_results([Result | Results],
                       IgnoreRules,
                       {FilteredResults, IgnoredResults}) ->
    #{file := File,
      rule := Rule,
      pattern := Pattern} =
        Result,
    IgnoreSpecs = ignore_specs(File, Rule, IgnoreRules),
    NewAcc =
        case lists:search(fun(IgnoreSpec) -> hank_rule:is_ignored(Rule, Pattern, IgnoreSpec) end,
                          IgnoreSpecs)
        of
            {value, IgnoreSpec} ->
                {FilteredResults, [Result#{ignore_spec => IgnoreSpec} | IgnoredResults]};
            false ->
                {[Result | FilteredResults], IgnoredResults}
        end,
    remove_ignored_results(Results, IgnoreRules, NewAcc).

ignore_specs(File, Rule, IgnoreRules) ->
    Fun = fun ({File0, Rule0, Specs}, IgnoreSpecs)
                  when File =:= File0 andalso Rule =:= Rule0 ->
                  case is_list(Specs) of
                      true ->
                          Specs ++ IgnoreSpecs;
                      false ->
                          [Specs | IgnoreSpecs]
                  end;
              (_, IgnoreSpecs) ->
                  IgnoreSpecs
          end,
    lists:foldl(Fun, [], IgnoreRules).

unused_ignore_specs(WhollyIgnoredFiles, IgnoreRules, IgnoredResults) ->
    FilteredIgnoreRules =
        [IR || IR = {File, _, _} <- IgnoreRules, not lists:member(File, WhollyIgnoredFiles)],
    lists:foldl(fun ({File, Rule, all}, Acc) ->
                        case unused_ignored_spec(File, Rule, all, IgnoredResults) of
                            true ->
                                [{File, Rule, all} | Acc];
                            false ->
                                Acc
                        end;
                    ({File, Rule, Specs}, Acc) ->
                        case [Spec
                              || Spec <- Specs,
                                 unused_ignored_spec(File, Rule, Spec, IgnoredResults)]
                        of
                            [] ->
                                Acc;
                            UnusedSpecs ->
                                [{File, Rule, UnusedSpecs} | Acc]
                        end
                end,
                [],
                FilteredIgnoreRules).

unused_ignored_spec(File, Rule, Spec, IgnoredResults) ->
    not
        lists:any(fun(#{file := File0,
                        rule := Rule0,
                        ignore_spec := Spec0}) ->
                     File == File0 andalso Rule == Rule0 andalso Spec == Spec0
                  end,
                  IgnoredResults).

analyze(Rules, ASTs, Context) ->
    [Result || Rule <- Rules, Result <- hank_rule:analyze(Rule, ASTs, Context)].