Much of the Ruby ecosystem (Rails routes, RSpec, Gemfiles, Rake) is a block-based DSL; knowing the trick demystifies them.
An internal DSL is plain Ruby shaped to read like a configuration language. Rails routes, RSpec, Rake tasks, Gemfiles and Sinatra apps are all DSLs. They rely on three features you already know: optional parentheses, blocks, and changing self.
The key move is instance_eval(&block) (or instance_exec(args, &block)): it runs the caller's block with self set to a builder object, so bare method calls inside the block go to the builder:
Router.draw do
get "/users", to: "users#index"
endHere get is a method on the builder, not on the code that wrote the block.
The trade-off is that inside the block, self is no longer the caller's object, so the caller's instance variables and methods disappear (local variables still work, since the block is a closure). For that reason many libraries offer a second form that yields the builder explicitly, config do |c| c.port = 80 end, which keeps self unchanged. Supporting both is easy: check block.arity.
Design tips:
- Keep the DSL small and make each method do one obvious thing.
- Store what the block declares and act on it later; don't do heavy work during evaluation.
- Validate eagerly and raise clear errors naming the DSL keyword.
- Use
Module#refine(refinements, activated withusing) rather than monkey patching if the DSL needs extra methods on core classes such as5.minutes.
Example
class RouteSet
Route = Data.define(:verb, :path, :target)
def self.draw(&block)
set = new
block.arity == 1 ? block.call(set) : set.instance_eval(&block)
set.routes
end
attr_reader :routes
def initialize = @routes = []
%w[get post].each do |verb|
define_method(verb) { |path, to:| @routes << Route.new(verb.upcase, path, to) }
end
def resources(name) = [get("/#{name}", to: "#{name}#index"), post("/#{name}", to: "#{name}#create")]
end
prefix = "/api"
routes = RouteSet.draw do
get "#{prefix}/health", to: "health#show" # locals still visible
resources :users
end
routes.each { |r| puts "#{r.verb} #{r.path} -> #{r.target}" }
# GET /api/health -> health#show
# GET /users -> users#index
# POST /users -> users#createRemember
- instance_eval runs a block with self set to a builder object
- Inside such blocks, locals still work but the caller's methods and ivars don't
- Offer a yield-the-builder form when callers need their own self
Watch out for
- Referencing @ivars of the calling object inside an instance_eval block; they belong to the builder there.