# DSL
Composable building blocks for Elixir-native DSLs.
DSL is a small library for building project-specific Elixir DSLs without forcing a framework shape. It gives you primitives for nested scopes, source-aware diagnostics, parent/child attachments, process-local settings, Ecto-backed option validation, and public macro wrapper generation.
## Installation
```elixir
def deps do
[
{:dsl, "~> 0.1"}
]
end
```
## When to use it
Use DSL when you want a human-shaped Elixir DSL such as:
```elixir
project :docs do
setting :environment, :prod
page "/", title: "Home" do
component :hero
component :features
end
end
```
and you want reusable plumbing for:
- stack-safe nested blocks
- generated `push_*`, `pop_*`, `current_*`, and `*_active?` helpers
- readable errors when directives are used outside the right block
- attaching child declarations to the nearest accepting parent
- validating keyword/map options with Ecto changesets
- preserving caller source locations for diagnostics
DSL does not define your public syntax. Your project owns the user-facing macros and domain structs; DSL only provides the reusable substrate.
## Example
Define your DSL internals:
```elixir
defmodule SiteDSL.Page do
defstruct path: nil, title: nil, draft?: false, components: []
def add_component(page, component) do
%{page | components: page.components ++ [component]}
end
end
defmodule SiteDSL.Scope do
use DSL
alias SiteDSL.Page
setting :environment, default: :dev
options :page_opts do
field :title, :string, required: true
field :draft, :boolean, default: false
end
scope :project do
accepts :page, into: :pages
end
scope :page do
accepts :component
requires :project
end
def start_page(path, opts, source) do
opts = validate_page_opts!(opts, location: source)
push_page(%Page{path: path, title: opts.title})
end
end
```
Wrap it with public macros:
```elixir
defmodule SiteDSL do
defmacro project(name, do: block) do
quote do
SiteDSL.Scope.push_project(%{name: unquote(name), pages: []})
unquote(block)
SiteDSL.Scope.pop_project()
end
end
defmacro page(path, opts \\ [], do: block) do
source = DSL.Source.escape_caller(__CALLER__)
quote do
SiteDSL.Scope.start_page(unquote(path), unquote(opts), unquote(source))
unquote(block)
SiteDSL.Scope.attach_page(SiteDSL.Scope.pop_page())
end
end
defmacro component(name) do
quote do
SiteDSL.Scope.attach(:component, unquote(name))
end
end
end
```
## Public macro wrappers
Use `DSL.Macros` when public DSL macros only wrap runtime calls:
```elixir
defmodule SiteDSL do
use DSL.Macros
defdirective component(name) do
SiteDSL.Scope.attach(:component, name)
end
defblock page(path, opts \\ []), source: true do
start SiteDSL.Scope.start_page(path, opts, source)
finish SiteDSL.Scope.attach_page(SiteDSL.Scope.pop_page())
end
end
```
`defdirective/2` defines a macro that expands to one call. `defblock/3` defines the common start/block/finish shape. Use `source: true` when the start or finish expression needs caller source metadata.
Keep hand-written macros for conditional syntax, macro composition, or domain-heavy expansion.
## Scopes
Declare scopes with `scope/1`, `scope/2`, or `scope/3`:
```elixir
scope :page do
requires :project
accepts :component
end
```
Generated helpers include:
```elixir
push_page(state)
pop_page()
current_page()
current_page!()
current_page_scope!()
update_page(fun)
page_active?()
attach_page(value)
```
Boolean/value scopes can generate start/finish helpers:
```elixir
scope :transaction, value: true
start_transaction()
finish_transaction()
```
You can suppress generated helpers when a module needs a smaller surface:
```elixir
scope :partial, current: false, update: false
```
## Attachments
A scope can accept child declarations:
```elixir
scope :page do
accepts :component
end
```
By default, `accepts :component` calls `Page.add_component(parent, child)` on the parent struct module.
Other attachment strategies are available:
```elixir
accepts :component, into: :components
accepts :component, via: :put_component
accepts :component, via: {MyBuilder, :add_component}
```
At runtime, `attach/2` or `DSL.attach/3` updates the nearest active scope that accepts the child.
## Options
Option schemas use an Ecto-shaped `field/3` DSL and validate with schemaless `Ecto.Changeset` internally:
```elixir
options :route_opts do
field :method, :atom, required: true, in: [:get, :post]
field :path, :string, required: true
field :private, :boolean, default: false
end
```
Generated validators:
```elixir
validate_route_opts(opts)
validate_route_opts!(opts)
```
Validation accepts atom or string keys, rejects unknown options, applies defaults, validates required fields, and returns a map by default.
Use `return: :keyword` when the result should be passed downstream as keyword options. Nil optional values are omitted from keyword output:
```elixir
options :command_opts, return: :keyword do
field :timeout, :integer
end
```
Pass source locations for better diagnostics:
```elixir
source = DSL.Source.from_caller(__CALLER__)
validate_route_opts!(opts, location: source)
```
Inside quoted macros, use:
```elixir
source = DSL.Source.escape_caller(__CALLER__)
```
## Settings
Settings are process-local ambient state namespaced to the declaring module:
```elixir
setting :environment, default: :dev
environment()
put_environment(:prod)
reset_environment()
```
Use settings for ambient DSL configuration, not for nested block state.
## Design notes
- Keep public DSL macros in your project modules.
- Keep domain data in your project structs.
- Use DSL scopes for process-local declaration state.
- Use `options` at macro boundaries before constructing domain structs.
- Use `DSL.Source` for diagnostics, not for domain metadata unless that is explicitly part of your API.