#!/usr/bin/env escript
%% vim:ft=erlang:

%% The code is copied from xref_runner.
%% https://github.com/inaka/xref_runner
%%
%% The only change is the support of our erlang_version_support
%% attribute: we don't want any warnings about functions which will be
%% dropped at load time.
%%
%% It's also a plain text escript instead of a compiled one because we
%% want to support Erlang R16B03 and the version of xref_runner uses
%% maps and is built with something like Erlang 18.

%% This mode allows us to reference local function. For instance:
%%   lists:map(fun generate_comment/1, Comments)
-mode(compile).

-define(DIRS, ["ebin", "test"]).

-define(CHECKS, [undefined_function_calls,
                 undefined_functions,
                 locals_not_used]).

main(_) ->
    Checks = ?CHECKS,
    ElixirDeps = get_elixir_deps_paths(),
    [true = code:add_path(P) || P <- ElixirDeps],
    XrefWarnings = lists:append([check(Check) || Check <- Checks]),
    warnings_prn(XrefWarnings),
    case XrefWarnings of
        [] -> ok;
        _  -> halt(1)
    end.

get_elixir_deps_paths() ->
    case os:getenv("ERLANG_MK_RECURSIVE_DEPS_LIST") of
        false ->
            [];
        Filename ->
            {ok, Fd} = file:open(Filename, [read]),
            get_elixir_deps_paths1(Fd, [])
    end.

get_elixir_deps_paths1(Fd, Paths) ->
    case file:read_line(Fd) of
        {ok, Line0} ->
            Line = Line0 -- [$\r, $\n],
            RootPath = case os:type() of
                           {unix, _} ->
                               Line;
                           {win32, _} ->
                               case os:find_executable("cygpath.exe") of
                                   false ->
                                       Line;
                                   Cygpath ->
                                       os:cmd(
                                         io_lib:format("~s --windows \"~s\"",
                                                       [Cygpath, Line]))
                                       -- [$\r, $\n]
                               end
                       end,
            Glob = filename:join([RootPath, "_build", "dev", "lib", "*", "ebin"]),
            NewPaths = filelib:wildcard(Glob),
            get_elixir_deps_paths1(Fd, Paths ++ NewPaths);
        eof ->
            add_elixir_stdlib_path(Paths)
    end.

add_elixir_stdlib_path(Paths) ->
    case find_elixir_home() of
        false        -> Paths;
        ElixirLibDir -> [ElixirLibDir | Paths]
    end.

find_elixir_home() ->
    ElixirExe = case os:type() of
                    {unix, _}  -> "elixir";
                    {win32, _} -> "elixir.bat"
                end,
    case os:find_executable(ElixirExe) of
        false   -> false;
        ExePath -> resolve_symlink(ExePath)
    end.

resolve_symlink(ExePath) ->
    case file:read_link_all(ExePath) of
        {error, einval} ->
            determine_elixir_home(ExePath);
        {ok, ResolvedLink} ->
            ExePath1 = filename:absname(ResolvedLink,
                                        filename:dirname(ExePath)),
            resolve_symlink(ExePath1);
        {error, _} ->
            false
    end.

determine_elixir_home(ExePath) ->
    LibPath = filename:join([filename:dirname(filename:dirname(ExePath)),
                             "lib",
                             "elixir",
                             "ebin"]),
    case filelib:is_dir(LibPath) of
        true  -> LibPath;
        false -> {skip, "Failed to locate Elixir lib dir"}
    end.
check(Check) ->
    Dirs = ?DIRS,
    lists:foreach(fun code:add_path/1, Dirs),

    {ok, Xref} = xref:start([]),
    try
        ok = xref:set_library_path(Xref, code:get_path()),

        lists:foreach(
          fun(Dir) ->
                  case filelib:is_dir(Dir) of
                      true  -> {ok, _} = xref:add_directory(Xref, Dir);
                      false -> ok
                  end
          end, Dirs),

        {ok, Results} = xref:analyze(Xref, Check),

        FilteredResults = filter_xref_results(Check, Results),

        [result_to_warning(Check, Result) || Result <- FilteredResults]
    after
        stopped = xref:stop(Xref)
    end.

