lib/dsl/strategy.ex

# Copyright 2018 - 2022, Mathijs Saey, Vrije Universiteit Brussel

# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this
# file, You can obtain one at http://mozilla.org/MPL/2.0/.

defmodule Skitter.DSL.Strategy do
  @moduledoc """
  Strategy and Hook definition DSL.

  This module offers macros to define a strategy and its hooks. A strategy is defined through the
  use of the `defstrategy/3` macro. Inside the body of `defstrategy/3`, `defhook/2` can be used
  to define a hook. The other macros in this module can be used inside the body of `defhook/2` to
  obtain information from the current `t:Skitter.Strategy.context/0`. We recommend reading the
  documentation of `defstrategy/3` to get started.
  """
  alias Skitter.DSL.AST

  # -------- #
  # Strategy #
  # -------- #

  @doc """
  Define a strategy.

  This macro is used to define a strategy. A `Skitter.Strategy` is defined as a regular Elixir
  module which defines several _hooks_. As such, any code that is valid inside an Elixir module
  (such as function definitions or module attributes) is valid inside `defstrategy/3`.
  Additionally, the `defhook/2` macro may be used to define a Skitter _hook_. Hooks are special
  Elixir functions which accept a `t:Skitter.Strategy.context/0` argument.

  ## Extending Strategies

  Strategies can be created based on existing strategies. This is done by _extending_ some
  strategy. When a strategy extends another strategy, it will inherit all the hooks defined by the
  strategy it extends:

      iex> defstrategy Parent do
      ...>   defhook example, do: :example_hook
      ...> end
      iex> defstrategy Child, extends: Parent do
      ...> end
      iex> Child.example(%Context{})
      :example_hook

  Inherited hooks can be overridden:

      iex> defstrategy Parent do
      ...>   defhook example, do: :parent
      ...> end
      iex> defstrategy Child, extends: Parent do
      ...>   defhook example, do: :child
      ...> end
      iex> Child.example(%Context{})
      :child

  Finally, a strategy can extend multiple parent strategies. When this is done, the hooks of
  earlier parent strategies take precedence over later hooks:

      iex> defstrategy Parent1 do
      ...>   defhook example, do: :parent1
      ...> end
      iex> defstrategy Parent2 do
      ...>   defhook example, do: :parent2
      ...>   defhook another, do: :parent2
      ...> end
      iex> defstrategy Child, extends: [Parent1, Parent2] do
      ...> end
      iex> Child.example(%Context{})
      :parent1
      iex> Child.another(%Context{})
      :parent2
  """
  defmacro defstrategy(name, opts \\ [], do: body) do
    parents = opts |> Keyword.get(:extends, []) |> parse_parents()

    quote do
      defmodule unquote(name) do
        # Required for hook "inheritance"
        @_sk_parents unquote(parents)
        @before_compile {unquote(__MODULE__), :add_parent_hooks}
        @before_compile {unquote(__MODULE__), :store_modules}
        Module.register_attribute(__MODULE__, :_sk_hook, accumulate: true)

        import unquote(__MODULE__), only: [defhook: 2]

        unquote(body)
      end
    end
  end

  defp parse_parents(lst) when is_list(lst), do: lst
  defp parse_parents(any), do: [any]

  # ----- #
  # Hooks #
  # ----- #

  defp context_var, do: quote(do: var!(context, unquote(__MODULE__)))

  @doc """
  Obtain the context struct.

  A strategy hook is called with a `t:Skitter.Strategy.context/0` as its first argument. This
  macro is used to obtain this struct.

  ## Examples

      iex> defstrategy FullContext do
      ...>   defhook read, do: context()
      ...> end
      iex> FullContext.read(%Context{operation: SomeOperation})
      %Context{operation: SomeOperation}
  """
  defmacro context do
    quote do
      unquote(context_var())
    end
  end

  @doc """
  Obtain the context's operation.

  ## Examples

      iex> defstrategy ReadOperation do
      ...>   defhook read, do: operation()
      ...> end
      iex> ReadOperation.read(%Context{operation: SomeOperation})
      SomeOperation
  """
  defmacro operation, do: quote(do: context().operation)

  @doc """
  Obtain the context's strategy.

  ## Examples

      iex> defstrategy ReadStrategy do
      ...>   defhook read, do: strategy()
      ...> end
      iex> ReadStrategy.read(%Context{strategy: ReadStrategy})
      Skitter.DSL.StrategyTest.ReadStrategy
  """
  defmacro strategy, do: quote(do: context().strategy)

  @doc """
  Obtain the context's deployment.

  ## Examples

      iex> defstrategy ReadDeployment do
      ...>   defhook read, do: deployment()
      ...> end
      iex> ReadDeployment.read(%Context{deployment: :some_deployment_data})
      :some_deployment_data
  """
  defmacro deployment, do: quote(do: context().deployment)

  @doc """
  Define a hook.

  This macro defines a strategy hook. Hooks are functions called by the Skitter runtime system in
  response to predefined events (described in `Skitter.Strategy.Operation`), or by other hooks.
  Internally, hooks are plain Elixir functions which accept a `t:Skitter.Strategy.context/0` as
  their first argument. This macro generates a plain Elixir function which accepts such a context.

  Said otherwise, the following two definitions are equivalent:

  ```
  def my_hook(context, arg1, arg2), do: ...
  ```

  ```
  defhook my_hook(arg1, arg2), do: ...
  ```

  Using the `defhook/2` macro, however, ensures hooks can be inherited (as described in
  `defstrategy/3`), and offers access to the macros defined in `Skitter.DSL.Strategy.Helpers`,
  which provide the building blocks required to build a strategy. The `context/0`, `operation/0`,
  `strategy/0` and `deployment/0` macros can be used to obtain the information stored in the
  context the hook was called with.

  ## Calling hooks

  Since hooks are plain Elixir functions, they may be called like any other function. However, a
  `t:Skitter.Strategy.context/0` must be provided:

      iex> defstrategy Strategy do
      ...>   defhook hook, do: "hello"
      ...> end
      iex> Strategy.hook(%Context{})
      "hello"

  When a hook calls another hook (defined in another strategy or in the same strategy), it _must_
  use the `context/0` macro to pass the current context.

      iex> defstrategy S1 do
      ...>   defhook example, do: "world!"
      ...> end
      iex> defstrategy S2 do
      ...>   defhook example, do: "Hello, " <> S1.example(context())
      ...> end
      iex> S2.example(%Context{})
      "Hello, world!"

      iex> defstrategy Local do
      ...>   defhook left, do: "Hello, "
      ...>   defhook right, do: "world!"
      ...>   defhook example, do: left(context()) <> right(context())
      ...> end
      iex> Local.example(%Context{})
      "Hello, world!"

  Since the context contains the current strategy, it is possible to dynamically call the current
  strategy. However, the syntax for this is rather unwieldy:

      iex> defstrategy AbstractAnimal do
      ...>   defhook example, do: "Animal says: " <> strategy().say(context())
      ...> end
      iex> defstrategy Dog, extends: AbstractAnimal do
      ...>   defhook say, do: "woof!"
      ...> end
      iex> Dog.example(%Context{strategy: Dog})
      "Animal says: woof!"
      iex> defstrategy Cat, extends: AbstractAnimal do
      ...>   defhook say, do: "meow!"
      ...> end
      iex> Cat.example(%Context{strategy: Cat})
      "Animal says: meow!"

  Note that we have to explicitly pass the strategy as a part of the context in the examples
  above. In a real application, the Skitter Runtime calls hooks, ensuring the appropriate context
  is passed.
  """
  defmacro defhook(clause, do: body) do
    {name, args, guards} = AST.decompose_clause(clause)

    body =
      quote do
        use unquote(__MODULE__).Helpers, hook: unquote(name)

        import unquote(__MODULE__),
          only: [
            context: 0,
            operation: 0,
            strategy: 0,
            deployment: 0
          ]

        unquote(body)
      end

    gen_hook(name, args, guards, quote(do: __MODULE__), body)
  end

  # Generate a hook implementation, store the module that defined the hook
  defp gen_hook(name, args, guards, module, body) do
    quote do
      @doc false
      @_sk_hook {{unquote(name), unquote(length(args))}, unquote(module)}
      def unquote(AST.build_clause(name, [context_var() | args], guards)) do
        unquote(body)
      end
    end
  end

  # Create an AST that calls a hook in a module
  defp gen_hook_call(module, hook, args) do
    quote do
      unquote(module).unquote(hook)(unquote(context_var()), unquote_splicing(args))
    end
  end

  # Get the list of hooks defined in the module
  defp get_hooks(mod), do: mod |> Module.get_attribute(:_sk_hook, []) |> Enum.map(&elem(&1, 0))

  @doc false
  # Add all the parent hooks that are not present in the strategy module.
  # This is done by generating a hook which calls the hook of the module that defined the hook
  defmacro add_parent_hooks(env) do
    hooks = env.module |> get_hooks() |> MapSet.new()

    hooks =
      env.module
      |> Module.get_attribute(:_sk_parents)
      |> Enum.map_reduce(hooks, fn parent, hooks ->
        # Find the hooks not present in strategy which are present in parent
        to_add = parent._sk_hooks() |> MapSet.new() |> MapSet.difference(hooks)

        # Generate calls to the original module that defined the hook
        stubs =
          Enum.map(to_add, fn {hook, arity} ->
            module = parent._sk_hook_module(hook, arity)
            args = Macro.generate_arguments(arity, __MODULE__)
            gen_hook(hook, args, nil, module, gen_hook_call(module, hook, args))
          end)

        {quote(do: (unquote_splicing(stubs))), MapSet.union(hooks, to_add)}
      end)
      |> elem(0)

    quote do
      (unquote_splicing(hooks))
    end
  end

  @doc false
  # Store the original module of each hook. This is used by add_parent_hooks to generate a call to
  # the proper module when inheriting a hook.
  defmacro store_modules(env) do
    modules = env.module |> Module.get_attribute(:_sk_hook) |> Map.new() |> Macro.escape()
    names = env.module |> get_hooks() |> Macro.escape()

    quote bind_quoted: [modules: modules, names: names] do
      def _sk_hooks, do: unquote(names)

      for {{name, arity}, module} <- modules do
        def _sk_hook_module(unquote(name), unquote(arity)), do: unquote(module)
      end
    end
  end
end