From bc8948ff12f8865dcaaa931efeb974a6338ba0f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Tue, 27 May 2025 13:17:39 +0200 Subject: [PATCH 01/10] Add more tests --- lib/elixir/lib/module/types/apply.ex | 71 +++++++++++++++++++ lib/elixir/lib/module/types/descr.ex | 64 ++++++----------- lib/elixir/lib/module/types/expr.ex | 34 +-------- .../test/elixir/module/types/descr_test.exs | 8 +++ 4 files changed, 103 insertions(+), 74 deletions(-) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index be5f050462..16c9f6dcfa 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -610,6 +610,19 @@ defmodule Module.Types.Apply do not Enum.any?(stack.no_warn_undefined, &(&1 == module or &1 == {module, fun, arity})) end + ## Funs + + def fun_apply(fun_type, args_types, call, stack, context) do + case fun_apply(fun_type, args_types) do + {:ok, res} -> + {res, context} + + reason -> + error = {{:badapply, reason}, args_types, fun_type, call, context} + {error_type(), error(__MODULE__, error, elem(call, 1), stack, context)} + end + end + ## Local def local_domain(fun, args, expected, meta, stack, context) do @@ -805,6 +818,64 @@ defmodule Module.Types.Apply do ## Diagnostics + def format_diagnostic({{:badapply, reason}, args_types, fun_type, expr, context}) do + traces = + if reason == :badarg do + collect_traces(expr, context) + else + # In case there the type itself is invalid, + # we limit the trace. + collect_traces(elem(expr, 0), context) + end + + message = + case reason do + :badarg -> + """ + expected a #{length(args_types)}-arity function on call: + + #{expr_to_string(expr) |> indent(4)} + + but got type: + + #{to_quoted_string(fun_type) |> indent(4)} + """ + + {:badarity, arities} -> + info = + case arities do + [arity] -> "function with arity #{arity}" + _ -> "function with arities #{Enum.join(arities, ",")}" + end + + """ + expected a #{length(args_types)}-arity function on call: + + #{expr_to_string(expr) |> indent(4)} + + but got #{info}: + + #{to_quoted_string(fun_type) |> indent(4)} + """ + + :badfun -> + """ + expected a #{length(args_types)}-arity function on call: + + #{expr_to_string(expr) |> indent(4)} + + but got type: + + #{to_quoted_string(fun_type) |> indent(4)} + """ + end + + %{ + details: %{typing_traces: traces}, + message: IO.iodata_to_binary([message, format_traces(traces)]) + } + end + def format_diagnostic({:badlocal, {_, domain, clauses}, args_types, expr, context}) do domain = domain(domain, clauses) traces = collect_traces(expr, context) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index a7a5d595d5..aa89d937f0 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -717,39 +717,6 @@ defmodule Module.Types.Descr do do: {type, [], []} end - ## Funs - - @doc """ - Checks if a function type with the specified arity exists in the descriptor. - - 1. If there is no dynamic component: - - The static part must be a non-empty function type of the given arity - - 2. If there is a dynamic component: - - Either the static part is a non-empty function type of the given arity, or - - The static part is empty and the dynamic part contains functions of the given arity - """ - # TODO: REMOVE ME - def fun_fetch(:term, _arity), do: :error - - def fun_fetch(%{} = descr, arity) when is_integer(arity) do - case :maps.take(:dynamic, descr) do - :error -> - if not empty?(descr) and fun_only?(descr, arity), do: :ok, else: :error - - {dynamic, static} -> - empty_static? = empty?(static) - - cond do - not empty_static? -> if fun_only?(static, arity), do: :ok, else: :error - empty_static? and not empty?(intersection(dynamic, fun(arity))) -> :ok - true -> :error - end - end - end - - defp fun_only?(descr, arity), do: empty?(difference(descr, fun(arity))) - ## Atoms # The atom component of a type consists of pairs `{tag, set}` where `set` is a @@ -984,22 +951,26 @@ defmodule Module.Types.Descr do @doc """ Applies a function type to a list of argument types. - Returns the result type if the application is valid, or `:badarg` if not. + Returns `{:ok, result}` if the application is valid + or one `:badarg`, `:badfun`, `{:badarity, arities}` if not. Handles both static and dynamic function types: + 1. For static functions: checks exact argument types 2. For dynamic functions: computes result based on both static and dynamic parts 3. For mixed static/dynamic: computes all valid combinations - # Function application formula for dynamic types: - # τ◦τ′ = (lower_bound(τ) ◦ upper_bound(τ′)) ∨ (dynamic(upper_bound(τ) ◦ lower_bound(τ′))) - # - # Where: - # - τ is a dynamic function type - # - τ′ are the arguments - # - ◦ is function application - # - # For more details, see Definition 6.15 in https://vlanvin.fr/papers/thesis.pdf + ## Function application formula for dynamic types + + τ◦τ′ = (lower_bound(τ) ◦ upper_bound(τ′)) ∨ (dynamic(upper_bound(τ) ◦ lower_bound(τ′))) + + Where: + + - τ is a dynamic function type + - τ′ are the arguments + - ◦ is function application + + For more details, see Definition 6.15 in https://vlanvin.fr/papers/thesis.pdf ## Examples @@ -1028,6 +999,13 @@ defmodule Module.Types.Descr do :badfun end + # Optimize the cases where dynamic closes over all function types + {:term, fun_static} when fun_static == %{} -> + {:ok, dynamic()} + + {%{fun: @fun_top}, fun_static} when fun_static == %{} -> + {:ok, dynamic()} + {fun_dynamic, fun_static} -> if fun_only?(fun_static) do fun_apply_with_strategy(fun_static, fun_dynamic, arguments) diff --git a/lib/elixir/lib/module/types/expr.ex b/lib/elixir/lib/module/types/expr.ex index 4ca70a1217..82a07f256e 100644 --- a/lib/elixir/lib/module/types/expr.ex +++ b/lib/elixir/lib/module/types/expr.ex @@ -450,20 +450,13 @@ defmodule Module.Types.Expr do end # TODO: fun.(args) - def of_expr({{:., meta, [fun]}, _meta, args} = call, _expected, _expr, stack, context) do + def of_expr({{:., _, [fun]}, _, args} = call, _expected, _expr, stack, context) do {fun_type, context} = of_expr(fun, fun(length(args)), call, stack, context) - {_args_types, context} = + {args_types, context} = Enum.map_reduce(args, context, &of_expr(&1, @pending, &1, stack, &2)) - case fun_fetch(fun_type, length(args)) do - :ok -> - {dynamic(), context} - - :error -> - error = {:badfun, length(args), fun_type, fun, call, context} - {error_type(), error(__MODULE__, error, meta, stack, context)} - end + Apply.fun_apply(fun_type, args_types, call, stack, context) end def of_expr({{:., _, [callee, key_or_fun]}, meta, []} = call, expected, expr, stack, context) @@ -863,27 +856,6 @@ defmodule Module.Types.Expr do } end - def format_diagnostic({:badfun, arity, type, fun_expr, call_expr, context}) do - traces = collect_traces(fun_expr, context) - - %{ - details: %{typing_traces: traces}, - message: - IO.iodata_to_binary([ - """ - expected a #{arity}-arity function on call: - - #{expr_to_string(call_expr) |> indent(4)} - - but got type: - - #{to_quoted_string(type) |> indent(4)} - """, - format_traces(traces) - ]) - } - end - def format_diagnostic({:badcond, explain, type, expr, context}) do traces = collect_traces(expr, context) diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 895e3b1228..81c4299725 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -758,6 +758,10 @@ defmodule Module.Types.DescrTest do end test "static" do + # Full static + assert fun_apply(fun(), [integer()]) == {:ok, term()} + assert fun_apply(difference(fun(), fun(2)), [integer()]) == {:ok, term()} + # Basic function application scenarios assert fun_apply(fun([integer()], atom()), [integer()]) == {:ok, atom()} assert fun_apply(fun([integer()], atom()), [float()]) == :badarg @@ -820,6 +824,10 @@ defmodule Module.Types.DescrTest do defp dynamic_fun(args, return), do: dynamic(fun(args, return)) test "dynamic" do + # Full dynamic + assert fun_apply(dynamic(), [integer()]) == {:ok, dynamic()} + assert fun_apply(difference(dynamic(), integer()), [integer()]) == {:ok, dynamic()} + # Basic function application scenarios assert fun_apply(dynamic_fun([integer()], atom()), [integer()]) == {:ok, dynamic(atom())} assert fun_apply(dynamic_fun([integer()], atom()), [float()]) == :badarg From c4301b6fb6a22c929bb4c5f48b4adfb5a79ed53c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Tue, 27 May 2025 19:43:30 +0200 Subject: [PATCH 02/10] Progress? --- lib/elixir/lib/module/types/descr.ex | 30 +++++--- lib/elixir/lib/module/types/expr.ex | 2 +- .../test/elixir/module/types/descr_test.exs | 69 +++++++++++-------- 3 files changed, 61 insertions(+), 40 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index aa89d937f0..c818b5cee6 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -1000,15 +1000,21 @@ defmodule Module.Types.Descr do end # Optimize the cases where dynamic closes over all function types - {:term, fun_static} when fun_static == %{} -> - {:ok, dynamic()} + # {:term, fun_static} when fun_static == %{} -> + # {:ok, dynamic()} - {%{fun: @fun_top}, fun_static} when fun_static == %{} -> - {:ok, dynamic()} + # {%{fun: @fun_top}, fun_static} when fun_static == %{} -> + # {:ok, dynamic()} {fun_dynamic, fun_static} -> if fun_only?(fun_static) do - fun_apply_with_strategy(fun_static, fun_dynamic, arguments) + with :badarg <- fun_apply_with_strategy(fun_static, fun_dynamic, arguments) do + if compatible?(fun, fun(arguments, term())) do + {:ok, dynamic()} + else + :badarg + end + end else :badfun end @@ -1106,11 +1112,19 @@ defmodule Module.Types.Descr do # # This function is used internally by `fun_apply_*`, and others to # ensure consistent handling of function types in all operations. + defp fun_normalize(:term, arity, mode) do + fun_normalize(%{fun: @fun_top}, arity, mode) + end + defp fun_normalize(%{fun: bdd}, arity, mode) do {domain, arrows, bad_arities} = Enum.reduce(fun_get(bdd), {term(), [], []}, fn - {[{args, _} | _] = pos_funs, neg_funs}, {domain, arrows, bad_arities} -> - arrow_arity = length(args) + {pos_funs, neg_funs}, {domain, arrows, bad_arities} -> + arrow_arity = + case pos_funs do + [{args, _} | _] -> length(args) + _ -> arity + end cond do arrow_arity != arity -> @@ -1393,8 +1407,6 @@ defmodule Module.Types.Descr do end # Converts a function BDD (Binary Decision Diagram) to its quoted representation. - defp fun_to_quoted(:fun, _opts), do: [{:fun, [], []}] - defp fun_to_quoted(bdd, opts) do arrows = fun_get(bdd) diff --git a/lib/elixir/lib/module/types/expr.ex b/lib/elixir/lib/module/types/expr.ex index 82a07f256e..eaa7bb3160 100644 --- a/lib/elixir/lib/module/types/expr.ex +++ b/lib/elixir/lib/module/types/expr.ex @@ -51,7 +51,7 @@ defmodule Module.Types.Expr do @stacktrace list( union( tuple([atom(), atom(), args_or_arity, extra_info]), - tuple([fun(), args_or_arity, extra_info]) + tuple([dynamic(fun()), args_or_arity, extra_info]) ) ) diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 81c4299725..162fcfc698 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -10,7 +10,7 @@ end defmodule Module.Types.DescrTest do use ExUnit.Case, async: true - import Module.Types.Descr + import Module.Types.Descr, except: [fun: 1] describe "union" do test "bitmap" do @@ -111,7 +111,7 @@ defmodule Module.Types.DescrTest do test "fun" do assert equal?(union(fun(), fun()), fun()) - assert equal?(union(fun(), fun(1)), fun()) + assert equal?(union(fun(), none_fun(1)), fun()) dynamic_fun = intersection(fun(), dynamic()) assert equal?(union(dynamic_fun, fun()), fun()) @@ -335,7 +335,7 @@ defmodule Module.Types.DescrTest do end test "function" do - assert not empty?(intersection(negation(fun(2)), negation(fun(3)))) + assert not empty?(intersection(negation(none_fun(2)), negation(none_fun(3)))) end end @@ -528,19 +528,19 @@ defmodule Module.Types.DescrTest do test "fun" do for arity <- [0, 1, 2, 3] do - assert empty?(difference(fun(arity), fun(arity))) + assert empty?(difference(none_fun(arity), none_fun(arity))) end assert empty?(difference(fun(), fun())) - assert empty?(difference(fun(3), fun())) - refute empty?(difference(fun(), fun(1))) - refute empty?(difference(fun(2), fun(3))) - assert empty?(intersection(fun(2), fun(3))) + assert empty?(difference(none_fun(3), fun())) + refute empty?(difference(fun(), none_fun(1))) + refute empty?(difference(none_fun(2), none_fun(3))) + assert empty?(intersection(none_fun(2), none_fun(3))) - f1f2 = union(fun(1), fun(2)) - assert f1f2 |> difference(fun(1)) |> difference(fun(2)) |> empty?() - assert fun(1) |> difference(difference(f1f2, fun(2))) |> empty?() - assert f1f2 |> difference(fun(1)) |> equal?(fun(2)) + f1f2 = union(none_fun(1), none_fun(2)) + assert f1f2 |> difference(none_fun(1)) |> difference(none_fun(2)) |> empty?() + assert none_fun(1) |> difference(difference(f1f2, none_fun(2))) |> empty?() + assert f1f2 |> difference(none_fun(1)) |> equal?(none_fun(2)) assert fun([integer()], term()) |> difference(fun([none()], term())) |> empty?() end @@ -742,25 +742,27 @@ defmodule Module.Types.DescrTest do test "fun" do refute empty?(fun()) - refute empty?(fun(1)) + refute empty?(none_fun(1)) refute empty?(fun([integer()], atom())) - assert empty?(intersection(fun(1), fun(2))) - refute empty?(intersection(fun(), fun(1))) - assert empty?(difference(fun(1), union(fun(1), fun(2)))) + assert empty?(intersection(none_fun(1), none_fun(2))) + refute empty?(intersection(fun(), none_fun(1))) + assert empty?(difference(none_fun(1), union(none_fun(1), none_fun(2)))) end end describe "function application" do + defp none_fun(arity), do: fun(List.duplicate(none(), arity), term()) + test "non funs" do assert fun_apply(term(), [integer()]) == :badfun - assert fun_apply(union(integer(), fun(1)), [integer()]) == :badfun + assert fun_apply(union(integer(), none_fun(1)), [integer()]) == :badfun end test "static" do # Full static - assert fun_apply(fun(), [integer()]) == {:ok, term()} - assert fun_apply(difference(fun(), fun(2)), [integer()]) == {:ok, term()} + assert fun_apply(fun(), [integer()]) == :badarg + assert fun_apply(difference(fun(), none_fun(2)), [integer()]) == :badarg # Basic function application scenarios assert fun_apply(fun([integer()], atom()), [integer()]) == {:ok, atom()} @@ -776,7 +778,13 @@ defmodule Module.Types.DescrTest do assert fun_apply(fun([integer()], integer()), [term(), term()]) == {:badarity, [1]} assert fun_apply(fun([integer(), atom()], boolean()), [integer()]) == {:badarity, [2]} - # Function intersection tests - basic + # Function intersection tests (no overlap) + fun0 = intersection(fun([integer()], atom()), fun([float()], binary())) + assert fun_apply(fun0, [integer()]) == {:ok, atom()} + assert fun_apply(fun0, [float()]) == {:ok, binary()} + assert fun_apply(fun0, [union(integer(), float())]) == {:ok, union(atom(), binary())} + + # Function intersection tests (overlap) fun1 = intersection(fun([integer()], atom()), fun([number()], term())) assert fun_apply(fun1, [integer()]) == {:ok, atom()} assert fun_apply(fun1, [float()]) == {:ok, term()} @@ -816,8 +824,8 @@ defmodule Module.Types.DescrTest do fun = fun([dynamic(integer())], atom()) assert fun_apply(fun, [dynamic(integer())]) |> elem(1) |> equal?(atom()) - assert fun_apply(fun, [dynamic(number())]) == :badarg - assert fun_apply(fun, [integer()]) == :badarg + assert fun_apply(fun, [dynamic(number())]) == {:ok, dynamic()} + assert fun_apply(fun, [integer()]) == {:ok, dynamic()} assert fun_apply(fun, [float()]) == :badarg end @@ -826,12 +834,13 @@ defmodule Module.Types.DescrTest do test "dynamic" do # Full dynamic assert fun_apply(dynamic(), [integer()]) == {:ok, dynamic()} - assert fun_apply(difference(dynamic(), integer()), [integer()]) == {:ok, dynamic()} + assert fun_apply(dynamic(none_fun(1)), [integer()]) == {:ok, dynamic()} + assert fun_apply(difference(dynamic(), none_fun(2)), [integer()]) == {:ok, dynamic()} # Basic function application scenarios assert fun_apply(dynamic_fun([integer()], atom()), [integer()]) == {:ok, dynamic(atom())} - assert fun_apply(dynamic_fun([integer()], atom()), [float()]) == :badarg - assert fun_apply(dynamic_fun([integer()], atom()), [term()]) == :badarg + assert fun_apply(dynamic_fun([integer()], atom()), [float()]) == {:ok, dynamic()} + assert fun_apply(dynamic_fun([integer()], atom()), [term()]) == {:ok, dynamic()} assert fun_apply(dynamic_fun([integer()], none()), [integer()]) == {:ok, dynamic(none())} assert fun_apply(dynamic_fun([integer()], term()), [integer()]) == {:ok, dynamic()} @@ -841,7 +850,7 @@ defmodule Module.Types.DescrTest do fun = dynamic_fun([integer()], binary()) assert fun_apply(fun, [integer()]) == {:ok, dynamic(binary())} assert fun_apply(fun, [dynamic(integer())]) == {:ok, dynamic(binary())} - assert fun_apply(fun, [dynamic(atom())]) == :badarg + assert fun_apply(fun, [dynamic(atom())]) == {:ok, dynamic()} # Arity mismatches assert fun_apply(dynamic_fun([integer()], integer()), [term(), term()]) == {:badarity, [1]} @@ -916,20 +925,20 @@ defmodule Module.Types.DescrTest do dynamic_fun([integer()], binary()) ) - assert fun_args |> fun_apply([atom()]) == :badarg + assert fun_args |> fun_apply([atom()]) == {:ok, dynamic()} assert fun_args |> fun_apply([integer()]) == :badarg # Badfun assert union( fun([atom()], integer()), - dynamic_fun([integer()], binary()) |> intersection(fun(2)) + dynamic_fun([integer()], binary()) |> intersection(none_fun(2)) ) |> fun_apply([atom()]) |> elem(1) |> equal?(integer()) assert union( - fun([atom()], integer()) |> intersection(fun(2)), + fun([atom()], integer()) |> intersection(none_fun(2)), dynamic_fun([integer()], binary()) ) |> fun_apply([integer()]) == {:ok, dynamic(binary())} @@ -1744,7 +1753,7 @@ defmodule Module.Types.DescrTest do test "function" do assert fun() |> to_quoted_string() == "fun()" - assert fun(1) |> to_quoted_string() == "(none() -> term())" + assert none_fun(1) |> to_quoted_string() == "(none() -> term())" assert fun([dynamic(integer())], float()) |> to_quoted_string() == "dynamic((none() -> float())) or (integer() -> float())" From 684acb91716185c50e83fe77805269fb6f449084 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Tue, 27 May 2025 21:13:29 +0200 Subject: [PATCH 03/10] Remove dynamic from stacktrace --- lib/elixir/lib/module/types/apply.ex | 1 + lib/elixir/lib/module/types/descr.ex | 8 ++++---- lib/elixir/lib/module/types/expr.ex | 4 ++-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index 16c9f6dcfa..3f24ef50ed 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -830,6 +830,7 @@ defmodule Module.Types.Apply do message = case reason do + # TODO: Return the domain here :badarg -> """ expected a #{length(args_types)}-arity function on call: diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index c818b5cee6..e385c940ca 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -1000,11 +1000,11 @@ defmodule Module.Types.Descr do end # Optimize the cases where dynamic closes over all function types - # {:term, fun_static} when fun_static == %{} -> - # {:ok, dynamic()} + {:term, fun_static} when fun_static == %{} -> + {:ok, dynamic()} - # {%{fun: @fun_top}, fun_static} when fun_static == %{} -> - # {:ok, dynamic()} + {%{fun: @fun_top}, fun_static} when fun_static == %{} -> + {:ok, dynamic()} {fun_dynamic, fun_static} -> if fun_only?(fun_static) do diff --git a/lib/elixir/lib/module/types/expr.ex b/lib/elixir/lib/module/types/expr.ex index eaa7bb3160..bef0a23484 100644 --- a/lib/elixir/lib/module/types/expr.ex +++ b/lib/elixir/lib/module/types/expr.ex @@ -51,7 +51,7 @@ defmodule Module.Types.Expr do @stacktrace list( union( tuple([atom(), atom(), args_or_arity, extra_info]), - tuple([dynamic(fun()), args_or_arity, extra_info]) + tuple([fun(), args_or_arity, extra_info]) ) ) @@ -449,10 +449,10 @@ defmodule Module.Types.Expr do {dynamic(), context} end - # TODO: fun.(args) def of_expr({{:., _, [fun]}, _, args} = call, _expected, _expr, stack, context) do {fun_type, context} = of_expr(fun, fun(length(args)), call, stack, context) + # TODO: Perform inference based on the strong domain of a function {args_types, context} = Enum.map_reduce(args, context, &of_expr(&1, @pending, &1, stack, &2)) From 981ef722d82525d5a868ad7bfd94679a30c2bf87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Wed, 28 May 2025 10:05:29 +0200 Subject: [PATCH 04/10] domain_to_args and args_to_domain --- lib/elixir/lib/module/types/apply.ex | 22 +- lib/elixir/lib/module/types/descr.ex | 215 +++++++++--------- .../test/elixir/module/types/descr_test.exs | 28 +-- 3 files changed, 129 insertions(+), 136 deletions(-) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index 3f24ef50ed..106d859c03 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -820,17 +820,27 @@ defmodule Module.Types.Apply do def format_diagnostic({{:badapply, reason}, args_types, fun_type, expr, context}) do traces = - if reason == :badarg do - collect_traces(expr, context) - else - # In case there the type itself is invalid, - # we limit the trace. - collect_traces(elem(expr, 0), context) + case reason do + # Include arguments in traces in case of badarg + {:badarg, _} -> collect_traces(expr, context) + # Otherwise just the fun + _ -> collect_traces(elem(expr, 0), context) end message = case reason do # TODO: Return the domain here + {:badarg, _} -> + """ + expected a #{length(args_types)}-arity function on call: + + #{expr_to_string(expr) |> indent(4)} + + but got type: + + #{to_quoted_string(fun_type) |> indent(4)} + """ + :badarg -> """ expected a #{length(args_types)}-arity function on call: diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index e385c940ca..581438449f 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -133,15 +133,50 @@ defmodule Module.Types.Descr do end @doc """ + Converts a list of arguments into a domain. + Tuples represent function domains, using unions to combine parameters. - Example: for functions (integer, float ->:ok) and (float, integer -> :error) - domain isn't {integer|float,integer|float} as that would incorrectly accept {float,float} - Instead, it is {integer,float} or {float,integer} + Example: for functions (integer(), float() -> :ok) and (float(), integer() -> :error) + domain isn't `{integer() or float(), integer() or float()}` as that would incorrectly + accept `{float(), float()}`, instead it is `{integer(), float()} or {float(), integer()}`. + """ + def args_to_domain(types) when is_list(types), do: tuple(types) + + @doc """ + Converts the domain to arguments. + + The domain is expected to be closed tuples. They may have complex negations + which are then simplified to a union of positive tuple literals only. - Made public for testing. + * For static tuple types: eliminates all negations from the DNF representation. + + * For gradual tuple types: processes both dynamic and static components separately, + then combines them. + + Internally it uses `tuple_reduce/4` with concatenation as the join function + and a transform that is simply the identity. """ - def domain_descr(types) when is_list(types), do: tuple(types) + def domain_to_args(descr) do + case :maps.take(:dynamic, descr) do + :error -> + {tuple_elim_negations_static(descr), []} + + {dynamic, static} -> + {tuple_elim_negations_static(static), tuple_elim_negations_static(dynamic)} + end + end + + # Call tuple_reduce to build the simple union of tuples that come from each map literal. + # Thus, initial is `[]`, join is concatenation, and the transform of a map literal + # with no negations is just to keep the map literal as is. + defp tuple_elim_negations_static(%{tuple: dnf} = descr) when map_size(descr) == 1 do + tuple_reduce(dnf, [], &Kernel.++/2, fn :closed, elements -> + [elements] + end) + end + + defp tuple_elim_negations_static(descr) when descr == %{}, do: [] ## Optional @@ -988,80 +1023,71 @@ defmodule Module.Types.Descr do end def fun_apply(fun, arguments) do - if empty?(domain_descr(arguments)) do - :badarg - else - case :maps.take(:dynamic, fun) do - :error -> - if fun_only?(fun) do - fun_apply_with_strategy(fun, nil, arguments) - else - :badfun - end + case :maps.take(:dynamic, fun) do + :error -> + if fun_only?(fun) do + fun_apply_with_strategy(fun, fun, nil, arguments) + else + :badfun + end - # Optimize the cases where dynamic closes over all function types - {:term, fun_static} when fun_static == %{} -> - {:ok, dynamic()} - - {%{fun: @fun_top}, fun_static} when fun_static == %{} -> - {:ok, dynamic()} - - {fun_dynamic, fun_static} -> - if fun_only?(fun_static) do - with :badarg <- fun_apply_with_strategy(fun_static, fun_dynamic, arguments) do - if compatible?(fun, fun(arguments, term())) do - {:ok, dynamic()} - else - :badarg - end - end - else - :badfun - end - end + # Optimize the cases where dynamic closes over all function types + {:term, fun_static} when fun_static == %{} -> + {:ok, dynamic()} + + {%{fun: @fun_top}, fun_static} when fun_static == %{} -> + {:ok, dynamic()} + + {fun_dynamic, fun_static} -> + if fun_only?(fun_static) do + fun_apply_with_strategy(fun, fun_static, fun_dynamic, arguments) + else + :badfun + end end end defp fun_only?(descr), do: empty?(Map.delete(descr, :fun)) - defp fun_apply_with_strategy(fun_static, fun_dynamic, arguments) do + defp fun_apply_with_strategy(fun, fun_static, fun_dynamic, arguments) do args_dynamic? = any_dynamic?(arguments) + args_domain = args_to_domain(arguments) + static? = fun_dynamic == nil and not args_dynamic? arity = length(arguments) - # For non-dynamic function and arguments, just return the static result - if fun_dynamic == nil and not args_dynamic? do - with {:ok, static_domain, static_arrows} <- fun_normalize(fun_static, arity, :static) do - if subtype?(domain_descr(arguments), static_domain) do + with {:ok, domain, static_arrows, dynamic_arrows} <- + fun_normalize_both(fun_static, fun_dynamic, arity) do + cond do + empty?(args_domain) -> + {:badarg, domain} + + not subtype?(args_domain, domain) -> + if static? or not compatible?(fun, fun(arguments, term())) do + {:badarg, domain} + else + {:ok, dynamic()} + end + + static? -> {:ok, fun_apply_static(arguments, static_arrows, false)} - else - :badarg - end - end - else - with {:ok, domain, static_arrows, dynamic_arrows} <- - fun_normalize_both(fun_static, fun_dynamic, arity) do - cond do - not subtype?(domain_descr(arguments), domain) -> - :badarg - static_arrows == [] -> - {:ok, dynamic(fun_apply_static(arguments, dynamic_arrows, false))} + static_arrows == [] -> + {:ok, dynamic(fun_apply_static(arguments, dynamic_arrows, false))} - true -> - # For dynamic cases, combine static and dynamic results - {static_args, dynamic_args, maybe_empty?} = - if args_dynamic? do - {Enum.map(arguments, &upper_bound/1), Enum.map(arguments, &lower_bound/1), true} - else - {arguments, arguments, false} - end - - {:ok, - union( - fun_apply_static(static_args, static_arrows, false), - dynamic(fun_apply_static(dynamic_args, dynamic_arrows, maybe_empty?)) - )} - end + true -> + # For dynamic cases, combine static and dynamic results + {static_args, dynamic_args, maybe_empty?} = + if args_dynamic? do + {Enum.map(arguments, &upper_bound/1), Enum.map(arguments, &lower_bound/1), true} + else + {arguments, arguments, false} + end + + {:ok, + union( + fun_apply_static(static_args, static_arrows, false), + dynamic(fun_apply_static(dynamic_args, dynamic_arrows, maybe_empty?)) + )} end end end @@ -1137,7 +1163,7 @@ defmodule Module.Types.Descr do # Calculate domain from all positive functions path_domain = Enum.reduce(pos_funs, none(), fn {args, _}, acc -> - union(acc, domain_descr(args)) + union(acc, args_to_domain(args)) end) {intersection(domain, path_domain), [pos_funs | arrows], bad_arities} @@ -1161,7 +1187,7 @@ defmodule Module.Types.Descr do end defp fun_apply_static(arguments, arrows, maybe_empty?) do - type_args = domain_descr(arguments) + type_args = args_to_domain(arguments) # Optimization: short-circuits when inner loop is none() or outer loop is term() if maybe_empty? and empty?(type_args) do @@ -1202,7 +1228,7 @@ defmodule Module.Types.Descr do defp aux_apply(result, input, returns_reached, [{dom, ret} | arrow_intersections]) do # Calculate the part of the input not covered by this arrow's domain - dom_subtract = difference(input, domain_descr(dom)) + dom_subtract = difference(input, args_to_domain(dom)) # Refine the return type by intersecting with this arrow's return type ret_refine = intersection(returns_reached, ret) @@ -1298,7 +1324,7 @@ defmodule Module.Types.Descr do # function's domain is a supertype of the positive domain and if the phi function # determines emptiness. length(neg_arguments) == positive_arity and - subtype?(domain_descr(neg_arguments), positive_domain) and + subtype?(args_to_domain(neg_arguments), positive_domain) and phi_starter(neg_arguments, negation(neg_return), positives) end) end @@ -1311,10 +1337,10 @@ defmodule Module.Types.Descr do positives |> Enum.reduce_while({:empty, none()}, fn {args, _}, {:empty, _} -> - {:cont, {length(args), domain_descr(args)}} + {:cont, {length(args), args_to_domain(args)}} {args, _}, {arity, dom} when length(args) == arity -> - {:cont, {arity, union(dom, domain_descr(args))}} + {:cont, {arity, union(dom, args_to_domain(args))}} {_args, _}, {_arity, _} -> {:halt, {:empty, none()}} @@ -3091,6 +3117,9 @@ defmodule Module.Types.Descr do end) end + @doc """ + Returns all of the values that are part of a tuple. + """ def tuple_values(descr) do case :maps.take(:dynamic, descr) do :error -> @@ -3182,46 +3211,6 @@ defmodule Module.Types.Descr do ) end - @doc """ - Converts a tuple type to a simple union by eliminating negations. - - Takes a tuple type with complex negations and simplifies it to a union of - positive tuple literals only. - - For static tuple types: eliminates all negations from the DNF representation. - For gradual tuple types: processes both dynamic and static components separately, - then combines them. - - Uses `tuple_reduce/4` with concatenation as the join function and a transform - that is simply the identity. - - Returns the descriptor unchanged for non-tuple types, or a descriptor with - simplified tuple DNF containing only positive literals. If simplification - results in an empty tuple list, removes the `:tuple` key entirely. - """ - def tuple_elim_negations(descr) do - case :maps.take(:dynamic, descr) do - :error -> - tuple_elim_negations_static(descr) - - {dynamic, static} -> - tuple_elim_negations_static(static) - |> union(dynamic(tuple_elim_negations_static(dynamic))) - end - end - - # Call tuple_reduce to build the simple union of tuples that come from each map literal. - # Thus, initial is `[]`, join is concatenation, and the transform of a map literal - # with no negations is just to keep the map literal as is. - defp tuple_elim_negations_static(%{tuple: dnf} = descr) do - case tuple_reduce(dnf, [], &Kernel.++/2, fn tag, elements -> [{tag, elements, []}] end) do - [] -> Map.delete(descr, :tuple) - new_dnf -> %{descr | tuple: new_dnf} - end - end - - defp tuple_elim_negations_static(descr), do: descr - defp tuple_pop_index(tag, elements, index) do case List.pop_at(elements, index) do {nil, _} -> {tag_to_type(tag), %{tuple: [{tag, elements, []}]}} diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index 162fcfc698..ccc88107b6 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -1242,7 +1242,7 @@ defmodule Module.Types.DescrTest do |> equal?(integer()) end - test "tuple_elim_negations" do + test "domain_to_args" do # take complex tuples, normalize them, and check if they are still equal complex_tuples = [ tuple([term(), atom(), number()]) @@ -1251,24 +1251,18 @@ defmodule Module.Types.DescrTest do difference( tuple([union(atom(), pid()), union(integer(), float())]), tuple([union(atom(), pid()), float()]) - ), - # open_tuple case with union in elements - difference( - open_tuple([union(boolean(), pid()), union(atom(), integer())]), - open_tuple([pid(), integer()]) - ), - open_tuple([term(), term(), term()]) - |> difference(open_tuple([term(), integer(), atom(), atom()])) - |> difference(tuple([float(), float(), float(), float(), float()])) - |> difference(tuple([term(), term(), term(), term(), term(), term()])) + ) ] - Enum.each(complex_tuples, fn orig -> - norm = tuple_elim_negations(orig) - # should split into multiple simple tuples - assert equal?(norm, orig) - assert Enum.all?(norm.tuple, fn {_, _, neg} -> neg == [] end) - assert not Enum.all?(orig.tuple, fn {_, _, neg} -> neg == [] end) + multi_args_to_domain = fn args -> + Enum.reduce(args, none(), &union(args_to_domain(&1), &2)) + end + + Enum.each(complex_tuples, fn domain -> + {static, dynamic} = domain_to_args(domain) + + assert union(multi_args_to_domain.(static), dynamic(multi_args_to_domain.(dynamic))) + |> equal?(domain) end) end From f405d311fa92cc16e836cde9c1dedbbfaa0a6b14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Wed, 28 May 2025 11:33:18 +0200 Subject: [PATCH 05/10] Return domain in badarg --- lib/elixir/lib/module/types/descr.ex | 35 ++++++++++++++----- .../test/elixir/module/types/descr_test.exs | 20 +++++------ 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 581438449f..08824f7372 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -144,7 +144,7 @@ defmodule Module.Types.Descr do def args_to_domain(types) when is_list(types), do: tuple(types) @doc """ - Converts the domain to arguments. + Converts the domain to a list of arguments. The domain is expected to be closed tuples. They may have complex negations which are then simplified to a union of positive tuple literals only. @@ -156,27 +156,39 @@ defmodule Module.Types.Descr do Internally it uses `tuple_reduce/4` with concatenation as the join function and a transform that is simply the identity. + + The list of arguments can be flattened into a broad domain by calling: + + |> Enum.zip_with(fn types -> Enum.reduce(types, &union/2) end) """ def domain_to_args(descr) do case :maps.take(:dynamic, descr) do :error -> - {tuple_elim_negations_static(descr), []} + tuple_elim_negations_static(descr, &Function.identity/1) {dynamic, static} -> - {tuple_elim_negations_static(static), tuple_elim_negations_static(dynamic)} + tuple_elim_negations_static(static, &Function.identity/1) ++ + tuple_elim_negations_static(dynamic, fn elems -> Enum.map(elems, &dynamic/1) end) end end # Call tuple_reduce to build the simple union of tuples that come from each map literal. # Thus, initial is `[]`, join is concatenation, and the transform of a map literal # with no negations is just to keep the map literal as is. - defp tuple_elim_negations_static(%{tuple: dnf} = descr) when map_size(descr) == 1 do + defp tuple_elim_negations_static(%{tuple: dnf} = descr, transform) when map_size(descr) == 1 do tuple_reduce(dnf, [], &Kernel.++/2, fn :closed, elements -> - [elements] + [transform.(elements)] end) end - defp tuple_elim_negations_static(descr) when descr == %{}, do: [] + defp tuple_elim_negations_static(descr, _transform) when descr == %{}, do: [] + + defp domain_to_flat_args(domain, arity) do + case domain_to_args(domain) do + [] -> List.duplicate(none(), arity) + args -> Enum.zip_with(args, fn types -> Enum.reduce(types, &union/2) end) + end + end ## Optional @@ -987,7 +999,12 @@ defmodule Module.Types.Descr do Applies a function type to a list of argument types. Returns `{:ok, result}` if the application is valid - or one `:badarg`, `:badfun`, `{:badarity, arities}` if not. + or one `{:badarg, to_succeed_domain}`, `:badfun`, + `{:badarity, arities}` if not. + + Note the domain returned by `:badarg` is not the strong + domain, but the domain that must be satisfied for the + function application to succeed. Handles both static and dynamic function types: @@ -1059,11 +1076,11 @@ defmodule Module.Types.Descr do fun_normalize_both(fun_static, fun_dynamic, arity) do cond do empty?(args_domain) -> - {:badarg, domain} + {:badarg, domain_to_flat_args(domain, arity)} not subtype?(args_domain, domain) -> if static? or not compatible?(fun, fun(arguments, term())) do - {:badarg, domain} + {:badarg, domain_to_flat_args(domain, arity)} else {:ok, dynamic()} end diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index ccc88107b6..ed75aa8e05 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -761,13 +761,13 @@ defmodule Module.Types.DescrTest do test "static" do # Full static - assert fun_apply(fun(), [integer()]) == :badarg - assert fun_apply(difference(fun(), none_fun(2)), [integer()]) == :badarg + assert fun_apply(fun(), [integer()]) == {:badarg, [none()]} + assert fun_apply(difference(fun(), none_fun(2)), [integer()]) == {:badarg, [none()]} # Basic function application scenarios assert fun_apply(fun([integer()], atom()), [integer()]) == {:ok, atom()} - assert fun_apply(fun([integer()], atom()), [float()]) == :badarg - assert fun_apply(fun([integer()], atom()), [term()]) == :badarg + assert fun_apply(fun([integer()], atom()), [float()]) == {:badarg, [integer()]} + assert fun_apply(fun([integer()], atom()), [term()]) == {:badarg, [integer()]} assert fun_apply(fun([integer()], none()), [integer()]) == {:ok, none()} assert fun_apply(fun([integer()], term()), [integer()]) == {:ok, term()} @@ -826,7 +826,7 @@ defmodule Module.Types.DescrTest do assert fun_apply(fun, [dynamic(integer())]) |> elem(1) |> equal?(atom()) assert fun_apply(fun, [dynamic(number())]) == {:ok, dynamic()} assert fun_apply(fun, [integer()]) == {:ok, dynamic()} - assert fun_apply(fun, [float()]) == :badarg + assert fun_apply(fun, [float()]) == {:badarg, [dynamic(integer())]} end defp dynamic_fun(args, return), do: dynamic(fun(args, return)) @@ -926,7 +926,7 @@ defmodule Module.Types.DescrTest do ) assert fun_args |> fun_apply([atom()]) == {:ok, dynamic()} - assert fun_args |> fun_apply([integer()]) == :badarg + assert fun_args |> fun_apply([integer()]) == {:badarg, [dynamic(atom())]} # Badfun assert union( @@ -1254,14 +1254,10 @@ defmodule Module.Types.DescrTest do ) ] - multi_args_to_domain = fn args -> - Enum.reduce(args, none(), &union(args_to_domain(&1), &2)) - end - Enum.each(complex_tuples, fn domain -> - {static, dynamic} = domain_to_args(domain) + args = domain_to_args(domain) - assert union(multi_args_to_domain.(static), dynamic(multi_args_to_domain.(dynamic))) + assert Enum.reduce(args, none(), &union(args_to_domain(&1), &2)) |> equal?(domain) end) end From 60f7d002feece6d14c20fc7dd45405273eb146c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Wed, 28 May 2025 11:47:44 +0200 Subject: [PATCH 06/10] More progress --- lib/elixir/lib/module/types/apply.ex | 12 +++++---- lib/elixir/lib/module/types/helpers.ex | 9 +++++++ lib/elixir/src/elixir_rewrite.erl | 9 ++++--- .../test/elixir/module/types/expr_test.exs | 26 ++++++++++++++++++- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index 106d859c03..53c5acaccf 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -472,7 +472,6 @@ defmodule Module.Types.Apply do Returns the type of a remote capture. """ def remote_capture(modules, fun, arity, meta, stack, context) do - # TODO: Do we check when the union of functions is invalid? # TODO: Deal with :infer types if stack.mode == :traversal or modules == [] do {dynamic(fun(arity)), context} @@ -829,14 +828,17 @@ defmodule Module.Types.Apply do message = case reason do - # TODO: Return the domain here - {:badarg, _} -> + {:badarg, domain} -> """ - expected a #{length(args_types)}-arity function on call: + incompatible types given on function application: #{expr_to_string(expr) |> indent(4)} - but got type: + given types: + + #{args_to_quoted_string(args_types, domain, &Function.identity/1) |> indent(4)} + + but function has type: #{to_quoted_string(fun_type) |> indent(4)} """ diff --git a/lib/elixir/lib/module/types/helpers.ex b/lib/elixir/lib/module/types/helpers.ex index d13ab354a4..dd55a6c65d 100644 --- a/lib/elixir/lib/module/types/helpers.ex +++ b/lib/elixir/lib/module/types/helpers.ex @@ -324,6 +324,15 @@ defmodule Module.Types.Helpers do {{:., _, [mod, fun]}, meta, args} -> erl_to_ex(mod, fun, args, meta) + {:&, amp_meta, [{:/, slash_meta, [{{:., dot_meta, [mod, fun]}, call_meta, []}, arity]}]} -> + {mod, fun} = + case :elixir_rewrite.erl_to_ex(mod, fun, arity) do + {mod, fun} -> {mod, fun} + false -> {mod, fun} + end + + {:&, amp_meta, [{:/, slash_meta, [{{:., dot_meta, [mod, fun]}, call_meta, []}, arity]}]} + {:case, meta, [expr, [do: clauses]]} = case -> if meta[:type_check] == :expr do case clauses do diff --git a/lib/elixir/src/elixir_rewrite.erl b/lib/elixir/src/elixir_rewrite.erl index 55ce44d953..04ed0206a3 100644 --- a/lib/elixir/src/elixir_rewrite.erl +++ b/lib/elixir/src/elixir_rewrite.erl @@ -44,13 +44,16 @@ inner_rewrite(erl_to_ex, _Meta, ErlMod, ErlFun, ErlArgs) -> {ExMod, ExFun, ExArgs, fun(ErlArgs) -> ExArgs end} ). -erl_to_ex(Mod, Fun, Args) -> +erl_to_ex(Mod, Fun, Args) when is_list(Args) -> case inner_inline(erl_to_ex, Mod, Fun, length(Args)) of false -> inner_rewrite(erl_to_ex, [], Mod, Fun, Args); {ExMod, ExFun} -> {ExMod, ExFun, Args, fun identity/1} - end. + end; + +erl_to_ex(Mod, Fun, Arity) when is_integer(Arity) -> + inner_inline(erl_to_ex, Mod, Fun, Arity). -%% Inline rules +%% Inline rules %% %% Inline rules are straightforward, they keep the same %% number and order of arguments and show up on captures. diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 1d6fc8bdf6..5db5dd7775 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -139,7 +139,7 @@ defmodule Module.Types.ExprTest do ) == dynamic(fun(2)) end - test "incompatible" do + test "bad function" do assert typeerror!([%x{}, a1, a2], x.(a1, a2)) == ~l""" expected a 2-arity function on call: @@ -156,6 +156,30 @@ defmodule Module.Types.ExprTest do %x{} """ end + + test "bad arity" do + assert typeerror!([a1, a2], (&String.to_integer/1).(a1, a2)) == ~l""" + expected a 2-arity function on call: + + (&String.to_integer/1).(a1, a2) + + but got function with arity 1: + + (binary() -> integer()) + """ + end + + test "bad argument" do + assert typeerror!([%a{}], (&String.to_integer/1).(a)) == ~l""" + expected a 2-arity function on call: + + (&String.to_integer/1).(a1, a2) + + but got function with arity 1: + + (binary() -> integer()) + """ + end end describe "remotes" do From 2ec8da5a4921b5ae99684806086c70fb978298a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Wed, 28 May 2025 12:55:51 +0200 Subject: [PATCH 07/10] Overlapping function clauses --- lib/elixir/lib/module/types/apply.ex | 30 +++++++--- lib/elixir/lib/module/types/descr.ex | 37 ++++++++++++ .../test/elixir/module/types/descr_test.exs | 58 +++++++++++++++++++ .../test/elixir/module/types/expr_test.exs | 1 + 4 files changed, 118 insertions(+), 8 deletions(-) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index 53c5acaccf..68efc9f5be 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -472,7 +472,6 @@ defmodule Module.Types.Apply do Returns the type of a remote capture. """ def remote_capture(modules, fun, arity, meta, stack, context) do - # TODO: Deal with :infer types if stack.mode == :traversal or modules == [] do {dynamic(fun(arity)), context} else @@ -482,6 +481,9 @@ defmodule Module.Types.Apply do {{:strong, _, clauses}, context} -> {union(type, fun_from_non_overlapping_clauses(clauses)), fallback?, context} + {{:infer, _, clauses}, context} when length(clauses) <= @max_clauses -> + {union(type, fun_from_overlapping_clauses(clauses)), fallback?, context} + {_, context} -> {type, true, context} end @@ -694,13 +696,25 @@ defmodule Module.Types.Apply do {_kind, _info, context} when stack.mode == :traversal -> {dynamic(fun(arity)), context} - {kind, _info, context} -> - if stack.mode != :infer and kind == :defp do - # Mark all clauses as used, as the function is being exported. - {dynamic(fun(arity)), put_in(context.local_used[fun_arity], [])} - else - {dynamic(fun(arity)), context} - end + {kind, info, context} -> + result = + case info do + {:infer, _, clauses} when length(clauses) <= @max_clauses -> + fun_from_overlapping_clauses(clauses) + + _ -> + dynamic(fun(arity)) + end + + context = + if stack.mode != :infer and kind == :defp do + # Mark all clauses as used, as the function is being exported. + put_in(context.local_used[fun_arity], []) + else + context + end + + {result, context} end end diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index 08824f7372..f242f9e6fb 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -132,6 +132,43 @@ defmodule Module.Types.Descr do end) end + @doc """ + Creates a function from overlapping function clauses. + """ + def fun_from_overlapping_clauses(args_clauses) do + domain_clauses = + Enum.reduce(args_clauses, [], fn {args, return}, acc -> + pivot_overlapping_clause(args_to_domain(args), return, acc) + end) + + funs = + for {domain, return} <- domain_clauses, + args <- domain_to_args(domain), + do: fun(args, return) + + Enum.reduce(funs, &intersection/2) + end + + defp pivot_overlapping_clause(domain, return, [{acc_domain, acc_return} | acc]) do + common = intersection(domain, acc_domain) + + if empty?(common) do + [{acc_domain, acc_return} | pivot_overlapping_clause(domain, return, acc)] + else + [{common, union(return, acc_return)} | acc] + |> prepend_to_unless_empty(difference(domain, common), return) + |> prepend_to_unless_empty(difference(acc_domain, common), acc_return) + end + end + + defp pivot_overlapping_clause(domain, return, []) do + [{domain, return}] + end + + defp prepend_to_unless_empty(acc, domain, return) do + if empty?(domain), do: acc, else: [{domain, return} | acc] + end + @doc """ Converts a list of arguments into a domain. diff --git a/lib/elixir/test/elixir/module/types/descr_test.exs b/lib/elixir/test/elixir/module/types/descr_test.exs index ed75aa8e05..1b9e9e5128 100644 --- a/lib/elixir/test/elixir/module/types/descr_test.exs +++ b/lib/elixir/test/elixir/module/types/descr_test.exs @@ -751,6 +751,64 @@ defmodule Module.Types.DescrTest do end end + describe "function creation" do + test "fun_from_non_overlapping_clauses" do + assert fun_from_non_overlapping_clauses([{[integer()], atom()}, {[float()], binary()}]) == + intersection(fun([integer()], atom()), fun([float()], binary())) + end + + test "fun_from_overlapping_clauses" do + # No overlap + assert fun_from_overlapping_clauses([{[integer()], atom()}, {[float()], binary()}]) + |> equal?( + fun_from_non_overlapping_clauses([{[integer()], atom()}, {[float()], binary()}]) + ) + + # Subsets + assert fun_from_overlapping_clauses([{[integer()], atom()}, {[number()], binary()}]) + |> equal?( + fun_from_non_overlapping_clauses([ + {[integer()], union(atom(), binary())}, + {[float()], binary()} + ]) + ) + + assert fun_from_overlapping_clauses([{[number()], binary()}, {[integer()], atom()}]) + |> equal?( + fun_from_non_overlapping_clauses([ + {[integer()], union(atom(), binary())}, + {[float()], binary()} + ]) + ) + + # Partial + assert fun_from_overlapping_clauses([ + {[union(integer(), pid())], atom()}, + {[union(float(), pid())], binary()} + ]) + |> equal?( + fun_from_non_overlapping_clauses([ + {[integer()], atom()}, + {[float()], binary()}, + {[pid()], union(atom(), binary())} + ]) + ) + + # Difference + assert fun_from_overlapping_clauses([ + {[integer(), union(pid(), atom())], atom()}, + {[number(), pid()], binary()} + ]) + |> equal?( + fun_from_non_overlapping_clauses([ + {[float(), pid()], binary()}, + {[integer(), atom()], atom()}, + {[integer(), pid()], union(atom(), binary())} + ]) + ) + end + end + describe "function application" do defp none_fun(arity), do: fun(List.duplicate(none(), arity), term()) diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 5db5dd7775..27c3918ccc 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -449,6 +449,7 @@ defmodule Module.Types.ExprTest do describe "remote capture" do test "strong" do assert typecheck!(&String.to_atom/1) == fun([binary()], atom()) + assert typecheck!(&:erlang.element/2) == fun([integer(), open_tuple([])], dynamic()) end test "unknown" do From bc93456bccdbc7e5d6460f56315613263811b9cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Wed, 28 May 2025 13:04:35 +0200 Subject: [PATCH 08/10] Add integration test --- .../elixir/module/types/integration_test.exs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/lib/elixir/test/elixir/module/types/integration_test.exs b/lib/elixir/test/elixir/module/types/integration_test.exs index 9acf41db00..97cb75bd0c 100644 --- a/lib/elixir/test/elixir/module/types/integration_test.exs +++ b/lib/elixir/test/elixir/module/types/integration_test.exs @@ -156,6 +156,36 @@ defmodule Module.Types.IntegrationTest do dynamic(open_map()) end + test "writes exports with inferred function types" do + files = %{ + "a.ex" => """ + defmodule A do + def captured, do: &to_capture/1 + defp to_capture(<<"ok">>), do: :ok + defp to_capture(<<"error">>), do: :error + defp to_capture([_ | _]), do: :list + end + """ + } + + modules = compile_modules(files) + exports = read_chunk(modules[A]).exports |> Map.new() + + return = fn name, arity -> + pair = {name, arity} + %{^pair => %{sig: {:infer, nil, [{_, return}]}}} = exports + return + end + + assert return.(:captured, 0) + |> equal?( + fun_from_non_overlapping_clauses([ + {[dynamic(binary())], atom([:ok, :error])}, + {[dynamic(non_empty_list(term(), term()))], atom([:list])} + ]) + ) + end + test "writes exports for implementations" do files = %{ "pi.ex" => """ From 22a51d45f3209ae4762952823dea06d3c98490a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Wed, 28 May 2025 15:08:18 +0200 Subject: [PATCH 09/10] All good --- lib/elixir/test/elixir/module/types/expr_test.exs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 27c3918ccc..e406e68299 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -170,12 +170,17 @@ defmodule Module.Types.ExprTest do end test "bad argument" do - assert typeerror!([%a{}], (&String.to_integer/1).(a)) == ~l""" - expected a 2-arity function on call: + assert typeerror!([], (&String.to_integer/1).(:foo)) + |> strip_ansi() == ~l""" + incompatible types given on function application: - (&String.to_integer/1).(a1, a2) + (&String.to_integer/1).(:foo) - but got function with arity 1: + given types: + + :foo + + but function has type: (binary() -> integer()) """ From 2becbfef9a51a8aa7925e9e7588c2a5bc0ff87d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Valim?= Date: Wed, 28 May 2025 15:10:51 +0200 Subject: [PATCH 10/10] Add TODOs --- lib/elixir/lib/module/types/descr.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/elixir/lib/module/types/descr.ex b/lib/elixir/lib/module/types/descr.ex index f242f9e6fb..9fdc445c23 100644 --- a/lib/elixir/lib/module/types/descr.ex +++ b/lib/elixir/lib/module/types/descr.ex @@ -1116,6 +1116,7 @@ defmodule Module.Types.Descr do {:badarg, domain_to_flat_args(domain, arity)} not subtype?(args_domain, domain) -> + # TODO: This compatibility check is not enough if static? or not compatible?(fun, fun(arguments, term())) do {:badarg, domain_to_flat_args(domain, arity)} else @@ -1126,6 +1127,7 @@ defmodule Module.Types.Descr do {:ok, fun_apply_static(arguments, static_arrows, false)} static_arrows == [] -> + # TODO: We need to validate this within the theory {:ok, dynamic(fun_apply_static(arguments, dynamic_arrows, false))} true ->