src/gradualizer_cli.erl

%% @doc
%% Gradualizer command line interface.
%% @end
-module(gradualizer_cli).
-export([main/1, handle_args/1]).

-spec main([string()]) -> ok.
main(Args) ->
    case handle_args(Args) of
        help    -> print_usage();
        version -> print_version();
        {error, Message} ->
            io:format(standard_error, "~s~n", [Message]),
            halt(1);
        {ok, Files, Opts} ->
            start_application(Opts),
            CheckResult = gradualizer:type_check_files(Files, Opts),
            gradualizer_tracer:flush(),
            case CheckResult of
                ok -> ok;
                nok -> halt(1)
            end
    end.

start_application(Opts) ->
    %% An explicit load makes sure any options defined in a *.config file are set before
    %% we call `application:set_env/3'.
    %% A load after set_env overrides anything set with set_env.
    %% If gradualizer is run as an escript this should not be necessary, but better safe than sorry.
    ok = application:load(gradualizer),
    application:set_env(gradualizer, options, Opts),
    %% We could start the tracer based on a CLI flag, but it's config is compile-time anyway.
    %gradualizer_tracer:start(),
    {ok, _} = application:ensure_all_started(gradualizer).

-spec handle_args([string()]) -> help | version | {error, string()} |
                                 {ok, [string()], gradualizer:options()}.
handle_args([]) -> help;
handle_args(Args) ->
    try parse_opts(Args, []) of
        {Rest, Opts} ->
            HasHelp = proplists:get_bool(help, Opts),
            HasVersion = proplists:get_bool(version, Opts),
            if
                HasHelp -> help;
                HasVersion -> version;
                Rest =:= [] -> {error, "No files specified to check (try --)"};
                true ->
                    {ok, Rest, Opts}
            end
    catch
        error:Message when is_list(Message) ->
            {error, Message}
    end.

-spec get_ver(atom()) -> string().
get_ver(App) ->
    {_, _, Ver} = lists:keyfind(App, 1, application:loaded_applications()),
    Ver.

print_version() ->
    application:load(gradualizer),
    io:format("Gradualizer v~s~n", [get_ver(gradualizer)]).

print_usage() ->
    io:format("Usage: gradualizer [options] [PATH...]~n"),
    io:format("A type checker for Erlang/Elixir~n~n"),
    io:format("       PATH                      Files or directories to type check~n"),
    io:format("       --                        Signals that no more options will follow. The following~n"),
    io:format("                                 arguments are treated as filenames, even if~n"),
    io:format("                                 they start with hyphens.~n"),
    io:format("  -h,  --help                    display this help and exit~n"),
    io:format("       --infer                   Infer type information from literals and other~n"),
    io:format("                                 language constructs~n"),
    io:format("       --no_infer                Only use type information from function specs~n"),
    io:format("                                  - the default behaviour~n"),
    io:format("       --verbose                 Show what Gradualizer is doing~n"),
    io:format("  -pa, --path_add                Add the specified directory to the beginning of~n"),
    io:format("                                 the code path; see erl -pa             [string]~n"),
    io:format("  -I                             Include path for Erlang source files; see -I in~n"),
    io:format("                                 the manual page erlc(1)~n"),
    io:format("       --stop_on_first_error     stop type checking at the first error~n"),
    io:format("       --no_stop_on_first_error  inverse of --stop-on-first-error~n"),
    io:format("                                  - the default behaviour~n"),
    io:format("       --no_prelude              Do not override OTP specs.~n"),
    io:format("       --specs_override_dir      Add specs overrides from the *.specs.erl files in~n"),
    io:format("                                 this directory.~n"),
    io:format("       --fmt_location            How to format location when pretty printing errors~n"),
    io:format("                                 (Column is only available if analyzing from source)~n"),
    io:format("                                 - 'none': no location for easier comparison~n"),
    io:format("                                 - 'brief': for machine processing~n"),
    io:format("                                   (\"LINE:COLUMN:\" before message text)~n"),
    io:format("                                 - 'verbose' (default): for human readers~n"),
    io:format("                                   (\"on line LINE at column COLUMN\" within the message text)~n"),
    io:format("       --color [ COLOR ]         - Use colors when printing fancy messages. An optional~n"),
    io:format("                                   argument is `always | never | auto'. However, auto-~n"),
    io:format("                                   detection of a TTY doesn't work when running as an escript.~n"),
    io:format("       --no_color                - Alias for `--color never'~n"),
    io:format("       --fancy                   - Use fancy error messages when possible (on by default)~n"),
    io:format("       --no_fancy                - Don't use fancy error messages.~n"),
    io:format("       --union_size_limit        - Performance hack: Unions larger than this value~n"),
    io:format("                                   are replaced by any() in normalization (default: 30)~n").

