Skip to content

Commit bcc36c5

Browse files
authored
Fuse maps and tuples for printing (#14079)
1 parent 53f6a45 commit bcc36c5

File tree

2 files changed

+219
-5
lines changed

2 files changed

+219
-5
lines changed

lib/elixir/lib/module/types/descr.ex

Lines changed: 90 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1704,6 +1704,49 @@ defmodule Module.Types.Descr do
17041704

17051705
{tag, fields, negs}
17061706
end)
1707+
|> map_fusion()
1708+
end
1709+
1710+
# Given a dnf, fuse maps when possible
1711+
# e.g. %{a: integer(), b: atom()} or %{a: float(), b: atom()} into %{a: number(), b: atom()}
1712+
defp map_fusion(dnf) do
1713+
# Steps:
1714+
# 1. Group maps by tags and keys
1715+
# 2. Try fusions for each group until no fusion is found
1716+
# 3. Merge the groups back into a dnf
1717+
dnf
1718+
|> Enum.group_by(fn {tag, fields, _} -> {tag, Map.keys(fields)} end)
1719+
|> Enum.flat_map(fn {_, maps} -> fuse_maps(maps) end)
1720+
end
1721+
1722+
defp fuse_maps(maps) do
1723+
Enum.reduce(maps, [], fn map, acc ->
1724+
case Enum.split_while(acc, &fusible_maps?(map, &1)) do
1725+
{_, []} ->
1726+
[map | acc]
1727+
1728+
{others, [match | rest]} ->
1729+
fused = fuse_map_pair(map, match)
1730+
others ++ [fused | rest]
1731+
end
1732+
end)
1733+
end
1734+
1735+
# Two maps are fusible if they have no negations and differ in at most one element.
1736+
defp fusible_maps?({_, fields1, negs1}, {_, fields2, negs2}) do
1737+
negs1 != [] or negs2 != [] or
1738+
Map.keys(fields1)
1739+
|> Enum.count(fn key -> Map.get(fields1, key) != Map.get(fields2, key) end) > 1
1740+
end
1741+
1742+
defp fuse_map_pair({tag, fields1, []}, {_, fields2, []}) do
1743+
fused_fields =
1744+
Map.new(fields1, fn {key, type1} ->
1745+
type2 = Map.get(fields2, key)
1746+
{key, if(type1 != type2, do: union(type1, type2), else: type1)}
1747+
end)
1748+
1749+
{tag, fused_fields, []}
17071750
end
17081751

17091752
# If all fields are the same except one, we can optimize map difference.
@@ -1925,24 +1968,66 @@ defmodule Module.Types.Descr do
19251968
defp tuple_to_quoted(dnf) do
19261969
dnf
19271970
|> tuple_simplify()
1971+
|> tuple_fusion()
19281972
|> Enum.map(&tuple_each_to_quoted/1)
19291973
|> case do
19301974
[] -> []
19311975
dnf -> Enum.reduce(dnf, &{:or, [], [&2, &1]}) |> List.wrap()
19321976
end
19331977
end
19341978

1935-
defp tuple_each_to_quoted({tag, positive_map, negative_maps}) do
1936-
case negative_maps do
1979+
# Given a dnf of tuples, fuses the tuple unions when possible,
1980+
# e.g. {integer(), atom()} or {float(), atom()} into {number(), atom()}
1981+
# The negations of two fused tuples are just concatenated.
1982+
defp tuple_fusion(dnf) do
1983+
# Steps:
1984+
# 1. Consider tuples without negations apart from those with
1985+
# 2. Group tuples by size and tag
1986+
# 3. Try fusions for each group until no fusion is found
1987+
# 4. Merge the groups back into a dnf
1988+
dnf
1989+
|> Enum.group_by(fn {tag, elems, _} -> {tag, length(elems)} end)
1990+
|> Enum.flat_map(fn {_, tuples} -> fuse_tuples(tuples) end)
1991+
end
1992+
1993+
defp fuse_tuples(tuples) do
1994+
Enum.reduce(tuples, [], fn tuple, acc ->
1995+
case Enum.split_while(acc, &fusible_tuples?(tuple, &1)) do
1996+
{_, []} ->
1997+
[tuple | acc]
1998+
1999+
{others, [match | rest]} ->
2000+
fused = fuse_tuple_pair(tuple, match)
2001+
others ++ [fused | rest]
2002+
end
2003+
end)
2004+
end
2005+
2006+
# Two tuples are fusible if they have no negations and differ in at most one element.
2007+
defp fusible_tuples?({_, elems1, negs1}, {_, elems2, negs2}) do
2008+
negs1 != [] or negs2 != [] or
2009+
Enum.zip(elems1, elems2) |> Enum.count(fn {a, b} -> a != b end) > 1
2010+
end
2011+
2012+
defp fuse_tuple_pair({tag, elems1, []}, {_, elems2, []}) do
2013+
fused_elements =
2014+
Enum.zip(elems1, elems2)
2015+
|> Enum.map(fn {a, b} -> if a != b, do: union(a, b), else: a end)
2016+
2017+
{tag, fused_elements, []}
2018+
end
2019+
2020+
defp tuple_each_to_quoted({tag, positive_tuple, negative_tuples}) do
2021+
case negative_tuples do
19372022
[] ->
1938-
tuple_literal_to_quoted({tag, positive_map})
2023+
tuple_literal_to_quoted({tag, positive_tuple})
19392024

