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¶
- Build a
MenuDSL withinstance_eval:Menu.build do section "Drinks" do item "Coffee", 3.0 end end, wheresectionnests by callinginstance_evalon the same object,totalsums the prices, andto_sprints an aligned menu. - Rewrite
Config.buildso 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'sselfwithblk.binding.receiverand forwarding unknown names to it frommethod_missing. What doesrespond_to_missing?have to say now, and what happens to theKernel#pcollision 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.
See also¶
instance_evalmovesself,class_evalmovesdef— the mechanism behind every bare word heremethod_missingneedsrespond_to_missing?— the open-ended keys of row 3 and the trap of row 13- Hooks fire when code is defined, not when it runs — class-level options as a DSL, and Python's
__init_subclass__ - Blocks are not objects — what
&blkcaptures so a router can store it - Procs and lambdas differ — the
aritythat row 8's builder checks BasicObjectandKernel— the escape from row 13'sKernel#pcollision- Rust: three kinds of procedural macro ↗ — the language whose DSLs are built at compile time, from tokens rather than from
self