Skip to content

Instantly share code, notes, and snippets.

@mayel
Last active March 13, 2023 08:16
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save mayel/e82a73d09ae608fbea3308359bc9c3fe to your computer and use it in GitHub Desktop.
Save mayel/e82a73d09ae608fbea3308359bc9c3fe to your computer and use it in GitHub Desktop.
defmodule Module.Extend do
@doc """
Extend a module (i.e. define `defdelegate` and `defoverridable` for all functions from the source module in the current module.
Usage:
import Module.Extend
extend_module Common.Text
"""
defmacro extend_module(module) do
require Logger
module = Macro.expand(module, __CALLER__)
Logger.info("[Module.Extend] Extending module #{inspect(module)}")
functions = module.__info__(:functions)
signatures =
Enum.map(functions, fn {name, arity} ->
args =
if arity == 0 do
[]
else
Enum.map(1..arity, fn i ->
{String.to_atom(<<?x, ?A + i - 1>>), [], nil}
end)
end
{name, [], args}
end)
zipped = List.zip([signatures, functions])
for sig_func <- zipped do
quote do
Module.register_attribute(__MODULE__, :extend_module, persist: true, accumulate: false)
@extend_module unquote(module)
defdelegate unquote(elem(sig_func, 0)), to: unquote(module)
defoverridable unquote([elem(sig_func, 1)])
end
end
end
@doc """
Copy the code defining a function from its original module to one that extends it (or a manually specified module).
Usage: `Module.Extend.inject_function(Common.TextExtended, :blank?)`
"""
def inject_function(module, fun, target_module \\ nil) do
with {:ok, module_file} <- module_file(module),
orig_module when not is_nil(orig_module) <- target_module || List.first(module.__info__(:attributes)[:extend_module]) do
code = function_code(orig_module, fun)
IO.inspect(code, label: "Injecting the code from `#{orig_module}.#{fun}` into #{module_file}")
inject_before_final_end(module_file, code)
end
end
@doc "Return the path of code file for a module (in dev only)"
def module_file(module) when is_atom(module) do
{:ok, to_string(module.__info__(:compile)[:source])}
end
@doc "Return the code of a module (in dev only)"
def module_code(module) when is_atom(module) do
with {:ok, code_file} <- module_file(module) do
File.read(code_file)
end
end
def module_code(code_file) when is_binary(code_file) do
File.read(code_file)
end
def function_code(module, fun) do
with {:ok, code} <- module_code(module),
{first_line, last_line} <- function_line_numbers(module, fun) do
code
|> Common.Text.split_lines()
|> Enum.slice((first_line-1)..(last_line-1))
|> Enum.join("\n")
end
end
@doc "Return the numbers (as a tuple) of the first and last lines of a function's definition in a module"
def function_line_numbers(module, fun) when is_atom(module) do
with {:ok, code} <- module_file(module) do
function_line_numbers(code, fun)
end
end
def function_line_numbers(module_code, fun) when is_binary(module_code) do
module_code
|> Code.string_to_quoted!()
|> function_line_numbers(fun)
end
def function_line_numbers(module_ast, fun) do
module_ast
|> Macro.prewalk(nil, fn
result = {:def, [line: number], [{^fun, _, _} | _]}, acc -> {result, acc || number}
result = {:defp, [line: number], [{^fun, _, _} | _]}, acc -> {result, acc || number}
result = {:def, [line: number], [{:when, _, [{^fun, _, _} | _]} | _]}, acc -> {result, acc || number}
result = {:defp, [line: number], [{:when, _, [{^fun, _, _} | _]} | _]}, acc -> {result, acc || number}
other = {prefix, [line: number], _}, acc when prefix in [:def, :defp] ->
if acc do
throw {acc, number-1}
else
{other, acc}
end
other, acc ->
{other, acc}
end)
catch
numbers ->
numbers
end
defp inject_before_final_end(file_path, content_to_inject) do
file = File.read!(file_path)
if String.contains?(file, content_to_inject) do
:ok
else
Mix.shell().info([:green, "* injecting ", :reset, Path.relative_to_cwd(file_path)])
content =
file
|> String.trim_trailing()
|> String.trim_trailing("end")
|> Kernel.<>("\n" <> content_to_inject)
|> Kernel.<>("\nend\n")
formatted_content = Code.format_string!(content) |> IO.iodata_to_binary()
File.write!(file_path, formatted_content)
end
end
end
defmodule Common.Text do
def blank?(str_or_nil), do: "" == str_or_nil |> to_string() |> String.trim()
def split_lines(string) when is_binary(string),
do: :binary.split(string, ["\r", "\n", "\r\n"], [:global])
end
defmodule Common.TextExtended do
import Module.Extend
extend_module Common.Text
def blank?(str_or_nil \\ 1) do
require Logger
Logger.info("Check if #{str_or_nil} is considered blank")
# call function from original module:
super(str_or_nil)
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment