diff --git a/lib/mix/tasks/check.ex b/lib/mix/tasks/check.ex index 07145e0..96b8297 100644 --- a/lib/mix/tasks/check.ex +++ b/lib/mix/tasks/check.ex @@ -13,9 +13,12 @@ defmodule Mix.Tasks.Atomvm.Check do """ alias Mix.Project + alias Mix.Tasks.Atomvm.MacroTracer def run(args) do + MacroTracer.start() Mix.Tasks.Compile.run(args) + MacroTracer.stop() beams_path = Project.compile_path() @@ -86,25 +89,25 @@ defmodule Mix.Tasks.Atomvm.Check do |> Enum.into(MapSet.new()) end - defp extract_ext_calls({:beam_file, module_name, _, _, _, code}) do + defp extract_ext_calls({:beam_file, module_name, _, _, _, code}, source_file) do ext_calls = - scan_instructions(code, fn - {:call_ext, _, {:extfunc, module, extfunc, arity}}, acc -> - [{module, extfunc, arity} | acc] + scan_instructions_with_location(code, module_name, fn + {:call_ext, _, {:extfunc, module, extfunc, arity}}, {caller_mod, caller_func, caller_arity}, acc -> + [{module, extfunc, arity, caller_mod, caller_func, caller_arity, source_file} | acc] - {:call_ext_last, _, {:extfunc, module, extfunc, arity}}, acc -> - [{module, extfunc, arity} | acc] + {:call_ext_last, _, {:extfunc, module, extfunc, arity}}, {caller_mod, caller_func, caller_arity}, acc -> + [{module, extfunc, arity, caller_mod, caller_func, caller_arity, source_file} | acc] - {:call_ext_only, _, {:extfunc, module, extfunc, arity}}, acc -> - [{module, extfunc, arity} | acc] + {:call_ext_only, _, {:extfunc, module, extfunc, arity}}, {caller_mod, caller_func, caller_arity}, acc -> + [{module, extfunc, arity, caller_mod, caller_func, caller_arity, source_file} | acc] - {:bif, func, _, args, _}, acc -> - [{:erlang, func, length(args)} | acc] + {:bif, func, _, args, _}, {caller_mod, caller_func, caller_arity}, acc -> + [{:erlang, func, length(args), caller_mod, caller_func, caller_arity, source_file} | acc] - {:gc_bif, func, _, _, args, _}, acc -> - [{:erlang, func, length(args)} | acc] + {:gc_bif, func, _, _, args, _}, {caller_mod, caller_func, caller_arity}, acc -> + [{:erlang, func, length(args), caller_mod, caller_func, caller_arity, source_file} | acc] - _, acc -> + _, _location, acc -> acc end) @@ -147,28 +150,42 @@ defmodule Mix.Tasks.Atomvm.Check do defp extract_calls(path) do files = list_beam_files(path) - calls_by_mod = - Enum.reduce(files, %{}, fn filename, acc -> - file_path = Path.join(path, filename) + Enum.reduce(files, %{}, fn filename, acc -> + file_path = Path.join(path, filename) + beam_binary = File.read!(file_path) + source_file = get_source_file(file_path, beam_binary) - {module_name, ext_calls} = - File.read!(file_path) - |> :beam_disasm.file() - |> extract_ext_calls() + {_module_name, ext_calls} = + beam_binary + |> :beam_disasm.file() + |> extract_ext_calls(source_file) - Map.put(acc, module_name, ext_calls) + Enum.reduce(ext_calls, acc, fn {m, f, a, _caller_mod, caller_func, caller_arity, src}, inner_acc -> + call_string = "#{Atom.to_string(m)}:#{Atom.to_string(f)}/#{a}" + location = "#{src} (#{caller_func}/#{caller_arity})" + Map.update(inner_acc, call_string, [location], fn sources -> [location | sources] end) end) + end) + end - calls_by_mod - |> Map.values() - |> List.flatten() - |> Enum.uniq() - |> Enum.map(fn {m, f, a} -> "#{Atom.to_string(m)}:#{Atom.to_string(f)}/#{a}" end) - |> Enum.into(MapSet.new()) + defp get_source_file(beam_path, beam_binary) do + source = + case :beam_lib.chunks(beam_binary, [:compile_info]) do + {:ok, {_, [{:compile_info, info}]}} -> + case Keyword.get(info, :source) do + nil -> beam_path + source -> List.to_string(source) + end + + _ -> + beam_path + end + + Path.relative_to_cwd(source) end defp check_ext_calls(beams_path) do - calls_set = extract_calls(beams_path) + calls_map = extract_calls(beams_path) runtime_deps_beams = Mix.Tasks.Atomvm.Packbeam.runtime_deps_beams() exported_calls_set = @@ -181,12 +198,17 @@ defmodule Mix.Tasks.Atomvm.Check do |> Enum.into(MapSet.new()) |> MapSet.union(exported_calls_set) - missing = MapSet.difference(calls_set, avail_funcs) + missing = + calls_map + |> Map.filter(fn {call, _sources} -> not MapSet.member?(avail_funcs, call) end) - if MapSet.size(missing) != 0 do + if map_size(missing) != 0 do IO.puts("Warning: following modules or functions are not available on AtomVM:") - print_list(missing) + print_list_with_sources(missing) IO.puts("") + + MacroTracer.report_macro_sources(missing) + IO.puts("(Using them may not be supported; make sure ExAtomVM is fully updated.)") IO.puts("") @@ -237,6 +259,17 @@ defmodule Mix.Tasks.Atomvm.Check do |> IO.puts() end + defp print_list_with_sources(map) do + map + |> Enum.sort_by(fn {call, _sources} -> call end) + |> Enum.map(fn {call, sources} -> + unique_sources = sources |> Enum.uniq() |> Enum.sort() |> Enum.join(", ") + "* #{call}\n in: #{unique_sources}" + end) + |> Enum.join("\n") + |> IO.puts() + end + defp list_beam_files(path) do path |> File.ls!() @@ -250,4 +283,11 @@ defmodule Mix.Tasks.Atomvm.Check do |> List.flatten() |> Enum.uniq() end + + defp scan_instructions_with_location(code, module_name, fun) do + Enum.flat_map(code, fn {:function, func_name, arity, _, func_code} -> + location = {module_name, func_name, arity} + Enum.reduce(func_code, [], fn instr, acc -> fun.(instr, location, acc) end) + end) + end end diff --git a/lib/mix/tasks/macro_tracer.ex b/lib/mix/tasks/macro_tracer.ex new file mode 100644 index 0000000..b743c4c --- /dev/null +++ b/lib/mix/tasks/macro_tracer.ex @@ -0,0 +1,246 @@ +defmodule Mix.Tasks.Atomvm.MacroTracer do + @moduledoc false + + @manifest_name "atomvm_call_origins" + + # --- Compiler Tracer API --- + + def manifest_path do + Path.join(Mix.Project.manifest_path(), @manifest_name) + end + + def start do + if :ets.whereis(__MODULE__) == :undefined do + :ets.new(__MODULE__, [:named_table, :public, :bag]) + else + :ets.delete_all_objects(__MODULE__) + end + + Code.put_compiler_option(:tracers, [__MODULE__ | Code.get_compiler_option(:tracers)]) + end + + def stop do + tracers = Code.get_compiler_option(:tracers) -- [__MODULE__] + Code.put_compiler_option(:tracers, tracers) + save_manifest() + + if :ets.whereis(__MODULE__) != :undefined do + :ets.delete(__MODULE__) + end + end + + def trace({:remote_macro, _meta, module, name, arity}, _env) do + macro_info = {module, name, arity} + stack = Process.get(:macro_stack, []) + Process.put(:macro_stack, [macro_info | stack]) + :ok + end + + def trace(:remote_macro_expansion, _env) do + :ok + end + + def trace({:remote_function, _meta, module, name, arity}, _env) do + call = "#{Atom.to_string(module)}:#{name}/#{arity}" + + case find_relevant_macro(Process.get(:macro_stack, [])) do + nil -> :ok + macro_info -> :ets.insert(__MODULE__, {call, macro_info}) + end + + :ok + end + + def trace(_event, _env), do: :ok + + # Skip standard Kernel/Elixir macros to find the actual dependency macro + @skip_modules [Kernel, Kernel.SpecialForms, Module, Code] + + defp find_relevant_macro([]), do: nil + + defp find_relevant_macro([{module, _name, _arity} = macro_info | rest]) do + if module in @skip_modules do + find_relevant_macro(rest) + else + macro_info + end + end + + defp save_manifest do + if :ets.whereis(__MODULE__) != :undefined do + data = + __MODULE__ + |> :ets.tab2list() + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + + File.mkdir_p!(Path.dirname(manifest_path())) + File.write!(manifest_path(), :erlang.term_to_binary(data)) + end + end + + def load_manifest do + case File.read(manifest_path()) do + {:ok, binary} -> :erlang.binary_to_term(binary) + {:error, _} -> %{} + end + end + + # --- Macro Source Finding API --- + + @doc """ + Finds the source locations of macros that generated the given calls. + Combines results from compile-time tracer and BEAM scanning. + Only returns entries for calls that have macro sources. + """ + def find_sources(missing_calls, opts \\ []) do + tracer_results = find_from_tracer(missing_calls, opts) + beam_results = find_from_beams(missing_calls, opts) + + Map.merge(beam_results, tracer_results, fn _k, beam_locs, tracer_locs -> + Enum.uniq(tracer_locs ++ beam_locs) + end) + end + + defp find_from_tracer(macro_calls, opts) do + tracer_manifest = load_manifest() + target_calls = Map.keys(macro_calls) + source_fn = Keyword.get(opts, :source_fn, &default_module_source/1) + + Enum.reduce(target_calls, %{}, fn call, acc -> + case Map.get(tracer_manifest, call) do + nil -> + acc + + origins -> + locations = + origins + |> Enum.uniq() + |> Enum.map(fn {mod, name, arity} -> + "#{source_fn.(mod)} (#{name}/#{arity})" + end) + + Map.put(acc, call, locations) + end + end) + end + + defp find_from_beams(macro_calls, opts) do + dep_beams = Keyword.get(opts, :dep_beams, Mix.Tasks.Atomvm.Packbeam.runtime_deps_beams()) + source_fn = Keyword.get(opts, :source_fn, &default_beam_source/1) + target_calls = macro_calls |> Map.keys() |> MapSet.new() + + Enum.reduce(dep_beams, %{}, fn beam_path, acc -> + beam_binary = File.read!(beam_path) + source_file = source_fn.(beam_path) + + case :beam_disasm.file(beam_binary) do + {:beam_file, _module_name, _exports, _, _, code} -> + find_macros_with_calls(code, target_calls) + |> Enum.reduce(acc, fn {macro_name, arity, used_calls}, inner_acc -> + Enum.reduce(used_calls, inner_acc, fn call, call_acc -> + location = "#{source_file} (#{format_macro_name(macro_name)}/#{arity - 1})" + Map.update(call_acc, call, [location], &[location | &1]) + end) + end) + + _ -> + acc + end + end) + end + + defp find_macros_with_calls(code, target_calls) do + Enum.flat_map(code, fn {:function, func_name, arity, _, func_code} -> + if macro_function?(func_name) do + calls = extract_matching_calls(func_code, target_calls) + + if MapSet.size(calls) > 0 do + [{func_name, arity, MapSet.to_list(calls)}] + else + [] + end + else + [] + end + end) + end + + defp macro_function?(func_name) do + func_name |> Atom.to_string() |> String.starts_with?("MACRO-") + end + + defp extract_matching_calls(func_code, target_calls) do + Enum.reduce(func_code, MapSet.new(), fn instr, acc -> + case extract_ext_call(instr) do + nil -> acc + call_str -> if MapSet.member?(target_calls, call_str), do: MapSet.put(acc, call_str), else: acc + end + end) + end + + defp extract_ext_call({:call_ext, _, {:extfunc, mod, func, ar}}), + do: "#{Atom.to_string(mod)}:#{Atom.to_string(func)}/#{ar}" + + defp extract_ext_call({:call_ext_last, _, {:extfunc, mod, func, ar}}), + do: "#{Atom.to_string(mod)}:#{Atom.to_string(func)}/#{ar}" + + defp extract_ext_call({:call_ext_only, _, {:extfunc, mod, func, ar}}), + do: "#{Atom.to_string(mod)}:#{Atom.to_string(func)}/#{ar}" + + defp extract_ext_call(_), do: nil + + defp format_macro_name(func_name) do + func_name |> Atom.to_string() |> String.replace_prefix("MACRO-", "") + end + + defp default_module_source(module) do + case :code.which(module) do + :non_existing -> inspect(module) + beam_path when is_list(beam_path) -> default_beam_source(List.to_string(beam_path)) + beam_path -> default_beam_source(beam_path) + end + end + + defp default_beam_source(beam_path) do + beam_binary = File.read!(beam_path) + + source = + case :beam_lib.chunks(beam_binary, [:compile_info]) do + {:ok, {_, [{:compile_info, info}]}} -> + case Keyword.get(info, :source) do + nil -> beam_path + source -> List.to_string(source) + end + + _ -> + beam_path + end + + Path.relative_to_cwd(source) + end + + # --- Reporting --- + + @doc """ + Reports macro sources for missing calls if any were traced to macros. + """ + def report_macro_sources(missing) do + macro_sources = find_sources(missing) + + if map_size(macro_sources) > 0 do + IO.puts("Note: The following appear to come from macros defined in dependencies:") + print_sources(macro_sources) + IO.puts("") + end + end + + defp print_sources(macro_sources) do + macro_sources + |> Enum.sort_by(fn {call, _sources} -> call end) + |> Enum.each(fn {call, sources} -> + unique_sources = sources |> Enum.uniq() |> Enum.sort() + IO.puts("* #{call}") + Enum.each(unique_sources, &IO.puts(" macro in: #{&1}")) + end) + end +end