Skip to content

Commit

Permalink
Merge pull request #564 from xxdavid/local_type_inference
Browse files Browse the repository at this point in the history
Use Local Type Inference approach to typecheck polymorphic calls
  • Loading branch information
erszcz authored May 30, 2024
2 parents 050b074 + aac8000 commit 3bdca60
Show file tree
Hide file tree
Showing 33 changed files with 2,222 additions and 1,649 deletions.
19 changes: 9 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,15 @@ A type checker for Erlang/Elixir
("LINE:COLUMN:" before message text)
- 'verbose' (default): for human readers
("on line LINE at column COLUMN" within the message text)
--color [ COLOR ] - Use colors when printing fancy messages. An optional
argument is `always | never | auto'. However, auto-
detection of a TTY doesn't work when running as an escript.
--no_color - Alias for `--color never'
--fancy - Use fancy error messages when possible (on by default)
--no_fancy - Don't use fancy error messages.
--union_size_limit - Performance hack: Unions larger than this value
are replaced by any() in normalization (default: 30)
--color [ COLOR ] Use colors when printing fancy messages. An optional
argument is `always | never | auto'. However, auto-
detection of a TTY doesn't work when running as an escript.
--no_color Alias for `--color never'
--fancy Use fancy error messages when possible (on by default)
--no_fancy Don't use fancy error messages.
--union_size_limit Performance hack: Unions larger than this value
are replaced by any() in normalization (default: 30)
--solve_constraints Type check polymorphic calls (off by default)
```


Expand Down Expand Up @@ -140,8 +141,6 @@ Gradualizer is close to a beta release. Most of the Erlang language constructs a
data types are handled, although there are things that don't work yet.
That being said, pull requests are most welcome!

As of writing this, the single biggest missing feature is support for subtype polymorphism.

A work-in-progress [Gradualizer manual](https://github.com/josefs/Gradualizer/wiki)
is located on the wiki.

Expand Down
8 changes: 7 additions & 1 deletion src/absform.erl
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
-module(absform).

-export([normalize_record_field/1,
normalize_function_type_list/1]).
normalize_function_type_list/1,
extract_function_from_call/1]).

-include("gradualizer.hrl").

Expand Down Expand Up @@ -51,3 +52,8 @@ normalize_function_type({type, L, 'fun', [{type, _, product, _ArgTypes}, _RetTyp
normalize_function_type({type, _, 'fun', _}) ->
%% TODO: same story as for bounded_fun above
erlang:error(unreachable).

extract_function_from_call({call, Anno, {remote, _, Mod, Fun}, Args}) ->
{function, Mod, Fun, {integer, Anno, length(Args)}};
extract_function_from_call({call, _Anno, {atom, _, Fun}, Args}) ->
{function, Fun, length(Args)}.
175 changes: 57 additions & 118 deletions src/constraints.erl
Original file line number Diff line number Diff line change
@@ -1,148 +1,87 @@
%% @private
-module(constraints).

%% The `constraint' module tracks lower and upper bounds (i.e., constraints) for type variables.
%% Each bound is a single type. A type variable may have a lower bound, an upper bound, or both.
%% Note that the bounds cannot contain any type variables, they are just plain monomorphic types.
%% For more information on constraint solving and typechecking polymorphic calls, see
%% `typechecker:type_check_poly_call/4'.

-export([empty/0,
vars/1,
upper/2,
lower/2,
combine/1, combine/2,
combine_with/4,
add_var/2,
solve/3,
append_values/3,
has_upper_bound/2]).

-export_type([t/0,
mapset/1,
var/0]).
combine/2, combine/3,
satisfiable/2]).

-include_lib("stdlib/include/assert.hrl").
-export_type([t/0, var/0]).

-type type() :: gradualizer_type:abstract_type().

-include("constraints.hrl").

-type t() :: #constraints{}.
-type mapset(T) :: #{T => true}.
-type var() :: gradualizer_type:gr_type_var().
-type var() :: atom().
-type env() :: typechecker:env().

-spec empty() -> t().
empty() ->
#constraints{}.

-spec vars(mapset(var())) -> #constraints{}.
vars(Vars) ->
#constraints{ exist_vars = Vars }.

-spec add_var(var(), t()) -> t().
add_var(Var, Cs) ->
Cs#constraints{ exist_vars = maps:put(Var, true, Cs#constraints.exist_vars) }.

-spec upper(var(), type()) -> t().
upper(Var, Ty) ->
#constraints{ upper_bounds = #{ Var => [Ty] } }.
#constraints{ upper_bounds = #{ Var => Ty } }.

-spec lower(var(), type()) -> t().
lower(Var, Ty) ->
#constraints{ lower_bounds = #{ Var => [Ty] } }.
#constraints{ lower_bounds = #{ Var => Ty } }.

-spec combine(t(), t()) -> t().
combine(C1, C2) ->
combine([C1, C2]).
-spec combine(t(), t(), env()) -> t().
combine(Cs1, Cs2, Env) ->
combine([Cs1, Cs2], Env).

-spec combine([t()]) -> t().
combine([]) ->
-spec combine([t()], env()) -> t().
combine([], _Env) ->
empty();
combine([Cs]) ->
combine([Cs], _Env) ->
Cs;
combine([C1, C2 | Cs]) ->
C = combine_with(C1, C2, fun append_values/3, fun append_values/3),
combine([C | Cs]).

-spec combine_with(t(), t(), BoundsMergeF, BoundsMergeF) -> t() when
BoundsMergeF :: fun((var(), [type()], [type()]) -> [type()]).
combine_with(C1, C2, MergeLBounds, MergeUBounds) ->
combine([Cs1, Cs2 | Css], Env) ->
Cs = do_combine(Cs1, Cs2, Env),
combine([Cs | Css], Env).

-spec do_combine(t(), t(), env()) -> t().
do_combine(Cs1, Cs2, Env) ->
MergeLBounds = fun (_Var, T1, T2) -> typechecker:lub([T1, T2], Env) end,
MergeUBounds = fun (_Var, T1, T2) -> typechecker:glb(T1, T2, Env) end,
LBounds = gradualizer_lib:merge_with(MergeLBounds,
C1#constraints.lower_bounds,
C2#constraints.lower_bounds),
Cs1#constraints.lower_bounds,
Cs2#constraints.lower_bounds),
UBounds = gradualizer_lib:merge_with(MergeUBounds,
C1#constraints.upper_bounds,
C2#constraints.upper_bounds),
EVars = maps:merge(C1#constraints.exist_vars, C2#constraints.exist_vars),
#constraints{lower_bounds = LBounds,
upper_bounds = UBounds,
exist_vars = EVars}.

-spec solve(t(), erl_anno:anno(), typechecker:env()) -> R when
R :: {t(), {#{var() => type()}, #{var() => type()}}}.
solve(Constraints, Anno, Env) ->
ElimVars = Constraints#constraints.exist_vars,
WorkList = [ {E, LB, UB} || E <- lists:sort(maps:keys(ElimVars)),
LB <- maps:get(E, Constraints#constraints.lower_bounds, []),
UB <- maps:get(E, Constraints#constraints.upper_bounds, []) ],
Cs = solve_loop(WorkList, maps:new(), Constraints, ElimVars, Anno, Env),
GlbSubs = fun(_Var, Tys) ->
{Ty, _C} = typechecker:glb(Tys, Env),
% TODO: Don't throw away the constraints
Ty
end,
LubSubs = fun(_Var, Tys) ->
Ty = typechecker:lub(Tys, Env),
Ty
end,
% TODO: What if the substition contains occurrences of the variables we're eliminating
% in the range of the substitution?
Subst = { maps:map(GlbSubs, maps:with(maps:keys(ElimVars), Cs#constraints.upper_bounds)),
maps:map(LubSubs, maps:with(maps:keys(ElimVars), Cs#constraints.lower_bounds)) },
UBounds = maps:without(maps:keys(ElimVars), Cs#constraints.upper_bounds),
LBounds = maps:without(maps:keys(ElimVars), Cs#constraints.lower_bounds),
C = #constraints{upper_bounds = UBounds,
lower_bounds = LBounds,
exist_vars = maps:new()},
{C, Subst}.

solve_loop([], _, Constraints, _, _, _) ->
Constraints;
solve_loop([I = {E, LB, UB} | WL], Seen, Constraints0, ElimVars, Anno, Env) ->
case maps:is_key(I, Seen) of
true ->
solve_loop(WL, Seen, Constraints0, ElimVars, Anno, Env);
false ->
Constraints1 = case typechecker:subtype(LB, UB, Env) of
false ->
throw({constraint_error, Anno, E, LB, UB});
{true, Cs} ->
Cs
end,

% Subtyping should not create new existential variables
?assert(Constraints1#constraints.exist_vars == #{}),

ELowerBounds = maps:with(maps:keys(ElimVars), Constraints1#constraints.lower_bounds),
EUpperBounds = maps:with(maps:keys(ElimVars), Constraints1#constraints.upper_bounds),

LBounds = gradualizer_lib:merge_with(fun append_values/3,
Constraints0#constraints.lower_bounds,
Constraints1#constraints.lower_bounds),
UBounds = gradualizer_lib:merge_with(fun append_values/3,
Constraints0#constraints.upper_bounds,
Constraints1#constraints.upper_bounds),
Constraints2 = #constraints{lower_bounds = LBounds,
upper_bounds = UBounds},
NewWL = ([ {EVar, Lower, Upper}
|| {EVar, Lowers} <- maps:to_list(ELowerBounds),
Lower <- Lowers,
Upper <- maps:get(EVar, Constraints2#constraints.upper_bounds, []) ] ++
[ {EVar, Lower, Upper}
|| {EVar, Uppers} <- maps:to_list(EUpperBounds),
Upper <- Uppers,
Lower <- maps:get(EVar, Constraints2#constraints.lower_bounds, []) ] ++
WL),
solve_loop(NewWL, maps:put(I, true, Seen), Constraints2, ElimVars, Anno, Env)
Cs1#constraints.upper_bounds,
Cs2#constraints.upper_bounds),
Combined = #constraints{lower_bounds = LBounds,
upper_bounds = UBounds},
Combined.


-spec variables(t()) -> [var()].
variables(#constraints{ upper_bounds = UBounds, lower_bounds = LBounds }) ->
gradualizer_lib:uniq(maps:keys(UBounds) ++ maps:keys(LBounds)).


%% Checks that all lower bounds are subtypes of respective upper bounds.
-spec satisfiable(t(), env()) -> true | {false, var(), type(), type()}.
satisfiable(Cs, Env) ->
Vars = variables(Cs),
try
lists:foreach(fun (Var) ->
LBound = maps:get(Var, Cs#constraints.lower_bounds, typechecker:type(none)),
UBound = maps:get(Var, Cs#constraints.upper_bounds, typechecker:type(top)),
case typechecker:subtype(LBound, UBound, Env) of
false -> throw({not_subtype, Var, LBound, UBound});
true -> ok
end
end, Vars),
true
catch
{not_subtype, Var, LBound, UBound} -> {false, Var, LBound, UBound}
end.

append_values(_, Xs, Ys) ->
Xs ++ Ys.

has_upper_bound(Var, Cs) ->
maps:is_key(Var, Cs#constraints.upper_bounds).
7 changes: 3 additions & 4 deletions src/constraints.hrl
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
-define(__CONSTRAINTS_HRL__, true).

-record(constraints, { lower_bounds = #{} :: #{ constraints:var() =>
[gradualizer_type:abstract_type()] },
upper_bounds = #{} :: #{ constraints:var() =>
[gradualizer_type:abstract_type()] },
exist_vars = #{} :: constraints:mapset(constraints:var()) }).
gradualizer_type:abstract_type() },
upper_bounds = #{} :: #{ constraints:var() =>
gradualizer_type:abstract_type() } }).

-endif. %% __CONSTRAINTS_HRL__
7 changes: 5 additions & 2 deletions src/gradualizer.erl
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,10 @@ type_check_files(Files, Opts) ->
lists:foldl(
fun(File, Errors) when Errors =:= [];
not StopOnFirstError ->
type_check_file_or_dir(File, Opts) ++ Errors;
NewErrors = type_check_file_or_dir(File, Opts),
% we can assert because we pass in the return_errors option
NewErrors = ?assert_type(NewErrors, [any()]),
NewErrors ++ Errors;
(_, Errors) ->
Errors
end, [], Files);
Expand Down Expand Up @@ -424,5 +427,5 @@ type_of(Expr) ->
type_of(Expr, Env) ->
AlwaysInfer = Env#env{infer = true},
[Form] = gradualizer_lib:ensure_form_list(merl:quote(lists:flatten(Expr))),
{Ty, _Env, _Cs} = typechecker:type_check_expr(AlwaysInfer, Form),
{Ty, _Env} = typechecker:type_check_expr(AlwaysInfer, Form),
Ty.
20 changes: 10 additions & 10 deletions src/gradualizer_cli.erl
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ start_application(Opts) ->
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(),
% gradualizer_tracer:start(),
{ok, _} = application:ensure_all_started(gradualizer).

-spec handle_args([string()]) -> help | version | {error, string()} |
Expand Down Expand Up @@ -92,15 +92,15 @@ print_usage() ->
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"),
io:format(" --solve_constraints - Use the experimental constraint solver (off by default)~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"),
io:format(" --solve_constraints Type check polymorphic calls (off by default)~n").

-spec parse_opts([string()], gradualizer:options()) -> {[string()], gradualizer:options()}.
parse_opts([], Opts) ->
Expand Down
32 changes: 24 additions & 8 deletions src/gradualizer_fmt.erl
Original file line number Diff line number Diff line change
Expand Up @@ -350,14 +350,27 @@ format_type_error({unsupported_expression, Anno, Expr}, Opts) ->
[format_location(Anno, brief, Opts),
atom_to_list(element(1, Expr)),
format_location(Anno, verbose, Opts)]);
format_type_error({constraint_error, Anno, E, LB, UB}, Opts) ->
io_lib:format(
"~sLower bound ~s of type variable ~s~s is not a subtype of ~s~n",
format_type_error({constraint_error, TyVar, LB, UB, {call, Anno, Name, Args} = Expr, FunTy, ArgTys}, Opts) ->
FancyExpr = try_highlight_in_context(Expr, Opts),
FunExpr = {'fun', Anno, absform:extract_function_from_call(Expr)},
FormattedName = [pp_expr(Name, Opts), "/", integer_to_list(length(Args))],
ArgsWithTypes = lists:zip(Args, ArgTys),
Bindings = [{FunExpr, FunTy} | ArgsWithTypes],
FormattedBindings = lists:map(fun ({Arg, ArgTy}) ->
io_lib:format(" ~s :: ~s~n", [pp_expr(Arg, Opts), pp_type(ArgTy, Opts)])
end, Bindings),
io_lib:format(
"~sThe type variable ~s in the call to ~s~s is instantiated to both ~s and ~s."
++ " The former should be a subtype of the latter, but it is not."
++ "~n~tsRelevant bindings:~n~s~n",
[format_location(Anno, brief, Opts),
pp_type(LB, Opts),
E,
maybe_colorize(atom_to_list(TyVar), Opts),
maybe_colorize(FormattedName, Opts),
format_location(Anno, verbose, Opts),
pp_type(UB, Opts)]);
pp_type(LB, Opts),
pp_type(UB, Opts),
FancyExpr,
FormattedBindings]);
format_type_error({Location, Module, ErrorDescription}, Opts)
when is_integer(Location) orelse is_tuple(Location),
is_atom(Module) ->
Expand Down Expand Up @@ -509,9 +522,12 @@ pp_type(Ty, Opts) ->
Fun when is_function(Fun) -> Fun(Ty);
_ -> typelib:pp_type(Ty)
end,
maybe_colorize(PP, Opts).

maybe_colorize(IoList, Opts) ->
case use_color(Opts) of
true -> [?color_type, PP, ?color_end];
false -> PP
true -> [?color_type, IoList, ?color_end];
false -> IoList
end.

print_errors(Errors, Opts) ->
Expand Down
2 changes: 1 addition & 1 deletion src/gradualizer_highlight.erl
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ blank("") -> "".
EndCol :: erl_anno:column(),
Color :: boolean().
color_and_mark_line(Line, C1, C2, Color) when C2 >= C1 ->
{Pre, Rest} = lists:split(C1 - 1, Line),
{Pre, Rest} = lists:split(?assert_type(C1 - 1, non_neg_integer()), Line),
HighlightLen = ?assert_type(C2 - C1, non_neg_integer()),
{Mid, Post} = lists:split(HighlightLen, Rest),
{ColorText, ColorMarker, ColorEnd} =
Expand Down
Loading

0 comments on commit 3bdca60

Please sign in to comment.