src/gradualizer.erl

%% @doc
%% Gradualizer is a <em>static type checker</em> for Erlang
%% with support for <em>gradual typing</em>.
%%
%% A static type checker detects errors thanks to type analysis of a given program.
%% Gradualizer can infer some types, but mostly relies on function specs.
%% In a nutshell, we could say that it checks for consistency of a function body with its
%% declared spec and for consistency of a callee's spec with passed in arguments.
%% Specifically, Gradualizer does not perform entire program flow analysis.
%%
%% Gradual typing means that full type information is not required to raise warnings - any type
%% information is better than no type information. It allows for a _gradual_ transition between
%% fully static typing, which offers the best correctness guarantee, and fully dynamic typing,
%% which means the least overhead of writing specs, the full Erlang expressiveness,
%% and _letting it crash_ at runtime.
%% We can choose the right balance between the static/dynamic extremes depending on the application,
%% project maturity, team size, the need for reusability or for documentation consistency.
%%
%% <strong>
%% If you are interested in using Gradualizer to type check your code,
%% then you might be looking for the Rebar3 plugin {@link rebar_prv_gradualizer}
%% or the command-line interface {@link gradualizer_cli}.
%% </strong>
%%
%% This module contains entry points for calling into Gradualizer as a library,
%% as well as some Erlang shell utilities for working directly with the type checker internals.
%%
%% Let's try out the shell utilities by running:
%%
%% ```
%% $ rebar3 shell
%% '''
%%
%% Now, we can play with some examples:
%%
%% ```
%% > gradualizer:type_of("[ a || _ <- lists:seq(1, 5) ]").
%% {type,0,list,[{atom,0,a}]}
%% > typelib:pp_type(v(-1)).
%% "[a]"
%% > typechecker:normalize(gradualizer:type("a()"), gradualizer:env("-type a() :: integer().", [])).
%% {type,0,integer,[]}
%% > typelib:pp_type(v(-1)).
%% "integer()"
%% > gradualizer:type_of("fun (A) -> #{tag => my_map, list_of_as => [ A || _ <- lists:seq(1, 5) ]} end").
%% {type,0,'fun',
%%       [{type,0,product,[{type,0,any,[]}]},
%%        {type,0,map,
%%              [{type,0,map_field_assoc,[{atom,0,tag},{atom,0,my_map}]},
%%               {type,0,map_field_assoc,
%%                     [{atom,0,list_of_as},{type,0,list,[{type,0,any,[]}]}]}]}]}
%% > typelib:pp_type(v(-1)).
%% "fun((any()) -> #{tag => my_map, list_of_as => [any()]})"
%% '''
%%
%% The main library API of Gradualizer are `type_check_(file|module|dir)' functions,
%% which accept options described by the {@link options()} type.
%% @end
-module(gradualizer).

-export([type_check_file/1, type_check_file/2,
         type_check_module/1, type_check_module/2,
         type_check_dir/1, type_check_dir/2,
         type_check_files/1, type_check_files/2,
         type_check_forms/2]).

-export([type/1,
         env/0, env/1, env/2,
         type_of/1, type_of/2]).

-export_type([options/0, top/0]).

-type options() :: proplists:proplist().
%% The options accepted by the Gradualizer API.
%%
%% <ul>
%%   <li>
%%   `{i, Dir}':
%%   include path for `-include' and `-include_lib' when checking
%%   Erlang source files. Specify multiple times for multiple include paths.
%%   </li>
%%
%%   <li>
%%   `stop_on_first_error':
%%   if `true' stop type checking on the first error,
%%   if `false' continue checking all functions in the given file and all files
%%   in the given directory.
%%   </li>
%%
%%   <li>
%%   `crash_on_error':
%%   if `true' crash on the first produced error.
%%   </li>
%%
%%   <li>
%%   `return_errors':
%%   return a list of errors instead of printing them and returning a single condensed `ok | nok'.
%%   </li>
%%
%%   <li>
%%   `fmt_location':
%%   how to format location when pretty printing errors:
%%     <ul>
%%       <li>
%%       `none':
%%       no location for easier comparison
%%       </li>
%%
%%       <li>
%%       `brief':
%%       for machine processing (`<line>:<column>:' before message text)
%%       </li>
%%
%%       <li>
%%       `verbose' (default): for human readers
%%       (`on line <line> at column <column>' within the message text)
%%       </li>
%%     </ul>
%%   </li>
%%
%%   <li>
%%   `fmt_expr_fun':
%%   function to pretty print an expression AST (useful to support other languages)
%%   </li>
%%
%%   <li>
%%   `fmt_type_fun':
%%   function to pretty print a type AST (useful to support other languages)
%%   </li>
%%
%%   <li>
%%   `{color, always | never | auto}':
%%   Use colors when printing fancy messages.
%%   Auto is the default but auto-detection of tty doesn't work when running
%%   as an escript. It works when running from the Erlang shell though.
%%   </li>
%%
%%   <li>
%%   `{fancy, boolean()}':
%%   Use fancy error messages when possible. True by
%%   default. Doesn't work when a custom `fmt_expr_fun' is used.
%%   </li>
%% </ul>

%% This type is the top of the subtyping lattice. It's never expanded.
%% The definition can be anything apart from any(),
%% so that we don't run into the "opaque type underspecified and therefore meaningless" warning.
-opaque top() :: none().

-include("gradualizer.hrl").
-include("typechecker.hrl").

%% API functions

%% @doc Type check a source or beam file
-spec type_check_file(file:filename()) -> ok | nok | [{file:filename(), any()}].
type_check_file(File) ->
    type_check_file(File, []).

%% @doc Type check a source or beam file
-spec type_check_file(file:filename(), options()) -> ok | nok | [{file:filename(), any()}].
type_check_file(File, Opts) ->
    ExcludeModules = proplists:get_value(exclude_modules, Opts, []),
    AtomBaseFile = list_to_atom(filename:rootname(filename:basename(File))),
    case lists:member(AtomBaseFile, ExcludeModules) of
        true ->
            ok;
        false ->
            case filename:extension(File) of
                ".erl" ->
                    Includes = proplists:get_all_values(i, Opts),
                    case gradualizer_file_utils:get_forms_from_erl(File, Includes) of
                        {ok, Forms} ->
                            lint_and_check_forms(Forms, File, Opts);
                        Error ->
                            throw(Error)
                    end;
                ".beam" ->
                    case gradualizer_file_utils:get_forms_from_beam(File) of
                        {ok, Forms} ->
                            type_check_forms(File, Forms, Opts);
                        Error ->
                            throw(Error)
                    end;
                Ext ->
                    throw({unknown_file_extension, Ext})
            end
    end.

%% @doc Runs an erl_lint pass, to check if the forms can be compiled at all,
%% before running the type checker.
-spec lint_and_check_forms(Forms, file:filename(), options()) -> R when
      Forms :: gradualizer_file_utils:abstract_forms(),
      R :: ok | nok | [{file:filename(), any()}].
lint_and_check_forms(Forms, File, Opts) ->
    case erl_lint:module(Forms, File, [return_errors]) of
        {ok, _Warnings} ->
            type_check_forms(File, Forms, Opts);
        {error, Errors, _Warnings} ->
            %% If there are lint errors (i.e. compile errors like undefined
            %% variables) we don't even try to type check.
            case proplists:get_bool(return_errors, Opts) of
                true ->
                    [{Filename, ErrorInfo} || {Filename, ErrorInfos} <- Errors,
                                              ErrorInfo <- ErrorInfos];
                false ->
                    [gradualizer_fmt:print_errors(ErrorInfos,
                                                  [{filename, Filename} | Opts])
                     || {Filename, ErrorInfos} <- Errors],
                    nok
            end
    end.

%% @doc Type check a module
-spec type_check_module(module()) -> ok | nok | [{file:filename(), any()}].
type_check_module(Module) ->
    type_check_module(Module, []).

%% @doc Type check a module
-spec type_check_module(module(), options()) ->
                               ok | nok | [{file:filename(), any()}].
type_check_module(Module, Opts) when is_atom(Module) ->
    case code:which(Module) of
        File when is_list(File) ->
            type_check_file(File, Opts);
        Error when is_atom(Error) ->
            throw({beam_not_found, Error})
    end.

%% @doc Type check all source or beam files in a directory.
-spec type_check_dir(file:filename()) -> ok | nok | [{file:filename(), any()}].
type_check_dir(Dir) ->
    type_check_dir(Dir, []).

%% @doc Type check all source or beam files in a directory.
-spec type_check_dir(file:filename(), options()) ->
                            ok | nok | [{file:filename(), any()}].
type_check_dir(Dir, Opts) ->
    case filelib:is_dir(Dir) of
        true ->
            Pattern = filename:join(Dir, "*.{erl,beam}"),
            type_check_files(filelib:wildcard(Pattern), Opts);
        false ->
            throw({dir_not_found, Dir})
    end.

%% @doc Type check a source or beam file
-spec type_check_files([file:filename()]) ->
                              ok | nok | [{file:filename(), any()}].
type_check_files(Files) ->
    type_check_files(Files, []).

%% @doc Type check a source or beam
-spec type_check_files([file:filename()], options()) ->
                              ok | nok | [{file:filename(), any()}].
type_check_files(Files, Opts) ->
    StopOnFirstError = proplists:get_bool(stop_on_first_error, Opts),
    ReturnErrors = proplists:get_bool(return_errors, Opts),
    if ReturnErrors ->
            lists:foldl(
              fun(File, Errors) when Errors =:= [];
                                     not StopOnFirstError ->
                      type_check_file_or_dir(File, Opts) ++ Errors;
                 (_, Errors) ->
                      Errors
              end, [], Files);
       true ->
            lists:foldl(
              fun(File, Res) when Res =:= ok;
                                  not StopOnFirstError ->
                      case type_check_file_or_dir(File, Opts) of
                          ok -> Res;
                          nok -> nok
                      end;
                 (_, nok) ->
                      nok
              end, ok, Files)
    end.

-spec type_check_file_or_dir(file:filename(), options()) ->
                                    ok | nok | [{file:filename(), any()}].
type_check_file_or_dir(File, Opts) ->
    IsRegular = filelib:is_regular(File),
    IsDir = filelib:is_dir(File),
    if
        IsDir     -> type_check_dir(File, Opts);
        IsRegular -> type_check_file(File, Opts);
        true      -> throw({file_not_found, File}) % TODO: better errors
    end.

%% @doc Type check an abstract syntax tree of a module. This can be useful
%% for tools where the abstract forms are generated in memory.
%%
%% If the first form is a file attribute (as in forms returned by e.g.
%% epp:parse_file/1,2), that filename will be used in error messages.
%% The second form is typically the module attribute.
-spec type_check_forms([erl_parse:abstract_form()], options()) ->
                            ok | nok | [{file:filename(), any()}].
type_check_forms(Forms, Opts) ->
    File = case Forms of
               [{attribute, _, file, {F,  _}} |  _] -> F;
               _ -> "no filename"
           end,
    type_check_forms(File, Forms, Opts).

%% Helper
-spec type_check_forms(file:filename(), Forms, options()) -> R when
      Forms :: gradualizer_file_utils:abstract_forms(),
      R :: ok | nok | [{file:filename(), any()}].
type_check_forms(File, Forms, Opts) ->
    ReturnErrors = proplists:get_bool(return_errors, Opts),
    OptsForModule = options_from_forms(Forms) ++ Opts,
    Errors = typechecker:type_check_forms(Forms, OptsForModule),
    case {ReturnErrors, Errors} of
        {true, _ } ->
            lists:map(fun(Error) -> {File, Error} end, Errors);
        {false, []} ->
            ok;
        {false, [_|_]} ->
            Opts1 = add_source_file_and_forms_to_opts(File, Forms, Opts),
            gradualizer_fmt:print_errors(Errors, Opts1),
            nok
    end.

add_source_file_and_forms_to_opts(File, Forms, Opts) ->
    Opts1 = [{filename, File}, {forms, Forms} | Opts],
    case filename:extension(File) == ".erl" andalso filelib:is_file(File) of
        true -> [{source_file, File} | Opts1];
        false -> Opts1
    end.

%% Extract `-gradualizer(Options)' from AST
-spec options_from_forms(gradualizer_file_utils:abstract_forms()) -> options().
options_from_forms([{attribute, _L, gradualizer, Opts} | Fs]) when is_list(Opts) ->
    Opts ++ options_from_forms(Fs);
options_from_forms([{attribute, _L, gradualizer, Opt} | Fs]) ->
    [Opt | options_from_forms(Fs)];
options_from_forms([_F | Fs]) -> options_from_forms(Fs);
options_from_forms([]) -> [].

%% @doc Return a Gradualizer type for the passed in Erlang type definition.
%%
%% ```
%% > gradualizer:type("[integer()]").
%% {type,0,list,[{type,0,integer,[]}]}
%% '''
-spec type(string()) -> typechecker:type().
type(Type) ->
    typelib:remove_pos(typelib:parse_type(Type)).

