defmodule Exaop do @moduledoc """ A minimal library for aspect-oriented programming. Unlike common AOP patterns, Exaop does not introduce any additional behavior to existing functions, as it may bring complexity and make the control flow obscured. Elixir developers prefer explicit over implicit, thus invoking the cross-cutting behavior by simply calling the plain old function generated by pointcut definitions is better than using some magic like module attributes and macros to decorate and weave a function. ## Examples Here's a simple example of using Exaop: defmodule Foo do use Exaop check :validity set :compute @impl true def check_validity(%{b: b} = _params, _args, _acc) do if b == 0 do {:error, :divide_by_zero} else :ok end end @impl true def set_compute(%{a: a, b: b} = _params, _args, acc) do Map.put(acc, :result, a / b) end end A function `__inject__/2` is generated in the above module `Foo`. When it is called, the callbacks are triggered in the order defined by your pointcut definitions. Throughout the execution of the pointcut callbacks, an accumulator is passed and updated after running each callback. The execution process may be halted by a return value of a callback. If the execution is not halted by any callback, the final accumulator value is returned by the `__inject__/2` function. Otherwise, the return value of the callback that terminates the entire execution process is returned. In the above example, the value of the accumulator is returned if the `check_validity` is passed: iex> params = %{a: 1, b: 2} iex> initial_acc = %{} iex> Foo.__inject__(params, initial_acc) %{result: 0.5} The halted error is returned if the execution is aborted: iex> params = %{a: 1, b: 0} iex> initial_acc = %{} iex> Foo.__inject__(params, initial_acc) {:error, :divide_by_zero} ## Pointcut definitions check :validity set :compute We've already seen the pointcut definitions in the example before. `check_validity/3` and `set_compute/3` are the pointcut callback functions required by these definitions. Additional arguments can be set: check :validity, some_option: true set :compute, {:a, :b} ## Pointcut callbacks ### Naming and arguments All types of callbacks have the same function signature. Each callback function following the naming convention in the example, using an underscore to connect the pointcut type and the following atom as the callback function name. Each callback has three arguments and each argument can be of any Elixir term. The first argument of the callback function is passed from the first argument of the caller `__inject__/2`. The argument remains unchanged in each callback during the execution process. The second argument of the callback function is passed from its pointcut definition, for example, `set :compute, :my_arg` passes `:my_arg` as the second argument of its callback function `set_compute/3`. The third argument is the accumulator. It is initialized as the second argument of the caller `__inject__/2`. The value of accumulator is updated or remains the same after each callback execution, depending on the types and the return values of the callback functions. ### Types and behaviours Each kind of pointcut has different impacts on the execution process and the accumulator. Exaop ships with three pointcut macros: * `check/3` * `set/3` * `preprocess/3` View documentation of these macros for details. """ @type acc :: map | struct @type args :: term @type params :: term defmacro __using__(_opts) do import Module, only: [register_attribute: 3] quote do register_attribute(__MODULE__, :exaop_callbacks, accumulate: true) @before_compile unquote(__MODULE__) @behaviour __MODULE__.ExaopBehaviour import unquote(__MODULE__) alias __MODULE__.ExaopBehaviour end end defmacro __before_compile__(%{module: module}) do all_callbacks = module |> Module.get_attribute(:exaop_callbacks) |> Enum.reverse() local_callbacks = Enum.filter(all_callbacks, fn {{^module, _, _}, _, _} -> true _ -> false end) [ compile_callbacks(local_callbacks), compile_injector(module, all_callbacks) ] end @doc """ Allows to decide whether to terminate the entire execution process of the generated `__inject__/2` function. It does not change the value of the accumulator. The execution of the generated function is halted if its callback return value matches the pattern `{:error, _}`. The execution continues if its callback returns `:ok`. """ defmacro check(target, args \\ nil, opts \\ []) do operate(:check, target, args, opts) end @doc """ Allows to update the value of the accumulator or halt the execution process. The execution of the generated function is halted if its callback return value matches the pattern `{:error, _}`. The accumulator is updated to the wrapped `acc` if its callback return value matches the pattern `{:ok, acc}`. """ defmacro preprocess(target, args \\ nil, opts \\ []) do operate(:preprocess, target, args, opts) end @doc """ Allows to update the value of the accumulator, setting the accumulator to the return value of its callback. It does not halt the execution process. """ defmacro set(target, args \\ nil, opts \\ []) do operate(:set, target, args, opts) end @doc false def mfa(target, args, caller, type, opts) do wrapped_args = [:params, args, :acc] target |> Atom.to_string() |> match_mfa(target, wrapped_args, caller, type, opts) end defp match_mfa("Elixir." <> _, target, args, _caller, type, _opts) do {target, type, args} end defp match_mfa("_" <> name, _target, args, caller, type, _opts) do function = :"_#{type}_#{name}" {caller, function, args} end defp match_mfa(name, _target, args, caller, type, _opts) do function = :"#{type}_#{name}" {caller, function, args} end @doc false def invalid_match_message(module, fun, op, invalid_match) do valid_ret = case op do :check -> ":ok or {:error, _}" :preprocess -> "{:ok, _acc} or {:error, _}" end "#{module}.#{fun}/3 expects #{valid_ret} as" <> " return values, got: #{inspect(invalid_match)}" end ## Helpers defp compile_callbacks(local_callbacks) do quote bind_quoted: [ local_callbacks: escape(local_callbacks) ] do defmodule ExaopBehaviour do @type acc :: Exaop.acc() @type args :: Exaop.args() @type params :: Exaop.params() Enum.each(local_callbacks, fn {{caller, fun, args}, op, _opts} -> case op do :set -> @callback unquote(fun)(params, args, acc) :: acc :preprocess -> @callback unquote(fun)(params, args, acc) :: {:ok, acc} | {:error, term} :check -> @callback unquote(fun)(params, args, acc) :: :ok | {:error, term} end end) end end end defp compile_injector(module, all_callbacks) do quote bind_quoted: [ all_callbacks: escape(all_callbacks), module: module ] do @exaop_callbacks_ordered all_callbacks @doc false def __inject__(params, initial_acc) do Enum.reduce_while(@exaop_callbacks_ordered, initial_acc, fn {{module, fun, [:params, args, :acc]}, op, _opts}, acc -> case {op, apply(module, fun, [params, args, acc])} do {:set, new_acc} -> {:cont, new_acc} {:preprocess, {:ok, new_acc}} -> {:cont, new_acc} {:preprocess, {:error, _} = error} -> {:halt, error} {:check, :ok} -> {:cont, acc} {:check, {:error, _} = error} -> {:halt, error} {op, invalid_match} when op in [:check, :preprocess] -> msg = invalid_match_message(module, fun, op, invalid_match) raise ArgumentError, msg end end) end @doc false def __exaop_callbacks__ do @exaop_callbacks_ordered end end end defp escape(expr) do Macro.escape(expr, unquote: true) end defp operate(op, target, args, opts) do quote bind_quoted: [ args: args, injector: __MODULE__, op: op, opts: opts, target: target ] do mfa = mfa(target, args, __MODULE__, op, opts) @exaop_callbacks {mfa, op, opts} end end end