name: elixir-dsl description: Use the DSL Elixir package to build project-specific, Elixir-native DSLs with scopes, attachments, settings, option validation, and source-aware diagnostics.


Building Elixir DSLs with DSL

Use this skill when adding or maintaining an Elixir library/application DSL that depends on the :dsl package.

Purpose

DSL is a substrate, not the public language. The host project should own user-facing macros, domain structs, and semantics. Use DSL for reusable mechanics:

  • process-local nested scopes
  • generated scope lifecycle helpers
  • scope requirement checks
  • parent/child attachment routing
  • process-local settings
  • Ecto-backed option schemas
  • caller source locations for diagnostics

Design rules

  1. Keep public macros in the host project.

    • Good: MyApp.Config.project/2, MyApp.Config.route/2.
    • Avoid exposing DSL.start/4 or DSL.Stack directly to end users.
  2. Keep domain data in structs owned by the host project.

    • Define %MyApp.Route{} or %MyApp.Page{}.
    • Do not pass loosely-shaped maps across module boundaries.
  3. Use DSL modules for declaration state only.

    • use DSL in an internal scope module such as MyApp.Config.Scope.
    • Public macros call that scope module.
  4. Validate options at macro/runtime boundaries.

    • Declare schemas with options :name do ... end.
    • Call generated validate_name_opts!/2 before building domain structs.
  5. Preserve source locations for diagnostics.

    • In a macro before quote, use DSL.Source.escape_caller(__CALLER__).
    • Outside quoted code, use DSL.Source.from_caller(__CALLER__).
    • Pass as location: source to validate_*_opts!/2.
  6. Prefer attachments over manual parent lookup.

    • Let accepts describe which children a scope can receive.
    • Use attach(child_name, child) or generated attach_* helpers.

Common implementation shape

Internal scope module:

defmodule MyApp.Config.Scope do
  use DSL

  alias MyApp.Config.Page

  setting :mode, default: :dev

  options :page_opts do
    field :title, :string, required: true
    field :draft, :boolean, default: false
  end

  scope :site do
    accepts :page, into: :pages
  end

  scope :page do
    requires :site
    accepts :component
  end

  def start_page(path, opts, source) do
    opts = validate_page_opts!(opts, location: source)
    push_page(%Page{path: path, title: opts.title, draft?: opts.draft})
  end
end

Public macros:

defmodule MyApp.Config do
  defmacro site(name, do: block) do
    quote do
      MyApp.Config.Scope.push_site(%{name: unquote(name), pages: []})
      unquote(block)
      MyApp.Config.Scope.pop_site()
    end
  end

  defmacro page(path, opts \\ [], do: block) do
    source = DSL.Source.escape_caller(__CALLER__)

    quote do
      MyApp.Config.Scope.start_page(unquote(path), unquote(opts), unquote(source))
      unquote(block)
      MyApp.Config.Scope.attach_page(MyApp.Config.Scope.pop_page())
    end
  end

  defmacro component(name) do
    quote do
      MyApp.Config.Scope.attach(:component, unquote(name))
    end
  end
end

Scopes

Use scope for nested block state:

scope :route do
  requires :router
  accepts :plug
end

Generated helpers include:

  • push_route(state)
  • pop_route()
  • current_route()
  • current_route!()
  • current_route_scope!()
  • update_route(fun)
  • route_active?()
  • attach_route(value)

For boolean/value scopes:

scope :transaction, value: true

This generates start_transaction/0 and finish_transaction/0 unless suppressed.

Attachments

Choose the smallest attachment strategy that fits the parent struct:

accepts :item                    # parent.__struct__.add_item(parent, item)
accepts :item, into: :items      # append to list field
accepts :item, via: :put_item    # parent.__struct__.put_item(parent, item)
accepts :item, via: {Mod, :fun}  # Mod.fun(parent, item)

If a child is used outside a valid parent, DSL raises readable errors such as:

item must be declared inside menu

Options

Use option schemas for public macro options:

options :route_opts, return: :keyword do
  field :method, :atom, required: true, in: [:get, :post]
  field :path, :string, required: true
  field :private, :boolean, default: false
end

Guidelines:

  • Prefer atom-keyed input in examples, but accept string-keyed maps when external data can reach the boundary.
  • Use :atom only when values must already be atoms; it does not create atoms from strings.
  • Use in: [...] for finite atom/enumeration options.
  • Use return: :keyword only for short-lived downstream keyword options.
  • Remember return: :keyword omits nil optional fields.

Settings

Use settings for ambient process-local configuration, not block nesting:

setting :default_provider, default: nil

default_provider()
put_default_provider(MyProvider)
reset_default_provider()

Verification

After changing a DSL built on this package:

  1. Add tests for generated helper behavior and public macro behavior.
  2. Test invalid nesting and invalid options; assert the error message.
  3. Test source-aware diagnostics when macros pass DSL.Source.
  4. Run the host project’s full validation gate.

Do not publish a host DSL change until downstream examples compile against the public macros, not internal DSL helpers.