From 18cef64956682d95b0163433dd25f18861e8edda Mon Sep 17 00:00:00 2001 From: sabiwara Date: Sat, 23 Aug 2025 08:42:12 +0900 Subject: [PATCH 1/2] Expand-compile regexes using OTP 28.1 :re.import --- lib/elixir/lib/kernel.ex | 18 ++++++++++++++---- lib/elixir/src/elixir_map.erl | 24 +++++++++++++++++++++++- lib/elixir/src/elixir_quote.erl | 12 +++++++++--- lib/elixir/test/elixir/macro_test.exs | 17 ++++++++++++++++- 4 files changed, 62 insertions(+), 9 deletions(-) diff --git a/lib/elixir/lib/kernel.ex b/lib/elixir/lib/kernel.ex index 7c8e8545494..096408b177f 100644 --- a/lib/elixir/lib/kernel.ex +++ b/lib/elixir/lib/kernel.ex @@ -6647,16 +6647,26 @@ defmodule Kernel do end defp compile_regex(binary_or_tuple, options) do - # TODO: Remove this when we require Erlang/OTP 28+ - case is_binary(binary_or_tuple) and :erlang.system_info(:otp_release) < [?2, ?8] do + bin_opts = :binary.list_to_bin(options) + + # TODO: Remove this when we require Erlang/OTP 28.1+ + case is_binary(binary_or_tuple) and compile_time_regexes_supported?() do true -> - Macro.escape(Regex.compile!(binary_or_tuple, :binary.list_to_bin(options))) + Macro.escape(Regex.compile!(binary_or_tuple, bin_opts)) false -> - quote(do: Regex.compile!(unquote(binary_or_tuple), unquote(:binary.list_to_bin(options)))) + quote(do: Regex.compile!(unquote(binary_or_tuple), unquote(bin_opts))) end end + defp compile_time_regexes_supported? do + # OTP 28.0 introduced refs in patterns, which can't be used in AST anymore + # OTP 28.1 introduced :re.import/1 which allows us to fix this in Macro.escape + :erlang.system_info(:otp_release) < [?2, ?8] or + (Code.ensure_loaded?(:re) and + function_exported?(:re, :import, 1)) + end + @doc ~S""" Handles the sigil `~D` for dates. diff --git a/lib/elixir/src/elixir_map.erl b/lib/elixir/src/elixir_map.erl index c9d9e3e1db4..9b324552a4c 100644 --- a/lib/elixir/src/elixir_map.erl +++ b/lib/elixir/src/elixir_map.erl @@ -16,7 +16,29 @@ expand_map(Meta, [{'|', _, [_, _]}] = Args, _S, #{context := Context, file := Fi expand_map(Meta, Args, S, E) -> {EArgs, SE, EE} = elixir_expand:expand_args(Args, S, E), validate_kv(Meta, EArgs, Args, E), - {{'%{}', Meta, EArgs}, SE, EE}. + PArgs = post_process_map_args(EArgs, E), + {{'%{}', Meta, PArgs}, SE, EE}. + +post_process_map_args(Args, #{context := nil}) -> + case lists:keyfind('__struct__', 1, Args) of + {'__struct__', 'Elixir.Regex'} -> + case lists:sort(Args) of + [ + {'__struct__', 'Elixir.Regex'}, + {opts, Opts}, + {re_pattern, '__expand_compile__'}, + {source, Source} + ] when is_binary(Source), is_list(Opts) -> + {ok, Exported} = re:compile(Source, [export] ++ Opts), + PatternAst = {{'.', [], [re, import]}, [], [elixir_quote:escape(Exported, none, false)]}, + lists:keyreplace(re_pattern, 1, Args, {re_pattern, PatternAst}); + _ -> + Args + end; + _ -> Args + end; + +post_process_map_args(Args, _) -> Args. expand_struct(Meta, Left, {'%{}', MapMeta, MapArgs}, S, #{context := Context} = E) -> CleanMapArgs = delete_struct_key(Meta, MapArgs, E), diff --git a/lib/elixir/src/elixir_quote.erl b/lib/elixir/src/elixir_quote.erl index 4260985f28b..8463cbf3bda 100644 --- a/lib/elixir/src/elixir_quote.erl +++ b/lib/elixir/src/elixir_quote.erl @@ -211,9 +211,15 @@ escape_map_key_value(K, V, Map, Q) -> end, if is_reference(MaybeRef) -> - argument_error(<<('Elixir.Kernel':inspect(Map, []))/binary, " contains a reference (", - ('Elixir.Kernel':inspect(MaybeRef, []))/binary, ") and therefore it cannot be escaped ", - "(it must be defined within a function instead). ", (bad_escape_hint())/binary>>); + case Map of + % we could make expose this mechanism in the future, e.g. by calling sth like StructModule.__info__(:hide_struct_refs) + #{'__struct__' := 'Elixir.Regex'} -> + {do_quote(K, Q), '__expand_compile__'}; + _ -> + argument_error(<<('Elixir.Kernel':inspect(Map, []))/binary, " contains a reference (", + ('Elixir.Kernel':inspect(MaybeRef, []))/binary, ") and therefore it cannot be escaped ", + "(it must be defined within a function instead). ", (bad_escape_hint())/binary>>) + end; true -> {do_quote(K, Q), do_quote(V, Q)} end. diff --git a/lib/elixir/test/elixir/macro_test.exs b/lib/elixir/test/elixir/macro_test.exs index 3c8944defde..94c802b3922 100644 --- a/lib/elixir/test/elixir/macro_test.exs +++ b/lib/elixir/test/elixir/macro_test.exs @@ -141,11 +141,26 @@ defmodule MacroTest do assert Macro.escape({:quote, [], [[do: :foo]]}) == {:{}, [], [:quote, [], [[do: :foo]]]} end - test "inspects container when a reference cannot be escaped" do + @tag skip: System.otp_release() < "28" or function_exported?(:re, :import, 1) + test "escape container when a reference cannot be escaped" do assert_raise ArgumentError, ~r"~r/foo/ contains a reference", fn -> Macro.escape(%{~r/foo/ | re_pattern: {:re_pattern, 0, 0, 0, make_ref()}}) end end + + @tag skip: not function_exported?(:re, :import, 1) + test "escape regex will remove references and replace it by a call to :re.import/1" do + assert { + :%{}, + [], + [ + __struct__: Regex, + opts: [], + re_pattern: :__expand_compile__, + source: "foo" + ] + } = Macro.escape(%{~r/foo/ | re_pattern: {:re_pattern, 0, 0, 0, make_ref()}}) + end end describe "expand_once/2" do From 5db9e24c9ba420926093ac32ea506018eb679c20 Mon Sep 17 00:00:00 2001 From: sabiwara Date: Sat, 23 Aug 2025 09:44:13 +0900 Subject: [PATCH 2/2] Look ma no cheating --- lib/elixir/lib/regex.ex | 7 ++++ lib/elixir/src/elixir_map.erl | 61 ++++++++++++++++++++++----------- lib/elixir/src/elixir_quote.erl | 13 ++++--- 3 files changed, 57 insertions(+), 24 deletions(-) diff --git a/lib/elixir/lib/regex.ex b/lib/elixir/lib/regex.ex index 8ca5a226d34..1d6c2abf939 100644 --- a/lib/elixir/lib/regex.ex +++ b/lib/elixir/lib/regex.ex @@ -1000,4 +1000,11 @@ defmodule Regex do defp translate_options(<<>>, acc), do: acc defp translate_options(t, _acc), do: {:error, t} + + if Code.ensure_loaded?(:re) and function_exported?(:re, :import, 1) do + def __expand_compile__(%Regex{source: source, opts: opts}, :re_pattern) do + {:ok, exported} = :re.compile(source, [:export] ++ opts) + quote do: :re.import(unquote(Macro.escape(exported))) + end + end end diff --git a/lib/elixir/src/elixir_map.erl b/lib/elixir/src/elixir_map.erl index 9b324552a4c..704833eadbb 100644 --- a/lib/elixir/src/elixir_map.erl +++ b/lib/elixir/src/elixir_map.erl @@ -19,26 +19,39 @@ expand_map(Meta, Args, S, E) -> PArgs = post_process_map_args(EArgs, E), {{'%{}', Meta, PArgs}, SE, EE}. -post_process_map_args(Args, #{context := nil}) -> - case lists:keyfind('__struct__', 1, Args) of - {'__struct__', 'Elixir.Regex'} -> - case lists:sort(Args) of - [ - {'__struct__', 'Elixir.Regex'}, - {opts, Opts}, - {re_pattern, '__expand_compile__'}, - {source, Source} - ] when is_binary(Source), is_list(Opts) -> - {ok, Exported} = re:compile(Source, [export] ++ Opts), - PatternAst = {{'.', [], [re, import]}, [], [elixir_quote:escape(Exported, none, false)]}, - lists:keyreplace(re_pattern, 1, Args, {re_pattern, PatternAst}); - _ -> - Args - end; - _ -> Args - end; - -post_process_map_args(Args, _) -> Args. +post_process_map_args(Args, E) -> + % TODO rewrite with maybe once we require OTP 2X+ + case lists:keyfind('__expand_compile__', 2, Args) of + {_Key, '__expand_compile__'} -> + case lists:keyfind('__struct__', 1, Args) of + {'__struct__', Module} -> + case code:ensure_loaded(Module) == {module, Module} andalso erlang:function_exported(Module, '__expand_compile__', 2) andalso is_literal(Args) of + true -> + case E of + #{context := nil} -> + Map = eval_literal({'%{}', [], Args}), + lists:map(fun + ({Key, '__expand_compile__'}) -> {Key, Module:'__expand_compile__'(Map, Key)}; + ({Key, Value}) -> {Key, Value} + end, Args); + #{context := match} -> + % replace it by wildcards in matches + lists:map(fun + ({Key, '__expand_compile__'}) -> {Key, {'_,', [], nil}}; + ({Key, Value}) -> {Key, Value} + end, Args); + _ -> + Args + end; + + false -> + Args + end; + _ -> Args + end; + false -> + Args + end. expand_struct(Meta, Left, {'%{}', MapMeta, MapArgs}, S, #{context := Context} = E) -> CleanMapArgs = delete_struct_key(Meta, MapArgs, E), @@ -125,11 +138,19 @@ validate_not_repeated(Meta, Key, Used, E) -> Used end. +is_literal({'{}', _, List}) when is_list(List) -> lists:all(fun is_literal/1, List); +is_literal({'%{}', _, List}) when is_list(List) -> lists:all(fun is_literal/1, List); is_literal({_, _, _}) -> false; is_literal({Left, Right}) -> is_literal(Left) andalso is_literal(Right); is_literal([_ | _] = List) -> lists:all(fun is_literal/1, List); is_literal(_) -> true. +eval_literal({'{}', _, List}) when is_list(List) -> list_to_tuple(eval_literal(List)); +eval_literal({'%{}', _, List}) when is_list(List) -> maps:from_list(eval_literal(List)); +eval_literal({Left, Right}) -> {eval_literal(Left), eval_literal(Right)}; +eval_literal([_ | _] = List) -> lists:map(fun eval_literal/1, List); +eval_literal(Other) when is_binary(Other); is_atom(Other); is_number(Other); is_pid(Other); Other == [] -> Other. + validate_kv(Meta, KV, Original, #{context := Context} = E) -> lists:foldl(fun ({K, _V}, {Index, Used}) -> diff --git a/lib/elixir/src/elixir_quote.erl b/lib/elixir/src/elixir_quote.erl index 8463cbf3bda..9cb2c5dbe3a 100644 --- a/lib/elixir/src/elixir_quote.erl +++ b/lib/elixir/src/elixir_quote.erl @@ -211,11 +211,16 @@ escape_map_key_value(K, V, Map, Q) -> end, if is_reference(MaybeRef) -> - case Map of - % we could make expose this mechanism in the future, e.g. by calling sth like StructModule.__info__(:hide_struct_refs) - #{'__struct__' := 'Elixir.Regex'} -> - {do_quote(K, Q), '__expand_compile__'}; + ExpandCompile = case Map of + #{'__struct__' := Module} -> + code:ensure_loaded(Module) == {module, Module} andalso erlang:function_exported(Module, '__expand_compile__', 2); _ -> + false + end, + case ExpandCompile of + true -> + {do_quote(K, Q), '__expand_compile__'}; + false -> argument_error(<<('Elixir.Kernel':inspect(Map, []))/binary, " contains a reference (", ('Elixir.Kernel':inspect(MaybeRef, []))/binary, ") and therefore it cannot be escaped ", "(it must be defined within a function instead). ", (bad_escape_hint())/binary>>)