19402025
_ ->
1941-
negative_maps
2026+
negative_tuples
19422027
|> Enum.map(&tuple_literal_to_quoted/1)
19432028
|> Enum.reduce(&{:or, [], [&2, &1]})
19442029
|> Kernel.then(
1945-
&{:and, [], [tuple_literal_to_quoted({tag, positive_map}), {:not, [], [&1]}]}
2030+
&{:and, [], [tuple_literal_to_quoted({tag, positive_tuple}), {:not, [], [&1]}]}
19462031
)
19472032
end
19482033
end

lib/elixir/test/elixir/module/types/descr_test.exs

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1268,6 +1268,91 @@ defmodule Module.Types.DescrTest do
12681268
# assert difference(tuple([number(), term()]), tuple([integer(), atom()]))
12691269
# |> to_quoted_string() ==
12701270
# "{float(), term()} or {number(), term() and not atom()}"
1271+
1272+
assert union(tuple([integer(), atom()]), tuple([integer(), atom()])) |> to_quoted_string() ==
1273+
"{integer(), atom()}"
1274+
1275+
assert union(tuple([integer(), atom()]), tuple([float(), atom()])) |> to_quoted_string() ==
1276+
"{float() or integer(), atom()}"
1277+
1278+
assert union(tuple([integer(), atom()]), tuple([float(), atom()]))
1279+
|> union(tuple([pid(), pid(), port()]))
1280+
|> union(tuple([pid(), pid(), atom()]))
1281+
|> to_quoted_string() ==
1282+
"{float() or integer(), atom()} or {pid(), pid(), atom() or port()}"
1283+
1284+
assert union(open_tuple([integer()]), open_tuple([float()])) |> to_quoted_string() ==
1285+
"{float() or integer(), ...}"
1286+
1287+
# {:ok, {term(), integer()}} or {:ok, {term(), float()}} or {:exit, :kill} or {:exit, :timeout}
1288+
assert tuple([atom([:ok]), tuple([term(), empty_list()])])
1289+
|> union(tuple([atom([:ok]), tuple([term(), open_map()])]))
1290+
|> union(tuple([atom([:exit]), atom([:kill])]))
1291+
|> union(tuple([atom([:exit]), atom([:timeout])]))
1292+
|> to_quoted_string() ==
1293+
"{:exit, :kill or :timeout} or {:ok, {term(), %{...} or empty_list()}}"
1294+
1295+
# Detection of duplicates
1296+
assert tuple([atom([:ok]), term()])
1297+
|> union(tuple([atom([:ok]), term()]))
1298+
|> to_quoted_string() == "{:ok, term()}"
1299+
1300+
assert tuple([closed_map(a: integer(), b: atom()), open_map()])
1301+
|> union(tuple([closed_map(a: integer(), b: atom()), open_map()]))
1302+
|> to_quoted_string() ==
1303+
"{%{a: integer(), b: atom()}, %{...}}"
1304+
1305+
# Nested fusion
1306+
assert tuple([closed_map(a: integer(), b: atom()), open_map()])
1307+
|> union(tuple([closed_map(a: float(), b: atom()), open_map()]))
1308+
|> to_quoted_string() ==
1309+
"{%{a: float() or integer(), b: atom()}, %{...}}"
1310+
1311+
# Complex simplification of map/tuple combinations. Initial type is:
1312+
# ```
1313+
# dynamic(
1314+
# :error or
1315+
# ({%Decimal{coef: :inf, exp: integer(), sign: integer()}, binary()} or
1316+
# {%Decimal{coef: :NaN, exp: integer(), sign: integer()}, binary()} or
1317+
# {%Decimal{coef: integer(), exp: integer(), sign: integer()}, term()} or
1318+
# {%Decimal{coef: :inf, exp: integer(), sign: integer()} or
1319+
# %Decimal{coef: :NaN, exp: integer(), sign: integer()} or
1320+
# %Decimal{coef: integer(), exp: integer(), sign: integer()}, term()})
1321+
# )
1322+
# ```
1323+
decimal_inf =
1324+
closed_map(
1325+
__struct__: atom([Decimal]),
1326+
coef: atom([:inf]),
1327+
exp: integer(),
1328+
sign: integer()
1329+
)
1330+
1331+
decimal_nan =
1332+
closed_map(
1333+
__struct__: atom([Decimal]),
1334+
coef: atom([:NaN]),
1335+
exp: integer(),
1336+
sign: integer()
1337+
)
1338+
1339+
decimal_int =
1340+
closed_map(__struct__: atom([Decimal]), coef: integer(), exp: integer(), sign: integer())
1341+
1342+
assert atom([:error])
1343+
|> union(
1344+
tuple([decimal_inf, binary()])
1345+
|> union(
1346+
tuple([decimal_nan, binary()])
1347+
|> union(
1348+
tuple([decimal_int, term()])
1349+
|> union(tuple([union(decimal_inf, union(decimal_nan, decimal_int)), term()]))
1350+
)
1351+
)
1352+
)
1353+
|> dynamic()
1354+
|> to_quoted_string() ==
1355+
"dynamic(\n :error or\n ({%Decimal{coef: integer() or (:NaN or :inf), exp: integer(), sign: integer()}, term()} or\n {%Decimal{coef: :NaN or :inf, exp: integer(), sign: integer()}, binary()})\n)"
12711356
end
12721357

