Skip to content

A DSL is instance_eval plus stored blocks

Level: 301 · for a reader who has written describe/it, task :build do, or get "/" do and wants to know what those words are

One line: A Ruby DSL is ordinary method calls made readable by three tricks — instance_eval so the block's bare words are methods of the object being built, blocks stored in a Hash so they can run later, and method_missing so any word can be a key — and the price is that the block no longer sees the caller's instance variables or methods; Python builds the same things out of a class body read by __init_subclass__, decorators, with, and keyword arguments.

Take Config.build do host "example.org"; port 80 end. Nothing here is syntax: host "example.org" is a call to a method named host with one argument, and it reads as configuration only because the receiver is implicit. instance_eval supplies that receiver — inside the block, self is the Config being built, so host finds Config#host. A database do ... end block nests by building a second Config the same way. Keys the class never declared can still work if method_missing stores them, which is what makes a configuration DSL open-ended. The second building block is a stored block: a router's get "/about" do ... end does not run the block, it files it under the path, and call("/about") runs it later with instance_exec, so a handler can use the router's own helpers. The trade-off follows directly from instance_eval: because self has moved, the block still sees the caller's local variables (it is a closure) but not the caller's @ivars or methods. The yielded style, Config.build { |c| c.host "x" }, keeps the caller's self and pays with a c. on every line; a builder can offer both by checking the block's arity. RSpec, Rake, Sinatra, Rails routing and the Gemfile are all this pattern (not machine-checked here).

Verified output of building_a_dsl_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. Config.build do host ...; port 80 end          Config(host: "example.org", port: 80, timeout: 30, database: Config(adapter: "pg", port: 5432))
 2. cfg.host                                       "example.org"
 3. timeout 30 -- an open-ended key (method_missing) 30
 4. database do adapter "pg" end -- nested         "pg"
 5. instance_eval block sees: local / @ivar / method 8080 / nil / NameError
 6. router.call("/about") -- a stored block        "about page (/about)"
 7. router.call("/") -- the block runs as the router "redirect to /home"
 8. Config.build { |c| ... } sees @ivar / method   "prod" / "helper"
 9. Kw.new(host: "x") -- keywords, no DSL          Kw(host: "x", port: 80)
10. Config.build { host "x" } -- validation        Config::Error: port is required
11. router.routes.keys -- insertion order          ["/", "/about"]
12. cfg.to_h                                       {host: "example.org", port: 80, timeout: 30, database: {adapter: "pg", port: 5432}}
13. Html.build { div { p "text" } } -- p is Kernel#p Kernel#p printed "text", built "<div></div>"
    Html.build { div { span "text" } }             "<div><span>text</span></div>"

Reading the output

Rows 1–4 build a Config and read it back: host and port are declared methods, timeout went through method_missing, and database nested a second builder. Row 5 is the cost of the pattern, measured: inside the instance_eval block, the local default_port from the calling method is visible (8080), but @env reads as nil — it is looked up on the Config, not on the Site — and calling helper is a NameError. Row 8 is the same block in the yielded style: @env and helper are back, because self never moved. Rows 6 and 7 are the routing DSL: the get blocks were stored, not run; call runs one with instance_exec, passing the path in and making redirect_to — a Router method — available inside the handler. Row 9 is the plainest alternative, which every DSL should be compared against: keyword arguments to new give the same readability for a flat configuration with no tricks. Row 10 is validation, which the builder does once the block has run. Rows 11 and 12 show what was built is ordinary data — a Hash of routes in insertion order, a Hash of settings. Row 13 is the trap every HTML-builder DSL hits: inside instance_eval a word first has to miss before method_missing sees it, and p does not miss — it is Kernel#p, so p "text" printed "text" to standard output and built an empty <div>, while span (no such method anywhere) reached the hook and worked. A DSL word that collides with Kernel — p, puts, print, select, test, format, open, system — silently does the wrong thing, which is why builders that need those words subclass BasicObject.

Compared with Python

