lib/diesel.ex

defmodule Diesel do
  @moduledoc """
  Declarative programming in Elixir

  Diesel is a toolkit that helps you build your own DSLs.

  Usage:

  ```elixir
  defmodule MyApp.Fsm do
    use Diesel,
      otp_app: :my_app,
      dsl: MyApp.Fsm.Dsl,
      parsers: [
        ...
      ],
      generators: [
        ...
      ]
  end
  ```


  For more information on how to use this library, please check:

  * the `Diesel.Dsl` and `Diesel.Tag` modules,
  * the guides and tutorials provided in the documentation
  * the examples used in tests
  """

  @type tag() :: atom()
  @type element() :: {tag(), keyword(), [element()]}

  @doc "Returns the raw definition for the dsl, before compilation"
  @callback definition() :: element()

  @doc """
  Compiles the raw definition and returns a compiled version of it

  The obtained structure is the result of applying the configured list of parsers to the raw
  internal definition and then compiling it according to the rules implemented by packages.
  """
  @callback compile(context :: map()) :: term()

  alias Diesel.Parser
  import Diesel.Naming

  defmacro __using__(opts) do
    otp_app = Keyword.fetch!(opts, :otp_app)
    mod = __CALLER__.module
    dsl = opts |> Keyword.fetch!(:dsl) |> module_name()
    overrides = Keyword.get(opts, :overrides, [])
    compilation_flags = Keyword.get(opts, :compilation_flags, [])

    generators =
      Keyword.get(opts, :generators, []) ++
        (otp_app
         |> Application.get_env(mod, [])
         |> Keyword.get(:generators, []))

    generators = Enum.map(generators, &module_name/1)

    parsers =
      Keyword.get(opts, :parsers, []) ++
        (otp_app
         |> Application.get_env(mod, [])
         |> Keyword.get(:parsers, []))

    parsers = Enum.map(compilation_flags, &Parser.named/1) ++ parsers

    parsers = Enum.map(parsers, &module_name/1)

    quote do
      @otp_app unquote(otp_app)
      @dsl unquote(dsl)
      @overrides unquote(overrides)
      @mod unquote(mod)
      @parsers unquote(parsers)
      @generators unquote(generators)

      defmacro __using__(_) do
        mod = __CALLER__.module

        quote do
          @behaviour Diesel
          @otp_app unquote(@otp_app)
          @dsl unquote(@dsl)
          @parsers unquote(@parsers)
          @generators unquote(@generators)
          @root @dsl.root()
          import Kernel, except: unquote(@overrides)
          import unquote(@dsl), only: :macros
          @before_compile unquote(@mod)

          @compilation_context @otp_app
                               |> Application.compile_env(__MODULE__, [])
                               |> Keyword.get(:compilation_context, %{})

          @impl Diesel
          def compile(ctx \\ %{}) do
            ctx = Map.merge(@compilation_context, Map.new(ctx))

            @parsers
            |> Enum.reduce(definition(), & &1.parse(__MODULE__, &2))
            |> @dsl.compile(ctx)
          end
        end
      end

      defmacro __before_compile__(_env) do
        mod = __CALLER__.module
        compilation_context = Module.get_attribute(mod, :compilation_context)
        dsl = Module.get_attribute(mod, :dsl)
        definition = Module.get_attribute(mod, :definition)
        parsers = Module.get_attribute(mod, :parsers)
        generators = Module.get_attribute(mod, :generators)

        Diesel.Dsl.validate!(dsl, definition)

        definition = Enum.reduce(parsers, definition, & &1.parse(mod, &2))

        generated_code =
          generators
          |> Enum.flat_map(&[&1.generate(mod, definition)])
          |> Enum.reject(&is_nil/1)

        [definition_ast() | generated_code]
      end

      defp definition_ast do
        quote do
          @impl Diesel
          def definition, do: @definition
        end
      end
    end
  end

  @doc "Returns all children elements matching the given tag"
  @spec children(element(), tag()) :: [element()]
  def children({_, _, children}, name) when is_list(children), do: elements(children, name)

  @doc "Returns all elements matching the given name"
  @spec elements([element()], tag()) :: [element()]
  def elements(elements, name) when is_list(elements),
    do: for({^name, _, _} = element <- elements, do: element)

  @doc "Returns the first child element matching the given name, from the given definition"
  @spec child(element(), tag()) :: element() | nil
  def child({_, _, _} = element, name) do
    element
    |> children(name)
    |> List.first()
  end

  @doc "Returns the first child of the given element, or list of elements"
  @spec child(element() | [element()]) :: any()
  def child({_, _, [child | _]}), do: child
  def child(nodes) when is_list(nodes), do: Enum.map(nodes, &child/1)
  def child(nil), do: nil
end