%% @see env/2
-spec env() -> typechecker:env().
env() ->
    env([]).

%% @see env/2
-spec env(gradualizer:options()) -> typechecker:env().
env(Opts) ->
    env("", Opts).

%% @doc Create a type checker environment populated by types defined in a source code snippet
%% via `-type ...' and `-record(...)' attributes.
%%
%% Currently, it's not possible to define variable bindings in the environment.
%%
%% ```
%% > rr(typechecker).
%% > gradualizer:env("-type a() :: integer().", []).
%% #env{fenv = #{},imported = #{},venv = #{},
%%      tenv = #{module => undefined,records => #{},
%%               types => #{{a,0} => {[],{type,0,integer,[]}}}},
%%      infer = false,verbose = false,exhaust = true,
%%      clauses_stack = [],union_size_limit = 30,
%%      current_spec = none}
%% > gradualizer:env("-record(r, {f}).", []).
%% #env{
%%     fenv = #{},imported = #{},venv = #{},
%%     tenv =
%%         #{module => undefined,
%%           records =>
%%               #{r =>
%%                     [{typed_record_field,
%%                          {record_field,0,{atom,0,f},{atom,1,undefined}},
%%                          {type,0,any,[]}}]},
%%           types => #{}},
%%     infer = false,verbose = false,exhaust = true,
%%     clauses_stack = [],union_size_limit = 30,
%%     current_spec = none}
%% '''
-spec env(string(), gradualizer:options()) -> typechecker:env().
env(ErlSource, Opts) ->
    Forms = gradualizer_lib:ensure_form_list(merl:quote(lists:flatten(ErlSource))),
    ErlParseForms = lists:map(fun erl_syntax:revert/1, Forms),
    ParseData = typechecker:collect_specs_types_opaques_and_functions(ErlParseForms),
    typechecker:create_env(ParseData, Opts).

%% @see type_of/2
-spec type_of(string()) -> typechecker:type().
type_of(Expr) ->
    type_of(Expr, env([infer])).

%% @doc Infer type of an Erlang expression.
%%
%% ```
%% > gradualizer:type_of("[ a || _ <- lists:seq(1, 5) ]").
%% {type,0,list,[{atom,0,a}]}
%% > gradualizer:type_of("case 5 of 1 -> one; _ -> more end").
%% {type,0,union,[{atom,0,more},{atom,0,one}]}
%% > Env = gradualizer:env([{union_size_limit, 1}]).
%% > gradualizer:type_of("case 5 of 1 -> one; _ -> more end", Env).
%% {type,0,any,[]}
%% '''
-spec type_of(string(), typechecker:env()) -> typechecker:type().
type_of(Expr, Env) ->
    AlwaysInfer = Env#env{infer = true},
    {Ty, _Env, _Cs} = typechecker:type_check_expr(AlwaysInfer, merl:quote(Expr)),
    Ty.