src/hank_utils.erl

%%% @doc Utility functions
-module(hank_utils).

%% Allow erl_syntax:syntaxTree/0 type spec
-elvis([{elvis_style, atom_naming_convention, #{regex => "^([a-zA-Z][a-z0-9]*_?)*$"}}]).

-export([macro_arity/1, macro_name/1, macro_definition_name/1, function_name/1,
         function_tuple/1, function_description/1, application_node_to_mfa/1,
         macro_from_control_flow_attr/1, attr_name/1, node_has_attrs/2, attr_args_concrete/2,
         is_old_test_suite/1, node_line/1, paths_match/2, format_text/2, node_has_atom/2]).

%% @doc Get the macro arity of given Node
-spec macro_arity(erl_syntax:syntaxTree()) -> none | pos_integer().
macro_arity(Node) ->
    case erl_syntax:macro_arguments(Node) of
        none ->
            none;
        Args ->
            length(Args)
    end.

%% @doc Get the parsed macro name of given Node
-spec macro_name(erl_syntax:syntaxTree()) -> unknown | string().
macro_name(Node) ->
    parse_node_name(erl_syntax:macro_name(Node)).

%% @doc Parse the given Node name
-spec parse_node_name(erl_syntax:syntaxTree()) -> unknown | string().
parse_node_name(Node) ->
    case erl_syntax:type(Node) of
        variable ->
            erl_syntax:variable_literal(Node);
        atom ->
            erl_syntax:atom_name(Node);
        macro ->
            parse_node_name(erl_syntax:macro_name(Node));
        _Other ->
            % Probably a case, a record field or some other block of code
            unknown
    end.

%% @doc Get the macro definition name and arity of a given Macro Node.
-spec macro_definition_name(erl_syntax:syntaxTree()) -> {string(), integer() | atom()}.
macro_definition_name(Node) ->
    [MacroNameNode | _] = erl_syntax:attribute_arguments(Node),
    case erl_syntax:type(MacroNameNode) of
        application ->
            Operator = erl_syntax:application_operator(MacroNameNode),
            MacroName = parse_node_name(Operator),
            MacroArity = length(erl_syntax:application_arguments(MacroNameNode)),
            {MacroName, MacroArity};
        variable ->
            {erl_syntax:variable_literal(MacroNameNode), none};
        atom ->
            {erl_syntax:atom_literal(MacroNameNode), none}
    end.

%% @doc Get the function name of a given Function Node.
-spec function_name(erl_syntax:syntaxTree()) -> string().
function_name(Node) ->
    FuncNameNode = erl_syntax:function_name(Node),
    case erl_syntax:type(FuncNameNode) of
        macro ->
            [$? | macro_name(FuncNameNode)];
        atom ->
            erl_syntax:atom_name(FuncNameNode)
    end.

%% @doc Get the function definition tuple {name, arity} of a given Function Node.
-spec function_tuple(erl_syntax:syntaxTree()) -> {atom(), pos_integer()}.
function_tuple(Node) ->
    {erlang:list_to_atom(function_name(Node)), erl_syntax:function_arity(Node)}.

%% @doc Get the function definition name and arity of a given Function Node.
-spec function_description(erl_syntax:syntaxTree()) -> string().
function_description(Node) ->
    FuncName = function_name(Node),
    FuncArity = erl_syntax:function_arity(Node),
    FuncName ++ [$/ | integer_to_list(FuncArity)].

%% @doc Returns a MFA tuple for given application node
-spec application_node_to_mfa(erl_syntax:syntaxTree()) ->
                                 undefined |
                                 {unknown | string(),
                                  unknown | string(),
                                  [erl_syntax:syntaxTree()]} |
                                 {string(), [erl_syntax:syntaxTree()]}.
application_node_to_mfa(Node) ->
    case erl_syntax:type(Node) of
        application ->
            Operator = erl_syntax:application_operator(Node),
            case erl_syntax:type(Operator) of
                module_qualifier ->
                    Module = erl_syntax:module_qualifier_argument(Operator),
                    Function = erl_syntax:module_qualifier_body(Operator),
                    {parse_node_name(Module),
                     parse_node_name(Function),
                     erl_syntax:application_arguments(Node)};
                atom ->
                    {erl_syntax:atom_name(Operator), erl_syntax:application_arguments(Node)};
                variable ->
                    {erl_syntax:variable_literal(Operator), erl_syntax:application_arguments(Node)};
                _ ->
                    undefined
            end;
        _ ->
            undefined
    end.

%% @doc Generates a macro from the variable that's used in a control flow attribute.
%%      e.g. returns ?MACRO if it receives -ifdef(MACRO).
-spec macro_from_control_flow_attr(erl_syntax:syntaxTree()) -> erl_syntax:syntaxTree().
macro_from_control_flow_attr(Node) ->
    [MacroName | _] = erl_syntax:attribute_arguments(Node),
    erl_syntax:macro(MacroName).

%% @doc Macro dodging version of erl_syntax:attribute_name/1
-spec attr_name(erl_syntax:syntaxTree()) -> atom() | string() | term().
attr_name(Node) ->
    N = erl_syntax:attribute_name(Node),
    try
        erl_syntax:concrete(N)
    catch
        _:_ ->
            N
    end.

%% @doc Whether the given Node node
%%      has defined the given AttrNames attribute names or not
-spec node_has_attrs(erl_syntax:syntaxTree(), atom() | [atom()]) -> boolean().
node_has_attrs(Node, AttrName) when not is_list(AttrName) ->
    node_has_attrs(Node, [AttrName]);
node_has_attrs(Node, AttrNames) ->
    erl_syntax:type(Node) == attribute andalso lists:member(attr_name(Node), AttrNames).

%% @doc Extract attribute arguments from given AST nodes list
%%      whose attribute name is AttrName and apply MapFunc to every element
-spec attr_args(erl_syntax:forms(), atom() | [atom()], function()) -> [term()].
attr_args(AST, AttrName, MapFunc) when not is_list(AttrName) ->
    attr_args(AST, [AttrName], MapFunc);
attr_args(AST, AttrNames, MapFunc) ->
    [MapFunc(AttrArg)
     || Node <- AST,
        node_has_attrs(Node, AttrNames),
        AttrArg <- erl_syntax:attribute_arguments(Node)].

%% @doc Same as attr_args/3 but calling erl_syntax:concrete/1 for each element
-spec attr_args_concrete(erl_syntax:forms(), atom() | [atom()]) -> [term()].
attr_args_concrete(AST, AttrName) ->
    attr_args(AST, AttrName, fun erl_syntax:concrete/1).

%% @doc Before OTP 23.2 test suites implemented an _implicit_ behavior.
%%      The only way to figure out that a module was actually a test suite was
%%      by its name.
-spec is_old_test_suite(file:filename()) -> boolean().
is_old_test_suite(File) ->
    code:which(ct_suite) == non_existing % OTP < 23.2
    andalso re:run(File, "_SUITE.erl$") /= nomatch.

%% @doc Returns the line number of the given node
-spec node_line(erl_syntax:syntaxTree()) ->
                   non_neg_integer() | {non_neg_integer(), pos_integer()}.
node_line(Node) ->
    erl_anno:location(
        erl_syntax:get_pos(Node)).

%% @doc Returns all the atoms found the given node list.
-spec node_atoms([erl_syntax:syntaxTree()]) -> [atom()].
node_atoms(Nodes) ->
    FoldFun =
        fun(Node, Atoms) ->
           case erl_syntax:type(Node) of
               atom ->
                   [Node | Atoms];
               macro ->
                   MacroName = erl_syntax:macro_name(Node),
                   case erl_syntax:type(MacroName) of
                       atom ->
                           %% Note that erl_syntax_lib:fold/3 works in a DFS manner.
                           %% That's why our macro-skipping trick works:
                           %%   it removes the atom that was previously introduced
                           %%   into the accumulator.
                           Atoms -- [MacroName];
                       _ ->
                           Atoms
                   end;
               _ ->
                   Atoms
           end
        end,
    AtomNodes = erl_syntax_lib:fold(FoldFun, [], erl_syntax:form_list(Nodes)),
    lists:usort(
        lists:map(fun erl_syntax:atom_value/1, AtomNodes)).

%% @doc Whether one of the given paths is contained inside the other one or not.
%%      It doesn't matter which one is contained at which other.
%%      Verifies if FilePath and IncludePath refer both to the same file.
%%      Note that we can't just compare both filename:absname's here, since we
%%      don't really know what is the absolute path of the file referred by
%%      the include directive.
-spec paths_match(string(), string()) -> boolean().
paths_match(IncludePath, IncludePath) ->
    % The path used in the include directive is exactly the file path
    true;
paths_match(FilePath, IncludePath) ->
    % We remove relative paths because FilePath will not be a relative path and,
    % in any case, the paths will be relative to something that we don't know.
    %
    % Note that this might result in some false negatives.
    % For instance, Hank may think that lib/app1/include/header.hrl is used
    % if lib/app2/src/module.erl contains -include("header.hrl").
    % when, in reality, module is including lib/app2/include/header.erl
    % That should be an extremely edge scenario and Hank never promised to find
    % ALL the dead code, anyway. It just promised that *if* it finds something,
    % that's dead code, 100% sure.
    compare_paths(clean_path(FilePath), clean_path(IncludePath)).

%% @doc Whether one of the given paths is contained inside the other one or not
%%      It doesn't matter which one is contained at which other
compare_paths({PathA, LenA}, {PathB, LenB}) when LenA > LenB ->
    PathB == string:find(PathA, PathB, trailing);
compare_paths({PathA, _}, {PathB, _}) ->
    PathA == string:find(PathB, PathA, trailing);
compare_paths(PathA, PathB) ->
    compare_paths({PathA, length(PathA)}, {PathB, length(PathB)}).

%% @doc Removes "../" and "./" from a given Path
clean_path(Path) ->
    unicode:characters_to_list(
        string:replace(
            string:replace(Path, "../", "", all), "./", "", all)).

%% @doc Format rule result text for console output
-spec format_text(string(), list()) -> binary().
format_text(Text, Args) ->
    Formatted = io_lib:format(Text, Args),
    case unicode:characters_to_binary(Formatted) of
        {_Error, Bin, _Rest} ->
            Bin;
        Bin ->
            Bin
    end.

%% @doc Returns true if the node contains the atom.
%%      Only analyzes functions and attributes.
-spec node_has_atom(erl_syntax:syntaxTree(), atom()) -> boolean().
node_has_atom(Node, Atom) ->
    ToCheck =
        case erl_syntax:type(Node) of
            function ->
                [Body
                 || Clause <- erl_syntax:function_clauses(Node),
                    Body <- erl_syntax:clause_body(Clause)];
            attribute ->
                case attr_name(Node) of
                    Name when Name == record; Name == define ->
                        [_RecOrMacroName | Attrs] = erl_syntax:attribute_arguments(Node),
                        Attrs;
                    _ ->
                        []
                end;
            _ ->
                []
        end,
    lists:member(Atom, node_atoms(ToCheck)).