Skip to content

Commit e07ccb1

Browse files
devonestesmichalmuskala
authored andcommitted
Add blame callback for KeyError (#7803)
This starts with an initial implementation of the `blame/2` callback for `KeyError` to add some helpful `did_you_mean` feedback for potentially typo'd keys. Right now it's only implemented for maps and keyword lists, and only for atom keys.
1 parent 1eae754 commit e07ccb1

File tree

2 files changed

+88
-6
lines changed

2 files changed

+88
-6
lines changed

lib/elixir/lib/exception.ex

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,18 +1091,72 @@ defmodule Protocol.UndefinedError do
10911091
end
10921092

10931093
defmodule KeyError do
1094-
defexception [:key, :term]
1094+
defexception [:key, :term, :message]
10951095

10961096
@impl true
1097-
def message(exception) do
1098-
msg = "key #{inspect(exception.key)} not found"
1097+
def message(exception = %{message: nil}), do: message(exception.key, exception.term)
1098+
def message(%{message: message}), do: message
1099+
1100+
def message(key, term) do
1101+
message = "key #{inspect(key)} not found"
1102+
1103+
if term != nil do
1104+
message <> " in: #{inspect(term)}"
1105+
else
1106+
message
1107+
end
1108+
end
1109+
1110+
@impl true
1111+
def blame(exception = %{term: nil}, stacktrace) do
1112+
message = message(exception.key, exception.term)
1113+
{%{exception | message: message}, stacktrace}
1114+
end
1115+
1116+
def blame(exception, stacktrace) do
1117+
%{term: term, key: key} = exception
1118+
message = message(key, term)
10991119

1100-
if exception.term != nil do
1101-
msg <> " in: #{inspect(exception.term)}"
1120+
if is_atom(key) and (map_with_atom_keys_only?(term) or Keyword.keyword?(term)) do
1121+
hint = did_you_mean(key, available_keys(term))
1122+
message = message <> IO.iodata_to_binary(hint)
1123+
{%{exception | message: message}, stacktrace}
11021124
else
1103-
msg
1125+
{%{exception | message: message}, stacktrace}
11041126
end
11051127
end
1128+
1129+
defp map_with_atom_keys_only?(term) do
1130+
is_map(term) and Enum.all?(term, fn {k, _} -> is_atom(k) end)
1131+
end
1132+
1133+
defp available_keys(term) when is_map(term), do: Map.keys(term)
1134+
defp available_keys(term) when is_list(term), do: Keyword.keys(term)
1135+
1136+
@threshold 0.77
1137+
@max_suggestions 5
1138+
defp did_you_mean(missing_key, available_keys) do
1139+
stringified_key = Atom.to_string(missing_key)
1140+
1141+
suggestions =
1142+
for key <- available_keys,
1143+
distance = String.jaro_distance(stringified_key, Atom.to_string(key)),
1144+
distance >= @threshold,
1145+
do: {distance, key}
1146+
1147+
case suggestions do
1148+
[] -> []
1149+
suggestions -> [". Did you mean one of:\n\n" | format_suggestions(suggestions)]
1150+
end
1151+
end
1152+
1153+
defp format_suggestions(suggestions) do
1154+
suggestions
1155+
|> Enum.sort(&(elem(&1, 0) >= elem(&2, 0)))
1156+
|> Enum.take(@max_suggestions)
1157+
|> Enum.sort(&(elem(&1, 1) <= elem(&2, 1)))
1158+
|> Enum.map(fn {_, key} -> [" * ", inspect(key), ?\n] end)
1159+
end
11061160
end
11071161

11081162
defmodule UnicodeConversionError do

lib/elixir/test/elixir/exception_test.exs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -482,6 +482,34 @@ defmodule ExceptionTest do
482482
"such as map.field or module.function, make sure the left side of the dot is an atom or a map"
483483
end
484484

485+
test "annotates key error with suggestions if keys are atoms" do
486+
message = blame_message(%{first: nil, second: nil}, fn map -> map.firts end)
487+
488+
assert message == """
489+
key :firts not found in: %{first: nil, second: nil}. Did you mean one of:
490+
491+
* :first
492+
"""
493+
494+
message = blame_message(%{"first" => nil, "second" => nil}, fn map -> map.firts end)
495+
496+
assert message == "key :firts not found in: %{\"first\" => nil, \"second\" => nil}"
497+
498+
message =
499+
blame_message(%{"first" => nil, "second" => nil}, fn map -> Map.fetch!(map, "firts") end)
500+
501+
assert message == "key \"firts\" not found in: %{\"first\" => nil, \"second\" => nil}"
502+
503+
message =
504+
blame_message([first: nil, second: nil], fn kwlist -> Keyword.fetch!(kwlist, :firts) end)
505+
506+
assert message == """
507+
key :firts not found in: [first: nil, second: nil]. Did you mean one of:
508+
509+
* :first
510+
"""
511+
end
512+
485513
defp blame_message(arg, fun) do
486514
try do
487515
fun.(arg)

0 commit comments

Comments
 (0)