Python has no way to move self, so its DSLs are shaped by the four places where Python already lets bare words carry meaning. The first is a class body: class Site(Config): host = "example.org"; port = 80 executes assignments into a namespace, and a parent's __init_subclass__ reads that namespace when the class is built (rows 1–4) — this is the Django-models and SQLAlchemy shape. It needs no method_missing: a new key is just another assignment (row 3), nesting is a nested class (row 4), and validation is an exception raised from the hook (row 10). Its scoping trap is different from Ruby's (row 5): a class body sees the enclosing function's locals and its own earlier names, but a def inside the body does not see the body's names — the well-known reason a method cannot refer to a class attribute without self. or the class name. The second place is a decorator: @app.route("/about") stores the function it decorates, which is exactly the stored-block idea (rows 6, 11), and since Python has no implicit receiver the handler takes app as a parameter (row 7). The third is a with block, which is the yielded style — it hands you an object, sees everything around it, and can validate on exit (row 8). The fourth is **kwargs (row 9), which both languages share and which is often enough. Row 13 has no Python counterpart to the Kernel#p collision: assigning print = "text" in a class body merely shadows the builtin inside that body, because there is no method-missing layer for a word to fall through.

Verified output of building_a_dsl_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. class Site(Config): host = ...; port = 80      {'host': 'example.org', 'port': 80, 'timeout': 30, 'database': {'adapter': 'pg', 'port': 5432}}
 2. Site.host                                      'example.org'
 3. timeout = 30 -- no hook needed for a new key   30
 4. class database(Config): adapter = 'pg' -- nested 'pg'
 5. class body sees: local / earlier name / from a def 8080 / 8081 / NameError
 6. app.call("/about") -- a stored function        'about page (/about)'
 7. app.call("/") -- the app arrives as a parameter 'redirect to /home'
 8. with edit(cfg) as c: c.host = ... -- sees everything Cfg(host='example.org', port=80)
 9. Cfg(host="x", port=80) -- keywords, no DSL     Cfg(host='x', port=80)
10. class Broken(Config): host = 'x' -- validation Error: port is required
11. list(app.routes) -- insertion order            ['/', '/about']
12. sorted(vars(Site)) minus dunders               ['database', 'host', 'port', 'settings', 'timeout']
13. class Page(Config): print = 'text'             settings['print'] = 'text'; print still works
    no method_missing layer to collide with        -

Try it

  1. Build a Menu DSL with instance_eval: Menu.build do section "Drinks" do item "Coffee", 3.0 end end, where section nests by calling instance_eval on the same object, total sums the prices, and to_s prints an aligned menu.
  2. Rewrite Config.build so that the block can use both styles at once: bare words for the config's own keys and access to the caller's @ivars — by capturing the caller's self with blk.binding.receiver and forwarding unknown names to it from method_missing. What does respond_to_missing? have to say now, and what happens to the Kernel#p collision of row 13?
Solution to 1

building_a_dsl_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# building_a_dsl_kata_rb.rb — a menu DSL with sections, built with instance_eval.

class Menu
  Item = Data.define(:name, :price)

  def initialize = @lines = []

  def self.build(&blk) = new.tap { |menu| menu.instance_eval(&blk) }

  def section(title, &blk)
    @lines << title
    instance_eval(&blk)                                # nested block, same self
  end

  def item(name, price) = @lines << Item.new(name:, price:)

  def total = @lines.grep(Item).sum(&:price)

  def to_s
    @lines.map { |l| l.is_a?(Item) ? format("  %-8s %5.2f", l.name, l.price) : l }.join("\n")
  end
end

menu = Menu.build do
  section "Drinks" do
    item "Coffee", 3.0
    item "Tea", 2.5
  end
  section "Food" do
    item "Scone", 4.0
  end
end

puts menu
puts format("total:     %5.2f", menu.total)

Verified output of building_a_dsl_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

Drinks
  Coffee    3.00
  Tea       2.50
Food
  Scone     4.00
total:      9.50

See also