defmodule Mix.Tasks.Compile.Dispatch do
use Mix.Task.Compiler
@moduledoc ~S"""
Consolidate protocols dispatch
This does NOT use the elixir defprotocol functionality but instead
builds a dispatch table based soly on pattern matching. The purpose beeing
a more flexible dispatch built at compile time.
Loads all the project files and their dependencies before scanning for the
`Dispatch` behaviour. The modules implementing the protocol is parsed
for their AST which the compiler merges with the original source file.
The result is a single consolidated file with all the function clauses
for the dispatch. The imlementing beam file may be discarded as it is
not used.
"""
@impl true
def run(_args) do
config = Mix.Project.config()
Mix.Task.run("compile")
ast =
config
|> implementations()
|> consolidate()
# The list of defs is present, the module should be redefined
# by removing all of the callback functions defined in behaviour
# and then injecting the defs.
Enum.each(ast, fn {proto, defs} ->
source = "#{proto.module_info(:compile)[:source]}"
output =
case :code.which(proto) do
[] ->
# File is not compiled yet;
beam_path(Mix.Project.compile_path(config), proto)
file ->
# File is compiled, overwrite it
"#{file}"
end
{:ok, ast} = Code.string_to_quoted(File.read!(source))
{:defmodule, env, [aliases, [do: {:__block__, [], module_body}]]} = ast
callbacks =
for {fun, ar} <- proto.behaviour_info(:callbacks), into: %{}, do: {"#{fun}/#{ar}", []}
# Split non-callbacks and callbacks by their signature.
{defast, callbacks} =
Enum.reduce(module_body, {[], callbacks}, fn
{:def, _env1, [{fun, _env2, args} | _] = fallback}, {defast, callbacks} ->
key = "#{fun}/#{length(args)}"
callbacks = %{callbacks | key => [{:def, [], fallback}]}
{defast, callbacks}
node, {defast, callbacks} ->
{defast ++ [node], callbacks}
end)
# Prepend all known callbacks
callbacks =
Enum.reduce(defs, callbacks, fn {sig, clauses}, acc ->
# Turn it into a remote call
clauses =
for {:remote, {mod, fun, args}, clause} <- clauses do
body = [do: {{:., [], [mod, fun]}, [], args}]
{:def, [], [clause, body]}
end
%{acc | sig => clauses ++ acc[sig]}
end)
callbacksast = for {_, v} <- callbacks, do: v
newast = defast ++ List.flatten(callbacksast)
amended = {:defmodule, env, [aliases, [do: {:__block__, [], newast}]]}
# Attempt to remove the code to avoid warning,
_ = :code.purge(proto)
_ = :code.delete(proto)
[{_, buf}] = Code.compile_quoted(amended, source)
File.write!(output, buf)
end)
end
defp consolidate(protocols) do
Enum.map(protocols, fn {protocol, impls} ->
defs =
Enum.reduce(impls, %{}, fn impl, acc ->
List.flatten(Keyword.get_values(impl.__info__(:attributes), :__impl__))
|> Enum.reduce(acc, fn
{fun, _env, []}, _inner ->
raise ArgumentError, """
Dispatch implentation #{impl}.#{fun}/0 takes no arguments. This is an error.
All implemntations require atleast one argument...
"""
{fun, env, args}, inner ->
key = "#{fun}/#{length(args)}"
inner = Map.put_new(inner, key, [])
# remove unused variable warning by collecting the variables and seeing
# which can be removed
{_node, used} = Macro.prewalk(args, [], fn
{k, _env, nil} = node, acc when is_atom(k) ->
case "#{k}" do
"_" <> _ -> {node, acc}
_ -> {node, [k|acc]}
end
node, acc -> {node, acc}
end)
# A variable is unique if its not refered by anything else in the function header
keep = Enum.uniq(used -- Enum.uniq(used))
# Prefix unused variables with undrescore
args =
Macro.prewalk(args, fn
{k, env, nil} = node ->
if not Enum.member?(keep, k) do
{:"_#{k}", env, nil}
else
node
end
node ->
node
end)
uniqueargs = Macro.generate_unique_arguments(length(args), protocol)
nextargs =
Enum.map(
Enum.zip(uniqueargs, args),
fn {unique, node} ->
{:=, [], [node, unique]}
end
)
callback = {:remote, {impl, fun, uniqueargs}, {fun, env, nextargs}}
%{inner | key => [callback | inner[key]]}
end)
end)
{protocol, defs}
end)
end
defp implementations(config) do
apps =
for app <- deps(config) do
# this may fail for uncompiled applications in a umbrella project, in which
# case it can be ignored since :dispatch compiler will re-run later if required.
_ = Application.ensure_loaded(app)
app
end
:ok = ensure_all_files_loaded(apps)
for protocol <- dispatchable(config), into: %{} do
{protocol, get_protocol_implementations(config, protocol)}
end
end
defp dispatchable(_config) do
Enum.flat_map(:code.all_loaded(), fn {module, _} ->
cond do
not function_exported?(module, :module_info, 1) ->
[]
[] != List.flatten(Keyword.get_values(module.module_info(:attributes), :dispatch)) ->
[module]
true ->
[]
end
end)
end
defp beam_path(compile_path, module) do
Path.join(compile_path, Atom.to_string(module) <> ".beam")
end
defp ensure_all_files_loaded([]), do: :ok
defp ensure_all_files_loaded([app | rest]) do
case :code.lib_dir(app) do
[_ | _] = dir ->
for file <- Path.wildcard("#{dir}/ebin/*.beam") do
case String.to_atom(Path.basename(file, ".beam")) do
__MODULE__ ->
:ok
module ->
:code.purge(module)
:code.delete(module)
{:module, _} = :code.load_abs('#{Path.rootname(file)}')
end
end
{:error, _} ->
:ok
end
ensure_all_files_loaded(rest)
end
defp get_protocol_implementations(_config, protocol) do
Enum.flat_map(:code.all_loaded(), fn {module, _} ->
cond do
not function_exported?(module, :module_info, 1) ->
[]
protocol in List.flatten(Keyword.get_values(module.module_info(:attributes), :behaviour)) ->
[module]
true ->
[]
end
end)
end
defp deps(config) do
deps = for %{scm: scm} = dep <- Mix.Dep.cached(), not scm.fetchable?, do: dep.app
Enum.uniq([config[:app] | deps])
end
end