From a9e741db4e36b3a7c334d13ace79152fb85a14b0 Mon Sep 17 00:00:00 2001 From: Onni Hakala Date: Sun, 27 Sep 2026 11:42:04 +0300 Subject: [PATCH] improvement: record the define line on code interface functions Generated code interface functions now record the line of their `define` in the module's debug info, so editors and language servers can jump to it instead of the top of the domain or resource. The file and the body's lines stay code_interface.ex, so stacktraces are unchanged. --- lib/ash/code_interface.ex | 419 +++++++++++++++++++++-------------- test/code_interface_test.exs | 47 ++++ 2 files changed, 295 insertions(+), 171 deletions(-) diff --git a/lib/ash/code_interface.ex b/lib/ash/code_interface.ex index 7f028a64c..90296e7f6 100644 --- a/lib/ash/code_interface.ex +++ b/lib/ash/code_interface.ex @@ -26,6 +26,62 @@ defmodule Ash.CodeInterface do action end + @doc false + # Defines the `def` in `do` with the line of the `define` that declared it, so its + # debug info points editors at that line. The file and the body's lines stay this + # module's, so stacktraces are unchanged. + defmacro define_at(interface, env, do: {:def, meta, [call | body]}) do + body = + case body do + [[do: body]] -> Macro.escape(body, unquote: true) + [] -> nil + end + + quote do + Ash.CodeInterface.eval_definition( + unquote(Macro.escape(call, unquote: true)), + unquote(body), + unquote(Keyword.delete(meta, :line)), + unquote(interface), + unquote(env) + ) + end + end + + @doc false + # The head and body reach the `def` through the binding, so the evaluator only + # substitutes two variables instead of walking the escaped body node by node. + def eval_definition(call, body, meta, interface, env) do + call_fragment = {:unquote, [], [{:call, [], nil}]} + + args = + case body do + nil -> [call_fragment] + _ -> [call_fragment, [do: {:unquote, [], [{:body, [], nil}]}]] + end + + Code.eval_quoted({:def, meta, args}, [call: call, body: body], %{ + env + | line: definition_line(interface, env) + }) + end + + @doc false + # The `define` line, when it is in the file being compiled; `define_interface/3` + # called by hand from another module keeps that call's line. + def definition_line(interface, env) do + with anno when not is_nil(anno) <- Spark.Dsl.Entity.anno(interface), + line when is_integer(line) and line > 0 <- :erl_anno.line(anno), + true <- same_file?(:erl_anno.file(anno), env.file) do + line + else + _ -> env.line + end + end + + defp same_file?(:undefined, _env_file), do: true + defp same_file?(file, env_file), do: Path.expand(to_string(file)) == Path.expand(env_file) + @doc false def default_value(resource, action, key) do {field_type, field} = @@ -430,6 +486,8 @@ defmodule Ash.CodeInterface do quote bind_quoted: [domain: domain, resource: resource, definitions: definitions], generated: true, location: :keep do + interface_env = __ENV__ + calculation_interfaces = case definitions do nil -> @@ -514,8 +572,13 @@ defmodule Ash.CodeInterface do {safe_name, bang_name} = Ash.CodeInterface.resolve_calc_method_names(name) - def unquote(bang_name)(unquote_splicing(args), opts \\ []) - def unquote(safe_name)(unquote_splicing(args), opts \\ []) + Ash.CodeInterface.define_at hd(interfaces), interface_env do + def unquote(bang_name)(unquote_splicing(args), opts \\ []) + end + + Ash.CodeInterface.define_at hd(interfaces), interface_env do + def unquote(safe_name)(unquote_splicing(args), opts \\ []) + end end for interface <- calculation_interfaces do @@ -545,56 +608,58 @@ defmodule Ash.CodeInterface do @doc spark_opts: [ {opts_location, interface_options.schema()} ] - def unquote(bang_name)(unquote_splicing(arg_bindings), opts) do - {refs, arguments, record} = - Enum.reduce( - [unquote_splicing(arg_access)], - {opts[:refs] || %{}, opts[:args] || %{}, nil}, - fn config, {refs, arguments, record} -> - case config[:type] do - :_record -> - {refs, arguments, config[:value]} - - :both -> - {Map.put(refs, config[:name], config[:value]), - Map.put(arguments, config[:name], config[:value]), record} - - :ref -> - {Map.put(refs, config[:name], config[:value]), arguments, record} - - :arg -> - {refs, Map.put(arguments, config[:name], config[:value]), record} + Ash.CodeInterface.define_at interface, interface_env do + def unquote(bang_name)(unquote_splicing(arg_bindings), opts) do + {refs, arguments, record} = + Enum.reduce( + [unquote_splicing(arg_access)], + {opts[:refs] || %{}, opts[:args] || %{}, nil}, + fn config, {refs, arguments, record} -> + case config[:type] do + :_record -> + {refs, arguments, config[:value]} + + :both -> + {Map.put(refs, config[:name], config[:value]), + Map.put(arguments, config[:name], config[:value]), record} + + :ref -> + {Map.put(refs, config[:name], config[:value]), arguments, record} + + :arg -> + {refs, Map.put(arguments, config[:name], config[:value]), record} + end end - end - ) + ) - case Enum.filter(unquote(interface.exclude_inputs || []), fn input -> - Map.has_key?(arguments, input) || Map.has_key?(arguments, to_string(input)) - end) do - [] -> - :ok + case Enum.filter(unquote(interface.exclude_inputs || []), fn input -> + Map.has_key?(arguments, input) || Map.has_key?(arguments, to_string(input)) + end) do + [] -> + :ok - inputs -> - raise ArgumentError, - "Input(s) `#{Enum.join(inputs, ", ")}` not accepted by #{inspect(unquote(resource))}.#{unquote(interface.calculation)}/#{unquote(Enum.count(arg_bindings) + 1)}" - end + inputs -> + raise ArgumentError, + "Input(s) `#{Enum.join(inputs, ", ")}` not accepted by #{inspect(unquote(resource))}.#{unquote(interface.calculation)}/#{unquote(Enum.count(arg_bindings) + 1)}" + end - {arguments, custom_input_errors} = - Ash.CodeInterface.handle_custom_inputs( - arguments, - unquote(custom_inputs), - unquote(resource) - ) + {arguments, custom_input_errors} = + Ash.CodeInterface.handle_custom_inputs( + arguments, + unquote(custom_inputs), + unquote(resource) + ) - case custom_input_errors do - [] -> - opts = - [domain: unquote(domain), refs: refs, args: arguments, record: record] ++ opts + case custom_input_errors do + [] -> + opts = + [domain: unquote(domain), refs: refs, args: arguments, record: record] ++ opts - Ash.calculate!(unquote(resource), unquote(interface.calculation), opts) + Ash.calculate!(unquote(resource), unquote(interface.calculation), opts) - errors -> - raise Ash.Error.to_error_class(errors) + errors -> + raise Ash.Error.to_error_class(errors) + end end end @@ -611,56 +676,58 @@ defmodule Ash.CodeInterface do @doc spark_opts: [ {opts_location, interface_options.schema()} ] - def unquote(safe_name)(unquote_splicing(arg_bindings), opts) do - {refs, arguments, record} = - Enum.reduce( - [unquote_splicing(arg_access)], - {opts[:refs] || %{}, opts[:args] || %{}, nil}, - fn config, {refs, arguments, record} -> - case config[:type] do - :_record -> - {refs, arguments, config[:value]} - - :both -> - {Map.put(refs, config[:name], config[:value]), - Map.put(arguments, config[:name], config[:value]), record} - - :ref -> - {Map.put(refs, config[:name], config[:value]), arguments, record} - - :arg -> - {refs, Map.put(arguments, config[:name], config[:value]), record} + Ash.CodeInterface.define_at interface, interface_env do + def unquote(safe_name)(unquote_splicing(arg_bindings), opts) do + {refs, arguments, record} = + Enum.reduce( + [unquote_splicing(arg_access)], + {opts[:refs] || %{}, opts[:args] || %{}, nil}, + fn config, {refs, arguments, record} -> + case config[:type] do + :_record -> + {refs, arguments, config[:value]} + + :both -> + {Map.put(refs, config[:name], config[:value]), + Map.put(arguments, config[:name], config[:value]), record} + + :ref -> + {Map.put(refs, config[:name], config[:value]), arguments, record} + + :arg -> + {refs, Map.put(arguments, config[:name], config[:value]), record} + end end - end - ) + ) - case Enum.filter(unquote(interface.exclude_inputs || []), fn input -> - Map.has_key?(arguments, input) || Map.has_key?(arguments, to_string(input)) - end) do - [] -> - :ok + case Enum.filter(unquote(interface.exclude_inputs || []), fn input -> + Map.has_key?(arguments, input) || Map.has_key?(arguments, to_string(input)) + end) do + [] -> + :ok - inputs -> - raise ArgumentError, - "Input(s) `#{Enum.join(inputs, ", ")}` not accepted by #{inspect(unquote(resource))}.#{unquote(interface.calculation)}/#{unquote(Enum.count(arg_bindings) + 1)}" - end + inputs -> + raise ArgumentError, + "Input(s) `#{Enum.join(inputs, ", ")}` not accepted by #{inspect(unquote(resource))}.#{unquote(interface.calculation)}/#{unquote(Enum.count(arg_bindings) + 1)}" + end - {arguments, custom_input_errors} = - Ash.CodeInterface.handle_custom_inputs( - arguments, - unquote(custom_inputs), - unquote(resource) - ) + {arguments, custom_input_errors} = + Ash.CodeInterface.handle_custom_inputs( + arguments, + unquote(custom_inputs), + unquote(resource) + ) - case custom_input_errors do - [] -> - opts = - [domain: unquote(domain), refs: refs, args: arguments, record: record] ++ opts + case custom_input_errors do + [] -> + opts = + [domain: unquote(domain), refs: refs, args: arguments, record: record] ++ opts - Ash.calculate(unquote(resource), unquote(interface.calculation), opts) + Ash.calculate(unquote(resource), unquote(interface.calculation), opts) - errors -> - {:error, Ash.Error.to_error_class(errors)} + errors -> + {:error, Ash.Error.to_error_class(errors)} + end end end end @@ -1109,16 +1176,18 @@ defmodule Ash.CodeInterface do {first_opts_location + 1, interface_options.schema()} ] - def unquote(action_fn)( - unquote_splicing(common_args), - params \\ nil, - opts \\ nil - ) do - {params_or_opts, opts} = unquote(params_handling_bulk_empty_params) - - unquote(resolve_params_and_opts) - unquote(resolve_subject) - unquote(act) + Ash.CodeInterface.define_at interface, interface_env do + def unquote(action_fn)( + unquote_splicing(common_args), + params \\ nil, + opts \\ nil + ) do + {params_or_opts, opts} = unquote(params_handling_bulk_empty_params) + + unquote(resolve_params_and_opts) + unquote(resolve_subject) + unquote(act) + end end end @@ -1138,15 +1207,17 @@ defmodule Ash.CodeInterface do {first_opts_location, interface_options.schema()}, {first_opts_location + 1, interface_options.schema()} ] - def unquote(bang_fn)( - unquote_splicing(common_args), - params \\ nil, - opts \\ nil - ) do - {params_or_opts, opts} = unquote(params_handling_bulk_empty_params) - unquote(resolve_params_and_opts) - unquote(resolve_subject) - unquote(act!) + Ash.CodeInterface.define_at interface, interface_env do + def unquote(bang_fn)( + unquote_splicing(common_args), + params \\ nil, + opts \\ nil + ) do + {params_or_opts, opts} = unquote(params_handling_bulk_empty_params) + unquote(resolve_params_and_opts) + unquote(resolve_subject) + unquote(act!) + end end end @@ -1176,14 +1247,16 @@ defmodule Ash.CodeInterface do {first_opts_location, subject_opts}, {first_opts_location + 1, subject_opts} ] - def unquote(:"#{subject_name}_to_#{interface.name}")( - unquote_splicing(common_args), - params_or_opts \\ %{}, - opts \\ [] - ) do - unquote(resolve_params_and_opts) - unquote(resolve_subject) - unquote(subject) + Ash.CodeInterface.define_at interface, interface_env do + def unquote(:"#{subject_name}_to_#{interface.name}")( + unquote_splicing(common_args), + params_or_opts \\ %{}, + opts \\ [] + ) do + unquote(resolve_params_and_opts) + unquote(resolve_subject) + unquote(subject) + end end end @@ -1195,36 +1268,38 @@ defmodule Ash.CodeInterface do {first_opts_location + 1, Ash.Resource.Interface.CanOpts.schema()}, {first_opts_location + 2, Ash.Resource.Interface.CanOpts.schema()} ] - def unquote(can_fn)( - actor, - unquote_splicing(common_args), - params_or_opts \\ %{}, - opts \\ [] - ) do - {params, opts, custom_input_errors} = - Ash.CodeInterface.can_opts( - params_or_opts, - opts, - unquote(arg_params), - unquote(resource), - unquote(interface.name), - unquote(interface.exclude_inputs), - unquote(Enum.count(interface.args || []) + 2), - unquote(custom_inputs), - unquote(interface_options), - actor - ) + Ash.CodeInterface.define_at interface, interface_env do + def unquote(can_fn)( + actor, + unquote_splicing(common_args), + params_or_opts \\ %{}, + opts \\ [] + ) do + {params, opts, custom_input_errors} = + Ash.CodeInterface.can_opts( + params_or_opts, + opts, + unquote(arg_params), + unquote(resource), + unquote(interface.name), + unquote(interface.exclude_inputs), + unquote(Enum.count(interface.args || []) + 2), + unquote(custom_inputs), + unquote(interface_options), + actor + ) - unquote(resolve_subject) + unquote(resolve_subject) - Ash.CodeInterface.can( - params, - opts, - actor, - unquote(subject), - unquote(action.name), - unquote(interface.name) - ) + Ash.CodeInterface.can( + params, + opts, + actor, + unquote(subject), + unquote(action.name), + unquote(interface.name) + ) + end end end @@ -1236,36 +1311,38 @@ defmodule Ash.CodeInterface do {first_opts_location + 2, Ash.Resource.Interface.CanQuestionMarkOpts.schema()} ] @doc Ash.CodeInterface.docs_can?(resource, action) - def unquote(can_question_fn)( - actor, - unquote_splicing(common_args), - params_or_opts \\ %{}, - opts \\ [] - ) do - {params, opts, custom_input_errors} = - Ash.CodeInterface.can_opts( - params_or_opts, - opts, - unquote(arg_params), - unquote(resource), - unquote(interface.name), - unquote(interface.exclude_inputs), - unquote(Enum.count(interface.args || []) + 2), - unquote(custom_inputs), - unquote(interface_options), - actor - ) + Ash.CodeInterface.define_at interface, interface_env do + def unquote(can_question_fn)( + actor, + unquote_splicing(common_args), + params_or_opts \\ %{}, + opts \\ [] + ) do + {params, opts, custom_input_errors} = + Ash.CodeInterface.can_opts( + params_or_opts, + opts, + unquote(arg_params), + unquote(resource), + unquote(interface.name), + unquote(interface.exclude_inputs), + unquote(Enum.count(interface.args || []) + 2), + unquote(custom_inputs), + unquote(interface_options), + actor + ) - unquote(resolve_subject) + unquote(resolve_subject) - Ash.CodeInterface.can?( - params, - opts, - actor, - unquote(subject), - unquote(action.name), - unquote(interface.name) - ) + Ash.CodeInterface.can?( + params, + opts, + actor, + unquote(subject), + unquote(action.name), + unquote(interface.name) + ) + end end end end diff --git a/test/code_interface_test.exs b/test/code_interface_test.exs index ad7a69bf0..e5cd097fd 100644 --- a/test/code_interface_test.exs +++ b/test/code_interface_test.exs @@ -356,6 +356,53 @@ defmodule Ash.Test.CodeInterfaceTest do :ok end + test "generated functions record the line of their `define`" do + # `mix test` compiles without debug info, which holds the recorded locations. + debug_info = Code.get_compiler_option(:debug_info) + Code.put_compiler_option(:debug_info, true) + + compiled = + try do + Code.compile_string(""" + defmodule Ash.Test.CodeInterfaceTest.DefineLine do + use Ash.Resource, domain: Ash.Test.Domain, data_layer: Ash.DataLayer.Ets + + attributes do + uuid_primary_key :id + end + + actions do + defaults [:read, create: []] + end + + code_interface do + define :list, action: :read + define :create + end + end + """) + after + Code.put_compiler_option(:debug_info, debug_info) + end + + {module, binary} = List.keyfind(compiled, Ash.Test.CodeInterfaceTest.DefineLine, 0) + + {:ok, {_, [debug_info: {:debug_info_v1, backend, data}]}} = + :beam_lib.chunks(binary, [:debug_info]) + + {:ok, %{definitions: definitions}} = backend.debug_info(:elixir_v1, module, data, []) + metas = Map.new(definitions, fn {fun_arity, _kind, meta, _clauses} -> {fun_arity, meta} end) + + assert metas[{:list!, 0}][:line] == 13 + assert metas[{:list, 1}][:line] == 13 + assert metas[{:create!, 0}][:line] == 14 + assert metas[{:can_create?, 1}][:line] == 14 + + # The file, and with it every line in the body and in stacktraces, stays Ash's. + {file, _line} = metas[{:create!, 0}][:file] + assert String.ends_with?(file, "lib/ash/code_interface.ex") + end + describe "exclude_inputs" do test "raises an error when used" do assert_raise ArgumentError,