-spec parse_opts([string()], gradualizer:options()) -> {[string()], gradualizer:options()}.
parse_opts([], Opts) ->
    {[], Opts};
parse_opts([A | Args], Opts) ->
    case A of
        "-h"                       -> {[], [help]};
        "--help"                   -> {[], [help]};
        "--infer"                  -> parse_opts(Args, [infer | Opts]);
        "--no_infer"               -> parse_opts(Args, [{infer, false} | Opts]);
        "--verbose"                -> parse_opts(Args, [verbose | Opts]);
        "-pa"                      -> handle_path_add(A, Args, Opts);
        "--path_add"               -> handle_path_add(A, Args, Opts);
        "-I"                       -> handle_include_path(A, Args, Opts);
        "--stop_on_first_error"    -> parse_opts(Args, [stop_on_first_error | Opts]);
        "--no_stop_on_first_error" -> parse_opts(Args, [{stop_on_first_error, false} | Opts]);
        "--crash_on_error"         -> parse_opts(Args, [crash_on_error | Opts]);
        "--no_crash_on_error"      -> parse_opts(Args, [{crash_on_error, false} | Opts]);
        "--version"                -> {[], [version]};
        "--no_prelude"             -> parse_opts(Args, [{prelude, false}| Opts]);
        "--specs_override_dir"     -> handle_specs_override(A, Args, Opts);
        "--fmt_location"           -> handle_fmt_location(Args, Opts);
        "--color"                  -> handle_color(Args, Opts);
        "--no_color"               -> parse_opts(Args, [{color, never} | Opts]);
        "--fancy"                  -> parse_opts(Args, [fancy | Opts]);
        "--no_fancy"               -> parse_opts(Args, [{fancy, false} | Opts]);
        "--union_size_limit"       -> handle_union_size_limit(A, Args, Opts);
        "--"                       -> {Args, Opts};
        "-" ++ _                   -> erlang:error(string:join(["Unknown parameter:", A], " "));
        _                          -> {[A | Args], Opts}
    end.

-spec handle_path_add(string(), [string()], gradualizer:options()) -> {[string()], gradualizer:options()}.
handle_path_add(A, Args, Opts) ->
    {Paths, RestArgs} = lists:splitwith(fun no_start_dash/1, Args),
    case Paths of
        [] ->
            erlang:error(string:join(["Missing argument for", A], " "));
        _ ->
            code:add_pathsa(Paths)
    end,
    parse_opts(RestArgs, Opts).

-spec handle_include_path(string(), [string()], gradualizer:options()) -> {[string()], gradualizer:options()}.
handle_include_path(_, [Dir | Args], Opts) ->
    parse_opts(Args, [{i, Dir} | Opts]);
handle_include_path(A, [], _Opts) ->
    erlang:error(string:join(["Missing argument for", A], " ")).

-spec handle_specs_override(string(), [string()], gradualizer:options()) -> {[string()], gradualizer:options()}.
handle_specs_override(_, [Dir | Args], Opts) ->
    parse_opts(Args, [{specs_override, Dir} | Opts]);
handle_specs_override(A, [], _Opts) ->
    erlang:error(string:join(["Missing argument for", A], " ")).

handle_fmt_location([FmtTypeStr | Args], Opts) ->
    try list_to_existing_atom(FmtTypeStr) of
        FmtType when FmtType =:= none;
                     FmtType =:= brief;
                     FmtType =:= verbose ->
            parse_opts(Args, [{fmt_location, FmtType}|Opts]);
        _ ->
            erlang:error(lists:append(["Bad value for fmt-location: ", FmtTypeStr]))
    catch _:_ ->
            erlang:error(lists:append(["Bad value for fmt-location: ", FmtTypeStr]))
    end.

%% Handle Args after --color.
-spec handle_color([string()], gradualizer:options()) -> {[string()], gradualizer:options()}.
handle_color(["always"|Args], Opts) -> parse_opts(Args, [{color, always} | Opts]);
handle_color(["never" |Args], Opts) -> parse_opts(Args, [{color, never}  | Opts]);
handle_color(["auto"  |Args], Opts) -> parse_opts(Args, [{color, auto}   | Opts]);
handle_color(Args,            Opts) -> parse_opts(Args, [{color, always} | Opts]).

handle_union_size_limit(_, [LimitS | Args], _Opts) ->
    Limit = list_to_integer(LimitS),
    parse_opts(Args, [{union_size_limit, Limit}]);
handle_union_size_limit(A, [], _Opts) ->
    erlang:error(string:join(["Missing argument for", A], " ")).

no_start_dash("-" ++ _) ->
    false;
no_start_dash(_) ->
    true.