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
Keep public macros in the host project.
- Good:
MyApp.Config.project/2,MyApp.Config.route/2. - Avoid exposing
DSL.start/4orDSL.Stackdirectly to end users.
- Good:
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.
- Define
Use
DSLmodules for declaration state only.use DSLin an internal scope module such asMyApp.Config.Scope.- Public macros call that scope module.
Validate options at macro/runtime boundaries.
- Declare schemas with
options :name do ... end. - Call generated
validate_name_opts!/2before building domain structs.
- Declare schemas with
Preserve source locations for diagnostics.
- In a macro before
quote, useDSL.Source.escape_caller(__CALLER__). - Outside quoted code, use
DSL.Source.from_caller(__CALLER__). - Pass as
location: sourcetovalidate_*_opts!/2.
- In a macro before
Prefer attachments over manual parent lookup.
- Let
acceptsdescribe which children a scope can receive. - Use
attach(child_name, child)or generatedattach_*helpers.
- Let
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
endPublic 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
endScopes
Use scope for nested block state:
scope :route do
requires :router
accepts :plug
endGenerated 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: trueThis 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 menuOptions
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
endGuidelines:
- Prefer atom-keyed input in examples, but accept string-keyed maps when external data can reach the boundary.
- Use
:atomonly when values must already be atoms; it does not create atoms from strings. - Use
in: [...]for finite atom/enumeration options. - Use
return: :keywordonly for short-lived downstream keyword options. - Remember
return: :keywordomits 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:
- Add tests for generated helper behavior and public macro behavior.
- Test invalid nesting and invalid options; assert the error message.
- Test source-aware diagnostics when macros pass
DSL.Source. - 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.