State Machine DSL Example
View SourceThis example demonstrates a DSL for defining state machines with multi-span error messages that show related code locations.
Use Case
You're building a state machine DSL and want to:
- Validate that transitions reference defined states
- Show both the error location and the similar state definition
- Provide helpful suggestions for typos
Example Error Output

Usage
defmodule TrafficLight do
use Pentiment.Examples.StateMachine
defstate :green
defstate :yellow
defstate :red
deftransition :change, from: :green, to: :yellow
deftransition :change, from: :yellow, to: :red
deftransition :change, from: :red, to: :green
end
# Runtime API
TrafficLight.states() # [:green, :yellow, :red]
TrafficLight.can_transition?(:green, :change) # true
TrafficLight.transition(:green, :change) # {:ok, :yellow}Implementation
The full implementation is in test/support/examples/state_machine.ex.
Key Points
1. Track definitions with metadata
defmacro defstate(name) do
caller = __CALLER__
quote do
@sm_states {
unquote(name),
%{
file: unquote(caller.file),
line: unquote(caller.line),
column: 1
}
}
end
end2. Validate in __before_compile__
defmacro __before_compile__(env) do
states = Module.get_attribute(env.module, :sm_states) || []
transitions = Module.get_attribute(env.module, :sm_transitions) || []
state_names = Enum.map(states, fn {name, _meta} -> name end)
errors =
transitions
|> Enum.flat_map(fn {_event, from, to, trans_meta} ->
undefined_refs = []
undefined_refs = if from not in state_names, do: [{:from, from, trans_meta} | undefined_refs], else: undefined_refs
undefined_refs = if to not in state_names, do: [{:to, to, trans_meta} | undefined_refs], else: undefined_refs
undefined_refs
end)
if errors != [] do
reports = Enum.map(errors, fn error ->
build_undefined_state_error(error, states, env.file)
end)
source = Pentiment.Elixir.source_from_env(env)
formatted = Pentiment.format_all(reports, source, colors: false)
raise CompileError, description: formatted
end
# ... generate runtime code
end3. Build multi-span errors
defp build_undefined_state_error(field, undefined_state, trans_meta, states, _file) do
state_names = Enum.map(states, fn {name, _} -> name end)
similar = find_similar(undefined_state, state_names)
trans_span = Pentiment.Span.position(trans_meta.line, trans_meta.column)
report =
Pentiment.Report.error("Transition references undefined state `#{undefined_state}`")
|> Pentiment.Report.with_code("SM001")
|> Pentiment.Report.with_source(trans_meta.file)
|> Pentiment.Report.with_label(Pentiment.Label.primary(trans_span, "undefined state"))
report =
if similar do
{_name, similar_meta} = Enum.find(states, fn {n, _} -> n == similar end)
def_span = Pentiment.Span.position(similar_meta.line, similar_meta.column)
report
|> Pentiment.Report.with_label(
Pentiment.Label.secondary(def_span, "did you mean this state?", source: similar_meta.file)
)
|> Pentiment.Report.with_help("change `#{field}: :#{undefined_state}` to `#{field}: :#{similar}`")
else
report
end
Pentiment.Report.with_note(report, "defined states are: #{Enum.join(state_names, ", ")}")
endKey Techniques
- Module attributes: Accumulate definitions with
@sm_states @before_compile: Defer validation until all definitions are collected- Multiple labels: Use primary for the error, secondary for context
source:option: Labels can reference different source files
Cross-file Output
When the similar state is defined in a different file, the source:
label renders as a continuation frame against its own file:
error[SM001]: Transition references undefined state `runing`
╭─[lib/my_app/worker_fsm.ex:4:42]
│
2 │ use Pentiment.Examples.StateMachine
3 │
4 │ deftransition :start, from: :idle, to: :runing
• ───┬───
• ╰── undefined state
5 │ end
│
├─[lib/my_app/states.ex:5:12]
│
3 │
4 │ defstate :idle
5 │ defstate :running
• ───┬────
• ╰── did you mean this state?
6 │ defstate :done
7 │ end
│
╰─────
note: defined states are: idle, running, done
help: change `to: :runing` to `to: :running`For the foreign file's excerpt to render, the sources you pass to
Pentiment.format/2,3 must include an entry for every label's
source: — pass a map (%{file_a => content_a, file_b => content_b}).
With a single Pentiment.Source (or a bare file path), groups whose
file does not match render header-only: ├─[file:line:col] with no
excerpt, which still names the location.
Testing
test "undefined state raises CompileError with multi-span error" do
code = """
defmodule TestStateMachine do
use Pentiment.Examples.StateMachine
defstate :green
deftransition :change, from: :green, to: :yello
end
"""
error = assert_raise CompileError, fn -> Code.compile_string(code) end
assert error.description =~ "undefined state"
assert error.description =~ "change `to: :yello` to `to: :yellow`"
end