defmodule DSL do @moduledoc "Composable building blocks for Elixir-native DSLs." alias DSL.Attach alias DSL.Options.Builder, as: OptionsBuilder alias DSL.Scope.Builder, as: ScopeBuilder alias DSL.Stack @doc "Import DSL macros and install compile-time scope metadata." defmacro __using__(_opts) do quote do alias DSL, as: DSL import DSL, only: [options: 2, options: 3, scope: 1, scope: 2, scope: 3, setting: 1, setting: 2] Module.register_attribute(__MODULE__, :dsl_scopes, accumulate: true) Module.register_attribute(__MODULE__, :dsl_options, accumulate: true) def attach(child_name, child) when is_atom(child_name) do DSL.attach(__MODULE__, child_name, child) end def require_scope!(required, opts \\ []) when is_atom(required) do DSL.require_scope!(__MODULE__, required, opts) end @before_compile DSL end end defmacro __before_compile__(env) do scopes = Module.get_attribute(env.module, :dsl_scopes) |> Enum.reverse() options = Module.get_attribute(env.module, :dsl_options) |> Enum.reverse() quote do def __dsl_scope__(_name), do: :error def __dsl_scopes__, do: unquote(Macro.escape(scopes)) def __dsl_options__(_name), do: :error def __dsl_options__, do: unquote(Macro.escape(options)) end end @doc "Declare a named Ecto-style option schema." defmacro options(name, opts \\ [], do: block) when is_atom(name) and is_list(opts) do schema = OptionsBuilder.schema!(name, opts, block, __CALLER__) validate_fun = :"validate_#{name}" validate_bang_fun = :"validate_#{name}!" quote do @dsl_options unquote(Macro.escape(schema)) def __dsl_options__(unquote(name)), do: {:ok, unquote(Macro.escape(schema))} def unquote(validate_fun)(opts) do DSL.Options.validate(unquote(Macro.escape(schema)), opts) end def unquote(validate_bang_fun)(opts, validate_opts \\ []) do DSL.Options.validate!(unquote(Macro.escape(schema)), opts, validate_opts) end end end @doc "Declare a named process-local DSL setting." defmacro setting(name, opts \\ []) when is_atom(name) and is_list(opts) do caller_module = __CALLER__.module key = {caller_module, name} default = Keyword.get(opts, :default) get_fun = name put_fun = :"put_#{name}" reset_fun = :"reset_#{name}" core = __MODULE__ escaped_key = Macro.escape(key) escaped_default = Macro.escape(default) quote do def unquote(get_fun)() do unquote(core).get_setting(unquote(escaped_key), unquote(escaped_default)) end def unquote(put_fun)(value) do unquote(core).put_setting(unquote(escaped_key), value) end def unquote(reset_fun)() do unquote(core).reset_setting(unquote(escaped_key)) end end end @doc "Declare a named process-local DSL scope." defmacro scope(name, opts \\ [], block \\ []) when is_atom(name) and is_list(opts) do {scope, functions} = ScopeBuilder.build(name, opts, block, __CALLER__) quote do @dsl_scopes unquote(Macro.escape(scope)) def __dsl_scope__(unquote(name)), do: {:ok, unquote(Macro.escape(scope))} unquote_splicing(functions) end end @doc "Require an active scope by name for the calling DSL owner." def require_scope!(owner, required, opts \\ []) when is_atom(owner) and is_atom(required) do if active_scope?(owner, required) do :ok else scope = Keyword.get(opts, :for) raise ArgumentError, require_scope_message(required, scope) end end @doc "Require all active scopes by name for the calling DSL owner." def require_scopes!(_owner, _scope, []), do: :ok def require_scopes!(owner, scope, required) when is_atom(owner) and is_atom(scope) do Enum.each(required, &require_scope!(owner, &1, for: scope)) end defp active_scope?(owner, required) do Enum.any?(Stack.active_keys(owner), fn key -> elem(key, 1) == required end) end defp require_scope_message(required, nil) do "#{required} requires an active #{required} scope" end defp require_scope_message(required, scope) do "#{scope} must be declared inside #{required}" end @doc "Attach a child value to the nearest active accepting scope." defdelegate attach(owner, child_name, child), to: Attach @doc "Finish the active scope with a readable scope-name error." def finish_scope(key, name) when is_atom(name) do if active?(key) do finish(key, name) else raise ArgumentError, "no active #{name} scope" end end @doc "Return active scope state with a readable scope-name error." def current_scope_state!(key, name) when is_atom(name) do if active?(key) do current!(key) else raise ArgumentError, "no active #{name} scope" end end @doc "Update the active scope state with a readable directive error." def update_scope(key, name, fun) when is_atom(name) and is_function(fun, 1) do if active?(key) do update(key, fun) else raise ArgumentError, "#{name} directive used outside #{name} block" end end @doc "Return a process-local DSL setting or its default value." def get_setting(key, default \\ nil), do: Process.get(setting_key(key), default) @doc "Store a process-local DSL setting." def put_setting(key, value) do Process.put(setting_key(key), value) :ok end @doc "Reset a process-local DSL setting." def reset_setting(key) do Process.delete(setting_key(key)) :ok end defp setting_key(key), do: {__MODULE__, :setting, key} @doc "Start a named DSL scope." defdelegate start(key, name, state, location \\ nil), to: Stack @doc "Finish the active DSL scope." defdelegate finish(key, expected_name \\ nil), to: Stack @doc "Return true when the keyed scope is active." defdelegate active?(key), to: Stack @doc "Return the active scope state, or nil when inactive." defdelegate current(key), to: Stack @doc "Return the active scope state." defdelegate current!(key), to: Stack @doc "Return the active scope struct." defdelegate current_scope!(key), to: Stack @doc "Update the active scope state." defdelegate update(key, fun), to: Stack @doc "Reset a scope stack." defdelegate reset(key), to: Stack end