From 24181bbbf0f421da82fedcf6b7a1a8ce45d313c5 Mon Sep 17 00:00:00 2001 From: sabiwara Date: Mon, 15 Jun 2026 17:30:05 +0900 Subject: [PATCH 1/7] Add String.to_existing_atom/2 --- lib/elixir/lib/string.ex | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/lib/elixir/lib/string.ex b/lib/elixir/lib/string.ex index 92073c849ad..86b347484b9 100644 --- a/lib/elixir/lib/string.ex +++ b/lib/elixir/lib/string.ex @@ -2992,6 +2992,8 @@ defmodule String do Converts a string to an existing atom or raises if the atom does not exist. + If the list of expected atoms is known upfront, prefer `to_existing_atom/2`. + The maximum atom size is of 255 Unicode code points. Raises an `ArgumentError` if the atom does not exist. @@ -3021,6 +3023,37 @@ defmodule String do :erlang.binary_to_existing_atom(string, :utf8) end + @doc """ + Converts a string to one of the `allowed_atoms` or raises. + + Raises an `ArgumentError` if the atom either does not exist or is not within + the existing list. + + This should be preferred to `to_existing_atom/1` if the list is known upfront, + since there is no risk that the atom has not been loaded. + + ## Examples + + iex> String.to_existing_atom("foo", [:foo, :bar]) + :foo + + iex> String.to_existing_atom("unknown", [:foo, :bar]) + ** (ArgumentError) unexpected atom: :unknown, the allowed values are: [:foo, :bar] + + """ + @spec to_existing_atom(String.t(), [atom]) :: atom + def to_existing_atom(string, allowed_atoms) + when is_binary(string) and is_list(allowed_atoms) do + atom = :erlang.binary_to_existing_atom(string, :utf8) + + if atom not in allowed_atoms do + raise ArgumentError, + "unexpected atom: #{inspect(atom)}, the allowed values are: #{inspect(allowed_atoms)}" + end + + atom + end + @doc """ Returns an integer whose text representation is `string`. From 8414cfb31c6af400c2daf19c677d2c88a7638ec2 Mon Sep 17 00:00:00 2001 From: sabiwara Date: Mon, 15 Jun 2026 20:31:32 +0900 Subject: [PATCH 2/7] Optimize the case of a static list --- lib/elixir/lib/string.ex | 16 +++++++++++++--- lib/elixir/src/elixir_erl_pass.erl | 21 +++++++++++++++++++++ lib/elixir/test/elixir/string_test.exs | 19 +++++++++++++++++++ lib/elixir/test/erlang/control_test.erl | 16 ++++++++++++++++ 4 files changed, 69 insertions(+), 3 deletions(-) diff --git a/lib/elixir/lib/string.ex b/lib/elixir/lib/string.ex index 86b347484b9..1bb39812f64 100644 --- a/lib/elixir/lib/string.ex +++ b/lib/elixir/lib/string.ex @@ -3038,7 +3038,7 @@ defmodule String do :foo iex> String.to_existing_atom("unknown", [:foo, :bar]) - ** (ArgumentError) unexpected atom: :unknown, the allowed values are: [:foo, :bar] + ** (ArgumentError) unexpected value: \"unknown\", the allowed atoms are: [:foo, :bar] """ @spec to_existing_atom(String.t(), [atom]) :: atom @@ -3047,13 +3047,23 @@ defmodule String do atom = :erlang.binary_to_existing_atom(string, :utf8) if atom not in allowed_atoms do - raise ArgumentError, - "unexpected atom: #{inspect(atom)}, the allowed values are: #{inspect(allowed_atoms)}" + to_existing_atom_unexpected(string, allowed_atoms) end atom end + # used just to have a less cryptic stacktrace and consistent error + @doc false + def __to_existing_atom__(string, allowed_atoms) do + to_existing_atom_unexpected(string, allowed_atoms) + end + + defp to_existing_atom_unexpected(string, allowed_atoms) do + raise ArgumentError, + "unexpected value: #{inspect(string)}, the allowed atoms are: #{inspect(allowed_atoms)}" + end + @doc """ Returns an integer whose text representation is `string`. diff --git a/lib/elixir/src/elixir_erl_pass.erl b/lib/elixir/src/elixir_erl_pass.erl index 71744113101..3674bd2a763 100644 --- a/lib/elixir/src/elixir_erl_pass.erl +++ b/lib/elixir/src/elixir_erl_pass.erl @@ -634,6 +634,27 @@ translate_remote(maps, merge, Meta, [Map1, Map2], S) -> {[TMap1, TMap2], TS} -> {{call, Ann, {remote, Ann, {atom, Ann, maps}, {atom, Ann, merge}}, [TMap1, TMap2]}, TS} end; +translate_remote('Elixir.String', to_existing_atom, Meta, [String, List], S) -> + Ann = ?ann(Meta), + {[TString, TList], TS} = translate_args([String, List], Ann, S), + + case is_list(List) andalso lists:all(fun is_atom/1, List) of + true -> + Generated = erl_anno:set_generated(true, Ann), + LastClause = {clause, Generated, + [{var, Generated, '_'}], + [], + [{call, Ann, {remote, Ann, {atom, Ann, 'Elixir.String'}, {atom, Ann, '__to_existing_atom__'}}, [TString, TList]}]}, + Clauses = [ + {clause, Generated, + [{bin, Generated, [{bin_element, Generated, {string, Generated, atom_to_list(Atom)}, default, default}]}], + [], + [{atom, Ann, Atom}]} + || Atom <- List] ++ [LastClause], + {{'case', Generated, TString, Clauses}, TS}; + false -> + {{call, Ann, {remote, Ann, {atom, Ann, 'Elixir.String'}, {atom, Ann, to_existing_atom}}, [TString, TList]}, TS} + end; translate_remote(Left, Right, Meta, Args, S) -> Ann = ?ann(Meta), diff --git a/lib/elixir/test/elixir/string_test.exs b/lib/elixir/test/elixir/string_test.exs index b96e9596238..40a6fe8b574 100644 --- a/lib/elixir/test/elixir/string_test.exs +++ b/lib/elixir/test/elixir/string_test.exs @@ -1099,4 +1099,23 @@ defmodule StringTest do assert String.bag_distance("\r\t\xFF\v", "\xFF\r\n\xFF") == 0.25 assert String.split("\r\t\v", "") == ["", "\r", "\t", "\v", ""] end + + test "to_existing_atom/2" do + # constant + assert String.to_existing_atom("foo", [:foo, :bar]) == :foo + assert String.to_existing_atom("bar", [:foo, :bar]) == :bar + + assert_raise ArgumentError, fn -> + String.to_existing_atom("baz", [:foo, :bar]) + end + + # variable + values = [:foo, :bar] + assert String.to_existing_atom("foo", values) == :foo + assert String.to_existing_atom("bar", values) == :bar + + assert_raise ArgumentError, fn -> + String.to_existing_atom("baz", values) + end + end end diff --git a/lib/elixir/test/erlang/control_test.erl b/lib/elixir/test/erlang/control_test.erl index 5648fb84d4a..597d6a933f8 100644 --- a/lib/elixir/test/erlang/control_test.erl +++ b/lib/elixir/test/erlang/control_test.erl @@ -86,6 +86,22 @@ optimized_inspect_interpolation_test() -> {call, _, {remote, _,{atom, _, 'Elixir.Kernel'}, {atom, _, inspect}}, [_]}, default, [binary]}]} = to_erl("\"#{inspect(1)}\""). +optimized_string_to_existing_atom_test() -> + {'case', _, _, + [{clause, _, + [{bin, _, [{bin_element, _, {string, _, "foo"}, default, default}]}], + [], + [{atom, _, foo}]}, + {clause, _, + [{bin, _, [{bin_element, _, {string, _, "bar"}, default, default}]}], + [], + [{atom, _, bar}]}, + {clause, _, + [{var, _, '_'}], + [], + [{call, _, {remote, _, {atom, _, 'Elixir.String'}, {atom, _, '__to_existing_atom__'}}, [_, _]}]}] + } = to_erl("String.to_existing_atom(\"baz\", [:foo, :bar])"). + optimized_map_merge_test() -> {map, _, [{map_field_assoc, _, {atom, _, a}, {integer, _, 1}}, From b551c17eb6d64863c592adaf9d2a7ca26afd2c4b Mon Sep 17 00:00:00 2001 From: sabiwara Date: Mon, 15 Jun 2026 20:36:38 +0900 Subject: [PATCH 3/7] Type-inference for static list --- lib/elixir/lib/module/types/apply.ex | 19 +++++++++++++++- .../test/elixir/module/types/expr_test.exs | 22 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index 3faa67410b3..d0049abd93a 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -288,7 +288,8 @@ defmodule Module.Types.Apply do [{[term(), open_map()], tuple([term(), open_map()]) |> opt_union(atom([:error]))}]}, {:maps, :to_list, [{[open_map()], list(tuple([term(), term()]))}]}, {:maps, :update, [{[term(), term(), open_map()], open_map()}]}, - {:maps, :values, [{[open_map()], list(term())}]} + {:maps, :values, [{[open_map()], list(term())}]}, + {String, :to_existing_atom, [{[binary(), list(atom())], atom()}]} ] do [arity] = Enum.map(clauses, fn {args, _return} -> length(args) end) |> Enum.uniq() @@ -710,6 +711,22 @@ defmodule Module.Types.Apply do end end + defp do_remote(String, :to_existing_atom, [string, atoms], _, expr, stack, context, of_fun) do + {string_type, context} = of_fun.(string, binary(), expr, stack, context) + {atoms_type, context} = of_fun.(atoms, list(atom()), expr, stack, context) + + atom_type = + with {_, atom_type} when atom_type != nil <- list_of(atoms_type), + true <- subtype?(atom_type, atom()) do + atom_type + else + _ -> + atom() + end + + {return(atom_type, [string_type, atoms_type], stack), context} + end + defp do_remote(mod, fun, args, expected, expr, stack, context, _of_fun) do remote_domain(mod, fun, args, expected, elem(expr, 1), stack, context) end diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 5963b6b2364..94cc19bf3c5 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -1898,6 +1898,28 @@ defmodule Module.Types.ExprTest do x in [:foo, :bar] """ end + + test "String.to_existing_atom/2" do + assert typecheck!( + [x], + String.to_existing_atom(x, [:foo, :bar]) + ) == atom([:foo, :bar]) + + assert typecheck!( + [x], + ( + values = [:foo, :bar] + String.to_existing_atom(x, values) + ) + ) == atom([:foo, :bar]) + + assert typecheck!( + [x, values], + String.to_existing_atom(x, values) + ) == dynamic(atom()) + + # TODO: check for errors when 1. not a list 2. not atoms + end end describe "case" do From c3094ca62adf1c550cca429f247fc31b800b29d3 Mon Sep 17 00:00:00 2001 From: sabiwara Date: Tue, 16 Jun 2026 13:55:08 +0900 Subject: [PATCH 4/7] Change type to non-empty lists --- lib/elixir/lib/module/types/apply.ex | 2 +- lib/elixir/lib/string.ex | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index d0049abd93a..48a18e62994 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -289,7 +289,7 @@ defmodule Module.Types.Apply do {:maps, :to_list, [{[open_map()], list(tuple([term(), term()]))}]}, {:maps, :update, [{[term(), term(), open_map()], open_map()}]}, {:maps, :values, [{[open_map()], list(term())}]}, - {String, :to_existing_atom, [{[binary(), list(atom())], atom()}]} + {String, :to_existing_atom, [{[binary(), non_empty_list(atom())], atom()}]} ] do [arity] = Enum.map(clauses, fn {args, _return} -> length(args) end) |> Enum.uniq() diff --git a/lib/elixir/lib/string.ex b/lib/elixir/lib/string.ex index 1bb39812f64..d6cd7baa109 100644 --- a/lib/elixir/lib/string.ex +++ b/lib/elixir/lib/string.ex @@ -3041,9 +3041,8 @@ defmodule String do ** (ArgumentError) unexpected value: \"unknown\", the allowed atoms are: [:foo, :bar] """ - @spec to_existing_atom(String.t(), [atom]) :: atom - def to_existing_atom(string, allowed_atoms) - when is_binary(string) and is_list(allowed_atoms) do + @spec to_existing_atom(String.t(), nonempty_list(atom)) :: atom + def to_existing_atom(string, [_ | _] = allowed_atoms) when is_binary(string) do atom = :erlang.binary_to_existing_atom(string, :utf8) if atom not in allowed_atoms do From 4456ce413005f77693bd213fb570b9a9b4f695c4 Mon Sep 17 00:00:00 2001 From: sabiwara Date: Tue, 16 Jun 2026 13:55:19 +0900 Subject: [PATCH 5/7] Refine using remote_apply --- lib/elixir/lib/module/types/apply.ex | 26 +++++++------------ .../test/elixir/module/types/expr_test.exs | 15 ++++++++++- 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index 48a18e62994..cf9c6f8223f 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -711,22 +711,6 @@ defmodule Module.Types.Apply do end end - defp do_remote(String, :to_existing_atom, [string, atoms], _, expr, stack, context, of_fun) do - {string_type, context} = of_fun.(string, binary(), expr, stack, context) - {atoms_type, context} = of_fun.(atoms, list(atom()), expr, stack, context) - - atom_type = - with {_, atom_type} when atom_type != nil <- list_of(atoms_type), - true <- subtype?(atom_type, atom()) do - atom_type - else - _ -> - atom() - end - - {return(atom_type, [string_type, atoms_type], stack), context} - end - defp do_remote(mod, fun, args, expected, expr, stack, context, _of_fun) do remote_domain(mod, fun, args, expected, elem(expr, 1), stack, context) end @@ -1435,6 +1419,16 @@ defmodule Module.Types.Apply do end end + defp remote_apply(String, :to_existing_atom, info, [_string, list] = args_types, stack) do + pre_refined = remote_apply(info, args_types, stack) + + with {:ok, _} <- pre_refined, {false, refined_atom} = list_of(list) do + {:ok, refined_atom} + else + _ -> pre_refined + end + end + defp remote_apply(_mod, _fun, info, args_types, stack) do remote_apply(info, args_types, stack) end diff --git a/lib/elixir/test/elixir/module/types/expr_test.exs b/lib/elixir/test/elixir/module/types/expr_test.exs index 94cc19bf3c5..e37befa9d8e 100644 --- a/lib/elixir/test/elixir/module/types/expr_test.exs +++ b/lib/elixir/test/elixir/module/types/expr_test.exs @@ -1918,7 +1918,20 @@ defmodule Module.Types.ExprTest do String.to_existing_atom(x, values) ) == dynamic(atom()) - # TODO: check for errors when 1. not a list 2. not atoms + assert typeerror!( + [x], + String.to_existing_atom(:not_a_string, x) + ) =~ "incompatible types given to String.to_existing_atom/2" + + assert typeerror!( + [x], + String.to_existing_atom(x, [:foo, "not atoms"]) + ) =~ "incompatible types given to String.to_existing_atom/2" + + assert typeerror!( + [x], + String.to_existing_atom(x, []) + ) =~ "incompatible types given to String.to_existing_atom/2" end end From 58099cf813df7127ec5af34480a14bc2848897e1 Mon Sep 17 00:00:00 2001 From: Jean Klingler Date: Tue, 16 Jun 2026 17:33:05 +0900 Subject: [PATCH 6/7] Apply suggestion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: José Valim --- lib/elixir/lib/module/types/apply.ex | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index cf9c6f8223f..33d5ae90ce1 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -1420,12 +1420,13 @@ defmodule Module.Types.Apply do end defp remote_apply(String, :to_existing_atom, info, [_string, list] = args_types, stack) do - pre_refined = remote_apply(info, args_types, stack) + case remote_apply(info, args_types, stack) do + {:ok, _} -> + {false, refined_atom} = list_of(list) + {:ok, refined_atom} - with {:ok, _} <- pre_refined, {false, refined_atom} = list_of(list) do - {:ok, refined_atom} - else - _ -> pre_refined + other -> + other end end From 1d9167e7b5f35ce3a49067512215f754f4dc75cf Mon Sep 17 00:00:00 2001 From: Jean Klingler Date: Tue, 16 Jun 2026 17:34:55 +0900 Subject: [PATCH 7/7] Add comment --- lib/elixir/lib/module/types/apply.ex | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/elixir/lib/module/types/apply.ex b/lib/elixir/lib/module/types/apply.ex index 33d5ae90ce1..cb28ae89916 100644 --- a/lib/elixir/lib/module/types/apply.ex +++ b/lib/elixir/lib/module/types/apply.ex @@ -1420,6 +1420,9 @@ defmodule Module.Types.Apply do end defp remote_apply(String, :to_existing_atom, info, [_string, list] = args_types, stack) do + # TODO remove once we add parametric types, this will just be: + # binary(), non_empty_list(a) -> a when a: atom() + case remote_apply(info, args_types, stack) do {:ok, _} -> {false, refined_atom} = list_of(list)