From 10be0e6842be73056069f16c91a934a0e9094135 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonatan=20K=C5=82osko?= Date: Thu, 12 Mar 2026 23:49:07 +0100 Subject: [PATCH] Add :dbg_callback option to Code eval functions --- lib/elixir/lib/code.ex | 84 ++++++++++++++++++---------- lib/elixir/lib/kernel.ex | 10 +++- lib/elixir/src/elixir.erl | 52 +++++++++-------- lib/elixir/test/elixir/code_test.exs | 50 +++++++++++++++++ 4 files changed, 142 insertions(+), 54 deletions(-) diff --git a/lib/elixir/lib/code.ex b/lib/elixir/lib/code.ex index d3f12c9df70..0dc8a3ac9a3 100644 --- a/lib/elixir/lib/code.ex +++ b/lib/elixir/lib/code.ex @@ -291,14 +291,20 @@ defmodule Code do ] @typedoc """ - Options for environment evaluation functions like eval_string/3 and eval_quoted/3. + Options for evaluation environment, accepted by `env_for_eval/1`. """ - @type env_eval_opts :: [ - file: binary(), - line: pos_integer(), - module: module(), - prune_binding: boolean() - ] + @type env_eval_opt :: + {:file, binary()} + | {:line, pos_integer()} + | {:module, module()} + + @typedoc """ + Options for evaluation functions like `eval_string/3`, `eval_quoted/3` + and `eval_quoted_with_env/4`. + """ + @type eval_opt :: + {:prune_binding, boolean()} + | {:dbg_callback, {module(), atom(), list()}} @boolean_compiler_options [ :docs, @@ -573,9 +579,11 @@ defmodule Code do ## Options - It accepts the same options as `env_for_eval/1`. Additionally, you may - also pass an environment as second argument, so the evaluation happens - within that environment. + It accepts the same options as both `env_for_eval/1` and + `eval_quoted_with_env/4`. Additionally, you may also pass an environment + as third argument, so the evaluation happens within that environment. + + ## Return Returns a tuple of the form `{value, binding}`, where `value` is the value returned from evaluating `string`. If an error occurs while evaluating @@ -605,7 +613,7 @@ defmodule Code do iex> Enum.sort(binding) [a: 3, b: 2] - For convenience, you can pass `__ENV__/0` as the `opts` argument and + For convenience, you can pass `__ENV__/0` as the `opts_or_env` argument and all imports, requires and aliases defined in the current environment will be automatically carried over: @@ -617,15 +625,16 @@ defmodule Code do [a: 1, b: 2] """ - @spec eval_string(List.Chars.t(), binding, Macro.Env.t() | env_eval_opts) :: {term, binding} - def eval_string(string, binding \\ [], opts \\ []) + @spec eval_string(List.Chars.t(), binding, Macro.Env.t() | [eval_opt | env_eval_opt]) :: + {term, binding} + def eval_string(string, binding \\ [], opts_or_env \\ []) def eval_string(string, binding, %Macro.Env{} = env) do - validated_eval_string(string, validate_binding(binding), env) + validated_eval_string(string, validate_binding(binding), env_for_eval(env), []) end def eval_string(string, binding, opts) when is_list(opts) do - validated_eval_string(string, validate_binding(binding), opts) + validated_eval_string(string, validate_binding(binding), env_for_eval(opts), opts) end defp validate_binding(binding) when is_list(binding), do: binding @@ -634,10 +643,10 @@ defmodule Code do raise ArgumentError, "binding must be a list, got: #{inspect(binding)}" end - defp validated_eval_string(string, binding, opts_or_env) do - %{line: line, file: file} = env = env_for_eval(opts_or_env) + defp validated_eval_string(string, binding, env, opts) do + %{line: line, file: file} = env forms = :elixir.string_to_quoted!(to_charlist(string), line, 1, file, []) - {value, binding, _env} = eval_verify(:eval_forms, [forms, binding, env]) + {value, binding, _env} = eval_verify(:eval_forms, [forms, binding, env, opts]) {value, binding} end @@ -1140,7 +1149,8 @@ defmodule Code do returned quoted expressions (instead of evaluated). See `eval_string/3` for a description of arguments and return types. - The options are described under `env_for_eval/1`. + It accepts the same options as both `env_for_eval/1` and + `eval_quoted_with_env/4`. ## Examples @@ -1162,11 +1172,20 @@ defmodule Code do [a: 1, b: 2] """ - @spec eval_quoted(Macro.t(), binding, Macro.Env.t() | env_eval_opts) :: {term, binding} - def eval_quoted(quoted, binding \\ [], env_or_opts \\ []) do - {value, binding, _env} = - eval_verify(:eval_quoted, [quoted, binding, env_for_eval(env_or_opts)]) + @spec eval_quoted(Macro.t(), binding, Macro.Env.t() | [eval_opt | env_eval_opt]) :: + {term, binding} + def eval_quoted(quoted, binding \\ [], env_or_opts \\ []) + def eval_quoted(quoted, binding, %Macro.Env{} = env) do + eval_quoted(quoted, validate_binding(binding), env_for_eval(env), []) + end + + def eval_quoted(quoted, binding, opts) when is_list(opts) do + eval_quoted(quoted, validate_binding(binding), env_for_eval(opts), opts) + end + + defp eval_quoted(quoted, binding, env, opts) do + {value, binding, _env} = eval_verify(:eval_quoted, [quoted, binding, env, opts]) {value, binding} end @@ -1194,14 +1213,9 @@ defmodule Code do * `:module` - the module to run the environment on - * `:prune_binding` - (since v1.14.2) prune binding to keep only - variables read or written by the evaluated code. Note that - variables used by modules are always pruned, even if later used - by the modules. You can submit to the `:on_module` tracer event - and access the variables used by the module from its environment. """ @doc since: "1.14.0" - @spec env_for_eval(Macro.Env.t() | env_eval_opts) :: Macro.Env.t() + @spec env_for_eval(Macro.Env.t() | [env_eval_opt]) :: Macro.Env.t() def env_for_eval(env_or_opts), do: :elixir.env_for_eval(env_or_opts) @doc """ @@ -1215,11 +1229,19 @@ defmodule Code do ## Options - It accepts the same options as `env_for_eval/1`. + * `:prune_binding` - (since v1.14.2) prune binding to keep only + variables read or written by the evaluated code. Note that + variables used by modules are always pruned, even if later used + by the modules. You can submit to the `:on_module` tracer event + and access the variables used by the module from its environment. + + * `:dbg_callback` - (since v1.20.0) overrides the behaviour of `dbg/2` + used in the evaluated code. It must be a `{module, function, args}` + tuple, see `dbg/2` for more details. """ @doc since: "1.14.0" - @spec eval_quoted_with_env(Macro.t(), binding, Macro.Env.t(), env_eval_opts) :: + @spec eval_quoted_with_env(Macro.t(), binding, Macro.Env.t(), [eval_opt]) :: {term, binding, Macro.Env.t()} def eval_quoted_with_env(quoted, binding, %Macro.Env{} = env, opts \\ []) when is_list(binding) do diff --git a/lib/elixir/lib/kernel.ex b/lib/elixir/lib/kernel.ex index 4ae542edc87..50c07283d1a 100644 --- a/lib/elixir/lib/kernel.ex +++ b/lib/elixir/lib/kernel.ex @@ -6326,7 +6326,15 @@ defmodule Kernel do """ @doc since: "1.14.0" defmacro dbg(code \\ quote(do: binding()), options \\ []) do - {mod, fun, args} = Application.compile_env!(__CALLER__, :elixir, :dbg_callback) + # The compiling process may override the callback by putting it in + # the process dictionary. + dbg_callback = + case :erlang.get({:elixir, :dbg_callback}) do + :undefined -> Application.compile_env!(__CALLER__, :elixir, :dbg_callback) + value -> value + end + + {mod, fun, args} = dbg_callback Macro.compile_apply(mod, fun, [code, options, __CALLER__ | args], __CALLER__) end diff --git a/lib/elixir/src/elixir.erl b/lib/elixir/src/elixir.erl index 324a9d27a9a..7db45854f5c 100644 --- a/lib/elixir/src/elixir.erl +++ b/lib/elixir/src/elixir.erl @@ -10,7 +10,7 @@ -export([start/2, stop/1, config_change/3]). -export([ string_to_tokens/5, tokens_to_quoted/3, string_to_quoted/5, 'string_to_quoted!'/5, - env_for_eval/1, quoted_to_erl/2, eval_forms/3, eval_quoted/3, eval_quoted/4, + env_for_eval/1, quoted_to_erl/2, eval_forms/3, eval_forms/4, eval_quoted/3, eval_quoted/4, erl_eval/3, eval_local_handler/2, eval_external_handler/3, emit_warnings/3 ]). -include("elixir.hrl"). @@ -304,27 +304,35 @@ eval_forms(Tree, Binding, OrigE) -> eval_forms(Tree, Binding, OrigE, []). eval_forms(Tree, Binding, OrigE, Opts) -> Prune = proplists:get_value(prune_binding, Opts, false), - {ExVars, ErlVars, ErlBinding} = elixir_erl_var:load_binding(Binding, Prune), - E = elixir_env:with_vars(OrigE, ExVars), - ExS = elixir_env:env_to_ex(E), - ErlS = elixir_erl_var:from_env(E, ErlVars), - {Erl, NewErlS, NewExS, NewE} = quoted_to_erl(Tree, ErlS, ExS, E), - - case Erl of - {Literal, _, Value} when Literal == atom; Literal == float; Literal == integer -> - if - Prune -> {Value, [], NewE#{versioned_vars := #{}}}; - true -> {Value, Binding, NewE} - end; - - _ -> - {value, Value, NewBinding} = erl_eval(Erl, ErlBinding, NewE), - PruneBefore = if Prune -> length(Binding); true -> -1 end, - - {DumpedBinding, DumpedVars} = - elixir_erl_var:dump_binding(NewBinding, NewErlS, NewExS, PruneBefore), - - {Value, DumpedBinding, NewE#{versioned_vars := DumpedVars}} + case proplists:get_value(dbg_callback, Opts) of + undefined -> ok; + DbgCallback -> erlang:put({elixir, dbg_callback}, DbgCallback) + end, + try + {ExVars, ErlVars, ErlBinding} = elixir_erl_var:load_binding(Binding, Prune), + E = elixir_env:with_vars(OrigE, ExVars), + ExS = elixir_env:env_to_ex(E), + ErlS = elixir_erl_var:from_env(E, ErlVars), + {Erl, NewErlS, NewExS, NewE} = quoted_to_erl(Tree, ErlS, ExS, E), + + case Erl of + {Literal, _, Value} when Literal == atom; Literal == float; Literal == integer -> + if + Prune -> {Value, [], NewE#{versioned_vars := #{}}}; + true -> {Value, Binding, NewE} + end; + + _ -> + {value, Value, NewBinding} = erl_eval(Erl, ErlBinding, NewE), + PruneBefore = if Prune -> length(Binding); true -> -1 end, + + {DumpedBinding, DumpedVars} = + elixir_erl_var:dump_binding(NewBinding, NewErlS, NewExS, PruneBefore), + + {Value, DumpedBinding, NewE#{versioned_vars := DumpedVars}} + end + after + erlang:erase({elixir, dbg_callback}) end. %% Evaluate Erlang code with careful handling of local and external functions diff --git a/lib/elixir/test/elixir/code_test.exs b/lib/elixir/test/elixir/code_test.exs index 876343e6584..96dd87165f5 100644 --- a/lib/elixir/test/elixir/code_test.exs +++ b/lib/elixir/test/elixir/code_test.exs @@ -246,6 +246,21 @@ defmodule CodeTest do } ] = diagnostics end + + test "with :prune_binding" do + opts = [prune_binding: true] + assert {2, [x: 1]} = Code.eval_string("x + 1", [x: 1, y: 2], opts) + end + + test "with :debug_callback" do + opts = [dbg_callback: {__MODULE__, :dbg_callback_add_one, []}] + assert {2, _binding} = Code.eval_string("dbg(1)", [], opts) + + # Maintains the default behaviour when called again without the option. + ExUnit.CaptureIO.capture_io(fn -> + assert {1, _binding} = Code.eval_string("dbg(1)", []) + end) + end end describe "eval_quoted/1" do @@ -270,6 +285,23 @@ defmodule CodeTest do {"foo", []} = Code.eval_string("Chars.to_string(:foo)", [], __ENV__) end end + + test "with :prune_binding" do + quoted = quote(do: var!(x) + 1) + opts = [prune_binding: true] + assert {2, [x: 1]} = Code.eval_quoted(quoted, [x: 1, y: 2], opts) + end + + test "with :dbg_callback" do + quoted = quote(do: dbg(1)) + opts = [dbg_callback: {__MODULE__, :dbg_callback_add_one, []}] + assert {2, _binding} = Code.eval_quoted(quoted, [], opts) + + # Maintains the default behaviour when called again without the option. + ExUnit.CaptureIO.capture_io(fn -> + assert {1, _binding} = Code.eval_quoted(quoted, []) + end) + end end test "eval_file/1" do @@ -381,6 +413,24 @@ defmodule CodeTest do assert binding == [] assert Macro.Env.vars(env) == [] end + + test "with :dbg_callback" do + quoted = quote(do: dbg(1)) + env = Code.env_for_eval(__ENV__) + opts = [dbg_callback: {__MODULE__, :dbg_callback_add_one, []}] + assert {2, _binding, _env} = Code.eval_quoted_with_env(quoted, [], env, opts) + + # Maintains the default behaviour when called again without the option. + ExUnit.CaptureIO.capture_io(fn -> + assert {1, _binding, _env} = Code.eval_quoted_with_env(quoted, [], env, []) + end) + end + end + + def dbg_callback_add_one(code, _options, _caller) do + quote do + unquote(code) + 1 + end end describe "compile_file/1" do