Skip to content

Commit fd1109e

Browse files
authored
Add String.to_existing_atom/2 to validate against a list of allowed atoms (#15483)
1 parent 1d59997 commit fd1109e

6 files changed

Lines changed: 149 additions & 1 deletion

File tree

lib/elixir/lib/module/types/apply.ex

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,8 @@ defmodule Module.Types.Apply do
288288
[{[term(), open_map()], tuple([term(), open_map()]) |> opt_union(atom([:error]))}]},
289289
{:maps, :to_list, [{[open_map()], list(tuple([term(), term()]))}]},
290290
{:maps, :update, [{[term(), term(), open_map()], open_map()}]},
291-
{:maps, :values, [{[open_map()], list(term())}]}
291+
{:maps, :values, [{[open_map()], list(term())}]},
292+
{String, :to_existing_atom, [{[binary(), non_empty_list(atom())], atom()}]}
292293
] do
293294
[arity] = Enum.map(clauses, fn {args, _return} -> length(args) end) |> Enum.uniq()
294295

@@ -1418,6 +1419,20 @@ defmodule Module.Types.Apply do
14181419
end
14191420
end
14201421

1422+
defp remote_apply(String, :to_existing_atom, info, [_string, list] = args_types, stack) do
1423+
# TODO remove once we add parametric types, this will just be:
1424+
# binary(), non_empty_list(a) -> a when a: atom()
1425+
1426+
case remote_apply(info, args_types, stack) do
1427+
{:ok, _} ->
1428+
{false, refined_atom} = list_of(list)
1429+
{:ok, refined_atom}
1430+
1431+
other ->
1432+
other
1433+
end
1434+
end
1435+
14211436
defp remote_apply(_mod, _fun, info, args_types, stack) do
14221437
remote_apply(info, args_types, stack)
14231438
end

lib/elixir/lib/string.ex

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2992,6 +2992,8 @@ defmodule String do
29922992
Converts a string to an existing atom or raises if
29932993
the atom does not exist.
29942994
2995+
If the list of expected atoms is known upfront, prefer `to_existing_atom/2`.
2996+
29952997
The maximum atom size is of 255 Unicode code points.
29962998
Raises an `ArgumentError` if the atom does not exist.
29972999
@@ -3021,6 +3023,46 @@ defmodule String do
30213023
:erlang.binary_to_existing_atom(string, :utf8)
30223024
end
30233025

3026+
@doc """
3027+
Converts a string to one of the `allowed_atoms` or raises.
3028+
3029+
Raises an `ArgumentError` if the atom either does not exist or is not within
3030+
the existing list.
3031+
3032+
This should be preferred to `to_existing_atom/1` if the list is known upfront,
3033+
since there is no risk that the atom has not been loaded.
3034+
3035+
## Examples
3036+
3037+
iex> String.to_existing_atom("foo", [:foo, :bar])
3038+
:foo
3039+
3040+
iex> String.to_existing_atom("unknown", [:foo, :bar])
3041+
** (ArgumentError) unexpected value: \"unknown\", the allowed atoms are: [:foo, :bar]
3042+
3043+
"""
3044+
@spec to_existing_atom(String.t(), nonempty_list(atom)) :: atom
3045+
def to_existing_atom(string, [_ | _] = allowed_atoms) when is_binary(string) do
3046+
atom = :erlang.binary_to_existing_atom(string, :utf8)
3047+
3048+
if atom not in allowed_atoms do
3049+
to_existing_atom_unexpected(string, allowed_atoms)
3050+
end
3051+
3052+
atom
3053+
end
3054+
3055+
# used just to have a less cryptic stacktrace and consistent error
3056+
@doc false
3057+
def __to_existing_atom__(string, allowed_atoms) do
3058+
to_existing_atom_unexpected(string, allowed_atoms)
3059+
end
3060+
3061+
defp to_existing_atom_unexpected(string, allowed_atoms) do
3062+
raise ArgumentError,
3063+
"unexpected value: #{inspect(string)}, the allowed atoms are: #{inspect(allowed_atoms)}"
3064+
end
3065+
30243066
@doc """
30253067
Returns an integer whose text representation is `string`.
30263068

lib/elixir/src/elixir_erl_pass.erl

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,27 @@ translate_remote(maps, merge, Meta, [Map1, Map2], S) ->
634634
{[TMap1, TMap2], TS} ->
635635
{{call, Ann, {remote, Ann, {atom, Ann, maps}, {atom, Ann, merge}}, [TMap1, TMap2]}, TS}
636636
end;
637+
translate_remote('Elixir.String', to_existing_atom, Meta, [String, List], S) ->
638+
Ann = ?ann(Meta),
639+
{[TString, TList], TS} = translate_args([String, List], Ann, S),
640+
641+
case is_list(List) andalso lists:all(fun is_atom/1, List) of
642+
true ->
643+
Generated = erl_anno:set_generated(true, Ann),
644+
LastClause = {clause, Generated,
645+
[{var, Generated, '_'}],
646+
[],
647+
[{call, Ann, {remote, Ann, {atom, Ann, 'Elixir.String'}, {atom, Ann, '__to_existing_atom__'}}, [TString, TList]}]},
648+
Clauses = [
649+
{clause, Generated,
650+
[{bin, Generated, [{bin_element, Generated, {string, Generated, atom_to_list(Atom)}, default, default}]}],
651+
[],
652+
[{atom, Ann, Atom}]}
653+
|| Atom <- List] ++ [LastClause],
654+
{{'case', Generated, TString, Clauses}, TS};
655+
false ->
656+
{{call, Ann, {remote, Ann, {atom, Ann, 'Elixir.String'}, {atom, Ann, to_existing_atom}}, [TString, TList]}, TS}
657+
end;
637658
translate_remote(Left, Right, Meta, Args, S) ->
638659
Ann = ?ann(Meta),
639660

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

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1898,6 +1898,41 @@ defmodule Module.Types.ExprTest do
18981898
x in [:foo, :bar]
18991899
"""
19001900
end
1901+
1902+
test "String.to_existing_atom/2" do
1903+
assert typecheck!(
1904+
[x],
1905+
String.to_existing_atom(x, [:foo, :bar])
1906+
) == atom([:foo, :bar])
1907+
1908+
assert typecheck!(
1909+
[x],
1910+
(
1911+
values = [:foo, :bar]
1912+
String.to_existing_atom(x, values)
1913+
)
1914+
) == atom([:foo, :bar])
1915+
1916+
assert typecheck!(
1917+
[x, values],
1918+
String.to_existing_atom(x, values)
1919+
) == dynamic(atom())
1920+
1921+
assert typeerror!(
1922+
[x],
1923+
String.to_existing_atom(:not_a_string, x)
1924+
) =~ "incompatible types given to String.to_existing_atom/2"
1925+
1926+
assert typeerror!(
1927+
[x],
1928+
String.to_existing_atom(x, [:foo, "not atoms"])
1929+
) =~ "incompatible types given to String.to_existing_atom/2"
1930+
1931+
assert typeerror!(
1932+
[x],
1933+
String.to_existing_atom(x, [])
1934+
) =~ "incompatible types given to String.to_existing_atom/2"
1935+
end
19011936
end
19021937

