Current section
Files
Jump to
Current section
Files
lib/delegate_with_docs.ex
defmodule DelegateWithDocs do
@moduledoc """
Public API documentation for `DelegateWithDocs`.
"""
import Kernel, except: [defdelegate: 2]
require Kernel
alias Kernel.Typespec
defmodule Error do
@moduledoc false
defexception [:message]
end
@doc """
Overrides `Kernel.defdelegate/2` with `defdelegate/2`.
"""
defmacro __using__(_) do
quote do
import Kernel, except: [defdelegate: 2]
require Kernel
import DelegateWithDocs
# Hack to ensure that the module's docs and specs
# are available to other modules at compile time.
#
# For some reason, Elixir waits until all the modules
# are compiled before writing anything to disk, which
# means that docs are not available on the first compile.
#
# We can circumvent this by writing a module's bytecode
# to the proper location after it is compiled.
@after_compile __MODULE__
def __after_compile__(_env, bytecode) do
__MODULE__
|> :code.which()
|> to_string()
|> File.write(bytecode)
end
end
end
@doc """
Delegates a function to another module, copying its docs.
Use exactly like `Kernel.defdelegate/2`.
"""
defmacro defdelegate(fun, opts) do
{options, _} = Code.eval_quoted(opts, [], __CALLER__)
{function_alias, _, args} = fun
module = options[:to]
function = options[:as] || function_alias
signature = {function, length(args)}
doc_str = get_doc(module, signature)
specs = get_specs(module, signature, function_alias)
quote location: :keep do
@doc unquote(doc_str)
unquote(specs)
Kernel.defdelegate(unquote(fun), unquote(opts))
end
end
@doc """
Get the doc string for a given module and function.
## Example
DelegateWithDocs.get_doc(MyModule.Internal, {:my_func, 2})
"""
@spec get_doc(module, {atom, integer}) :: String.t() | nil
def get_doc(module, {function, arity}) do
assert_module_exists!(module)
module
|> Code.get_docs(:docs)
|> Enum.flat_map(&function_docs({function, arity}, &1))
|> List.first()
end
defp function_docs({function, arity}, {{function, arity}, _line, _type, _vars, doc}) do
[doc]
end
defp function_docs(_function, _docs) do
[]
end
@doc """
Get the typespecs for a given function as an AST.
"""
def get_specs(module, {function, arity}, function_alias \\ nil) do
function_alias = function_alias || function
assert_module_exists!(module)
module
|> Typespec.beam_specs()
|> Enum.into(%{})
|> Map.get({function, arity})
|> Enum.map(&Typespec.spec_to_ast(function, &1))
|> Enum.map(&rename_function(&1, function_alias))
|> Enum.map(&remove_line_numbers/1)
|> Enum.map(fn ast ->
quote do
@spec unquote(ast)
end
end)
end
# Line numbers must be recursively stripped out of the spec AST
# to prevent errors when we inject the spec into the delegating
# module
defp remove_line_numbers(ast, acc) when ast in [[], nil], do: Enum.reverse(acc)
defp remove_line_numbers([ast | tail], acc) do
remove_line_numbers(tail, [remove_line_numbers(ast) | acc])
end
defp remove_line_numbers({ast, context, args}) when is_tuple(ast) do
{remove_line_numbers(ast), Keyword.drop(context, [:line]), remove_line_numbers(args, [])}
end
defp remove_line_numbers({func, context, args}) when is_list(context) do
{func, Keyword.drop(context, [:line]), remove_line_numbers(args, [])}
end
defp remove_line_numbers({func, line, args}) when is_integer(line) do
{func, [], remove_line_numbers(args, [])}
end
defp remove_line_numbers(other), do: other
# Renames the spec to the alias
defp rename_function({:::, sc, [{_function, fc, fargs}, return]}, function_alias) do
{:::, sc, [{function_alias, fc, fargs}, return]}
end
defp rename_function(ast, _as), do: ast
defp assert_module_exists!(module) do
unless Code.ensure_compiled?(module),
do: raise(Error, "Module #{inspect(module)} is not defined/available")
unless Code.get_docs(module, :docs) do
raise(Error, """
Module #{inspect(module)} was not compiled with docs.
You must `use DelegateWithDocs` within #{inspect(module)} to ensure
that its docs are available to other modules at compile time.
""")
end
end
end