12731358
test "map" do
@@ -1311,6 +1396,50 @@ defmodule Module.Types.DescrTest do
13111396
assert difference(open_map(a: number(), b: atom()), open_map(a: integer()))
13121397
|> to_quoted_string() == "%{..., a: float(), b: atom()}"
13131398

1399+
# Basic map fusion
1400+
assert union(closed_map(a: integer()), closed_map(a: integer())) |> to_quoted_string() ==
1401+
"%{a: integer()}"
1402+
1403+
assert union(closed_map(a: integer()), closed_map(a: float())) |> to_quoted_string() ==
1404+
"%{a: float() or integer()}"
1405+
1406+
# Nested fusion
1407+
assert union(closed_map(a: integer(), b: atom()), closed_map(a: float(), b: atom()))
1408+
|> union(closed_map(x: pid(), y: pid(), z: port()))
1409+
|> union(closed_map(x: pid(), y: pid(), z: atom()))
1410+
|> to_quoted_string() ==
1411+
"%{a: float() or integer(), b: atom()} or %{x: pid(), y: pid(), z: atom() or port()}"
1412+
1413+
# Open map fusion
1414+
assert union(open_map(a: integer()), open_map(a: float())) |> to_quoted_string() ==
1415+
"%{..., a: float() or integer()}"
1416+
1417+
# Fusing complex nested maps with unions
1418+
assert closed_map(status: atom([:ok]), data: closed_map(value: term(), count: empty_list()))
1419+
|> union(
1420+
closed_map(status: atom([:ok]), data: closed_map(value: term(), count: open_map()))
1421+
)
1422+
|> union(closed_map(status: atom([:error]), reason: atom([:timeout])))
1423+
|> union(closed_map(status: atom([:error]), reason: atom([:crash])))
1424+
|> to_quoted_string() ==
1425+
"%{data: %{count: %{...} or empty_list(), value: term()}, status: :ok} or\n %{reason: :crash or :timeout, status: :error}"
1426+
1427+
# Difference and union tests
1428+
assert closed_map(status: atom([:ok]), value: term())
1429+
|> difference(closed_map(status: atom([:ok]), value: float()))
1430+
|> union(
1431+
closed_map(status: atom([:ok]), value: term())
1432+
|> difference(closed_map(status: atom([:ok]), value: integer()))
1433+
)
1434+
|> to_quoted_string() ==
1435+
"%{status: :ok, value: term()}"
1436+
1437+
# Nested map fusion
1438+
assert closed_map(data: closed_map(x: integer(), y: atom()), meta: open_map())
1439+
|> union(closed_map(data: closed_map(x: float(), y: atom()), meta: open_map()))
1440+
|> to_quoted_string() ==
1441+
"%{data: %{x: float() or integer(), y: atom()}, meta: %{...}}"
1442+
13141443
# Test complex combinations
13151444
assert intersection(open_map(a: number(), b: atom()), open_map(a: integer(), c: boolean()))
13161445
|> union(difference(open_map(x: atom()), open_map(x: boolean())))

0 commit comments

Comments
 (0)