19031938
describe "case" do

lib/elixir/test/elixir/string_test.exs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,4 +1099,23 @@ defmodule StringTest do
10991099
assert String.bag_distance("\r\t\xFF\v", "\xFF\r\n\xFF") == 0.25
11001100
assert String.split("\r\t\v", "") == ["", "\r", "\t", "\v", ""]
11011101
end
1102+
1103+
test "to_existing_atom/2" do
1104+
# constant
1105+
assert String.to_existing_atom("foo", [:foo, :bar]) == :foo
1106+
assert String.to_existing_atom("bar", [:foo, :bar]) == :bar
1107+
1108+
assert_raise ArgumentError, fn ->
1109+
String.to_existing_atom("baz", [:foo, :bar])
1110+
end
1111+
1112+
# variable
1113+
values = [:foo, :bar]
1114+
assert String.to_existing_atom("foo", values) == :foo
1115+
assert String.to_existing_atom("bar", values) == :bar
1116+
1117+
assert_raise ArgumentError, fn ->
1118+
String.to_existing_atom("baz", values)
1119+
end
1120+
end
11021121
end

lib/elixir/test/erlang/control_test.erl

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,22 @@ optimized_inspect_interpolation_test() ->
8686
{call, _, {remote, _,{atom, _, 'Elixir.Kernel'}, {atom, _, inspect}}, [_]},
8787
default, [binary]}]} = to_erl("\"#{inspect(1)}\"").
8888

89+
optimized_string_to_existing_atom_test() ->
90+
{'case', _, _,
91+
[{clause, _,
92+
[{bin, _, [{bin_element, _, {string, _, "foo"}, default, default}]}],
93+
[],
94+
[{atom, _, foo}]},
95+
{clause, _,
96+
[{bin, _, [{bin_element, _, {string, _, "bar"}, default, default}]}],
97+
[],
98+
[{atom, _, bar}]},
99+
{clause, _,
100+
[{var, _, '_'}],
101+
[],
102+
[{call, _, {remote, _, {atom, _, 'Elixir.String'}, {atom, _, '__to_existing_atom__'}}, [_, _]}]}]
103+
} = to_erl("String.to_existing_atom(\"baz\", [:foo, :bar])").
104+
89105
optimized_map_merge_test() ->
90106
{map, _,
91107
[{map_field_assoc, _, {atom, _, a}, {integer, _, 1}},

0 commit comments

Comments
 (0)