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

def deps do
[
{:dsl, "~> 0.1"}
]
end

When to use it

Use DSL when you want a human-shaped Elixir DSL such as:

project :docs do
setting :environment, :prod
page "/", title: "Home" do
component :hero
component :features
end
end

and you want reusable plumbing for:

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:

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:

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:

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:

scope :page do
requires :project
accepts :component
end

Generated helpers include:

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:

scope :transaction, value: true
start_transaction()
finish_transaction()

You can suppress generated helpers when a module needs a smaller surface:

scope :partial, current: false, update: false

Attachments

A scope can accept child declarations:

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:

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:

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:

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:

options :command_opts, return: :keyword do
field :timeout, :integer
end

Pass source locations for better diagnostics:

source = DSL.Source.from_caller(__CALLER__)
validate_route_opts!(opts, location: source)

Inside quoted macros, use:

source = DSL.Source.escape_caller(__CALLER__)

Settings

Settings are process-local ambient state namespaced to the declaring module:

setting :environment, default: :dev
environment()
put_environment(:prod)
reset_environment()

Use settings for ambient DSL configuration, not for nested block state.

Design notes