%% -------------------------------------------------------------------
%% Filtering results.
%% -------------------------------------------------------------------

filter_xref_results(Check, Results) ->
  SourceModules =
    lists:usort([source_module(Result) || Result <- Results]),

  Ignores = lists:flatmap(
              fun(Module) -> get_ignorelist(Module, Check) end, SourceModules),

  UnusedFunctions = lists:flatmap(
                      fun(Mod) -> get_unused_compat_functions(Mod) end,
                      SourceModules),

  ToIgnore = case get(results_to_ignore) of
                 undefined -> [];
                 RTI       -> RTI
             end,
  NewToIgnore = [parse_xref_target(Result)
                 || Result <- Results,
                    lists:member(parse_xref_source(Result), UnusedFunctions)],
  AllToIgnore = ToIgnore ++ NewToIgnore ++ [mfa(M, {F, A})
                                            || {_, {M, F, A}} <- Ignores],
  put(results_to_ignore, AllToIgnore),

  [Result || Result <- Results,
             not lists:member(parse_xref_result(Result), Ignores) andalso
             not lists:member(parse_xref_result(Result), AllToIgnore) andalso
             not lists:member(parse_xref_source(Result), UnusedFunctions)].

source_module({Mt, _Ft, _At}) -> Mt;
source_module({{Ms, _Fs, _As}, _Target}) -> Ms.

%%
%% Ignore behaviour functions, and explicitly marked functions
%%
%% Functions can be ignored by using
%% -ignore_xref([{F, A}, {M, F, A}...]).
get_ignorelist(Mod, Check) ->
  %% Get ignore_xref attribute and combine them in one list
  Attributes =
    try
      Mod:module_info(attributes)
    catch
      _Class:_Error -> []
    end,

  IgnoreXref =
    [mfa(Mod, Value) || {ignore_xref, Values} <- Attributes, Value <- Values],

  BehaviourCallbacks = get_behaviour_callbacks(Check, Mod, Attributes),

  %% And create a flat {M, F, A} list
  IgnoreXref ++ BehaviourCallbacks.

get_behaviour_callbacks(exports_not_used, Mod, Attributes) ->
  Behaviours = [Value || {behaviour, Values} <- Attributes, Value <- Values],
  [{Mod, {Mod, F, A}}
   || B <- Behaviours, {F, A} <- B:behaviour_info(callbacks)];
get_behaviour_callbacks(_Check, _Mod, _Attributes) ->
  [].

get_unused_compat_functions(Module) ->
    OTPVersion = code_version:get_otp_version(),
    Attributes = try
                     Module:module_info(attributes)
                 catch
                     _Class:_Error -> []
                 end,
    CompatTuples = [Tuple
                    || {erlang_version_support, Tuples} <- Attributes,
                       Tuple <- Tuples],
    get_unused_compat_functions(Module, OTPVersion, CompatTuples, []).

get_unused_compat_functions(_, _, [], Result) ->
    Result;
get_unused_compat_functions(Module,
                            OTPVersion,
                            [{MinOTPVersion, Choices} | Rest],
                            Result) ->
    Functions = lists:map(
                  fun({_, Arity, Pre, Post}) ->
                          if
                              OTPVersion >= MinOTPVersion ->
                                  %% We ignore the "pre" function.
                                  mfa(Module, {Pre, Arity});
                              true ->
                                  %% We ignore the "post" function.
                                  mfa(Module, {Post, Arity})
                          end
                  end, Choices),
    get_unused_compat_functions(Module, OTPVersion, Rest,
                                Result ++ Functions).

mfa(M, {F, A}) -> {M, {M, F, A}};
mfa(M, MFA) -> {M, MFA}.

parse_xref_result({{SM, _, _}, MFAt}) -> {SM, MFAt};
parse_xref_result({TM, _, _} = MFAt) -> {TM, MFAt}.

parse_xref_source({{SM, _, _} = MFAt, _}) -> {SM, MFAt};
parse_xref_source({TM, _, _} = MFAt) -> {TM, MFAt}.

parse_xref_target({_, {TM, _, _} = MFAt}) -> {TM, MFAt};
parse_xref_target({TM, _, _} = MFAt) -> {TM, MFAt}.

%% -------------------------------------------------------------------
%% Preparing results.
%% -------------------------------------------------------------------

result_to_warning(Check, {MFASource, MFATarget}) ->
    {Filename, Line} = get_source(MFASource),
    [{filename, Filename},
     {line,     Line},
     {source,   MFASource},
     {target,   MFATarget},
     {check,    Check}];
result_to_warning(Check, MFA) ->
    {Filename, Line} = get_source(MFA),
    [{filename, Filename},
     {line,     Line},
     {source,   MFA},
     {check,    Check}].

%%
%% Given a MFA, find the file and LOC where it's defined. Note that
%% xref doesn't work if there is no abstract_code, so we can avoid
%% being too paranoid here.
%%
get_source({M, F, A}) ->
  case code:get_object_code(M) of
    error -> {"", 0};
    {M, Bin, _} -> find_function_source(M, F, A, Bin)
  end.

find_function_source(M, F, A, Bin) ->
  AbstractCode = beam_lib:chunks(Bin, [abstract_code]),
  {ok, {M, [{abstract_code, {raw_abstract_v1, Code}}]}} = AbstractCode,

  %% Extract the original source filename from the abstract code
  [Source|_] = [S || {attribute, _, file, {S, _}} <- Code],

  %% Extract the line number for a given function def
  Fn = [E || E <- Code,
             element(1, E) == function,
             element(3, E) == F,
             element(4, E) == A],

  case Fn of
    [{function, Line, F, _, _}] when is_integer(Line) ->
          {Source, Line};
    [{function, Line, F, _, _}] ->
          {Source, erl_anno:line(Line)};
    %% do not crash if functions are exported, even though they
    %% are not in the source.
    %% parameterized modules add new/1 and instance/1 for example.
    [] -> {Source, 0}
  end.

%% -------------------------------------------------------------------
%% Reporting results.
%% -------------------------------------------------------------------

warnings_prn([]) ->
    ok;
warnings_prn(Comments) ->
    Messages = lists:map(fun generate_comment/1, Comments),
    lists:foreach(fun warning_prn/1, Messages).

warning_prn(Message) ->
    FullMessage = Message ++ "~n",
    io:format(FullMessage, []).

generate_comment(XrefWarning) ->
    Filename = proplists:get_value(filename, XrefWarning),
    Line = proplists:get_value(line, XrefWarning),
    Source = proplists:get_value(source, XrefWarning),
    Check = proplists:get_value(check, XrefWarning),
    Target = proplists:get_value(target, XrefWarning),
    Position = case {Filename, Line} of
                   {"", _} -> "";
                   {Filename, 0} -> [Filename, " "];
                   {Filename, Line} -> [Filename, ":",
                                        integer_to_list(Line), " "]
               end,
    [Position, generate_comment_text(Check, Source, Target)].

generate_comment_text(Check, {SM, SF, SA}, TMFA) ->
    SMFA = io_lib:format("`~p:~p/~p`", [SM, SF, SA]),
    generate_comment_text(Check, SMFA, TMFA);
generate_comment_text(Check, SMFA, {TM, TF, TA}) ->
    TMFA = io_lib:format("`~p:~p/~p`", [TM, TF, TA]),
    generate_comment_text(Check, SMFA, TMFA);

generate_comment_text(undefined_function_calls, SMFA, TMFA) ->
    io_lib:format("~s calls undefined function ~s", [SMFA, TMFA]);
generate_comment_text(undefined_functions, SMFA, _TMFA) ->
    io_lib:format("~s is not defined as a function", [SMFA]);
generate_comment_text(locals_not_used, SMFA, _TMFA) ->
    io_lib:format("~s is an unused local function", [SMFA]);
generate_comment_text(exports_not_used, SMFA, _TMFA) ->
    io_lib:format("~s is an unused export", [SMFA]);
generate_comment_text(deprecated_function_calls, SMFA, TMFA) ->
    io_lib:format("~s calls deprecated function ~s", [SMFA, TMFA]);
generate_comment_text(deprecated_functions, SMFA, _TMFA) ->
    io_lib:format("~s is deprecated", [SMFA]).
