Introduction
If you’ve written Ruby, you’ve already used a DSL.
RSpec.describe ... do, task :build do, Sinatra’s get "/" do all look
like small purpose-built languages. Under the hood they’re ordinary Ruby
method calls. That’s what an internal DSL is: Ruby arranged so it reads like
a language of its own.
Let’s build one. Our goal is a small language for describing quizzes:
question "Which language powers Rails?" do
wrong "Python"
correct "Ruby"
wrong "PHP"
end
There’s no parser and no grammar here. Every line is a Ruby method call, and the whole job is deciding which objects those methods live on.
The naive first cut
The quickest way to make those calls work is to define them as top-level methods:
def question(text) = puts "Q: #{text}"
def correct(text) = puts " [x] #{text}"
def wrong(text) = puts " [ ] #{text}"
With those defined, a quiz file is literally a Ruby script you load:
# quiz.rb
load "example.quiz"
# example.quiz — plain Ruby, despite the extension
question "Which language powers Rails?"
wrong "Python"
correct "Ruby"
wrong "PHP"
It runs, and it prints what you’d expect. With almost no code you have a working DSL.
It also has a real problem. A top-level def defines a private method on
Object, so question, correct and wrong now exist on every object in
the program. The DSL leaks into the whole runtime, and there’s no structure
to collect what the verbs produce. That’s tolerable for a throwaway script,
but I wouldn’t ship it.
A cleaner design: a builder and instance_eval
The fix is to give the verbs a home. Instead of methods on Object, they
become methods on a small builder object, and we run the DSL block in the
context of that object with instance_eval.
First, ordinary classes to hold the data:
class Answer
attr_reader :text
def initialize(text, correct)
@text = text
@correct = correct
end
def correct? = @correct
end
class Question
attr_reader :text, :answers
def initialize(text)
@text = text
@answers = []
end
def correct(text) = @answers << Answer.new(text, true)
def wrong(text) = @answers << Answer.new(text, false)
end
Then the Quiz builder. instance_eval runs a block with self set to the
receiver, so bare calls like question resolve to the builder’s methods:
class Quiz
attr_reader :questions
def self.build(&block)
quiz = new
quiz.instance_eval(&block)
quiz
end
def initialize
@questions = []
end
def question(text, &block)
new_question = Question.new(text)
new_question.instance_eval(&block)
@questions << new_question
end
end
Now the DSL nests cleanly, and nothing lands on Object:
quiz = Quiz.build do
question "Which language powers Rails?" do
wrong "Python"
correct "Ruby"
wrong "PHP"
end
end
quiz.questions.size # => 1
quiz.questions.first.answers.size # => 3
quiz.questions.first.answers.find(&:correct?).text # => "Ruby"
Two levels of instance_eval do all the work: the outer block runs against
the Quiz, the inner block against each Question. The verbs are scoped to
exactly the object that should understand them.
Passing data in with instance_exec
instance_eval changes self, but it can’t hand arguments to the block.
When you need both the DSL context and some outside value, reach for
instance_exec:
obj.instance_eval { self } # => obj
obj.instance_exec(1, 2) { |a, b| [self, a, b] } # => [obj, 1, 2]
That lets a builder feed external data into the block. Say the answers come from a hash somewhere else in your program:
class Quiz
def self.build(*args, &block)
quiz = new
quiz.instance_exec(*args, &block)
quiz
end
end
languages = { "Ruby" => true, "Python" => false, "PHP" => false }
quiz = Quiz.build(languages) do |langs|
question "Which language powers Rails?" do
langs.each { |name, ok| ok ? correct(name) : wrong(name) }
end
end
quiz.questions.first.answers.size # => 3
self is still the Quiz inside the outer block, so question works, but
now the block also receives langs.
Dynamic verbs with method_missing
Sometimes you don’t want to enumerate every verb up front. method_missing
catches calls that don’t match a defined method, which turns any name into a
DSL keyword. It’s the natural way to write a settings block:
class Config
attr_reader :settings
def initialize
@settings = {}
end
def method_missing(name, *args)
@settings[name] = args.first
end
def respond_to_missing?(name, include_private = false)
true
end
end
config = Config.new
config.instance_eval do
host "localhost"
port 3000
end
config.settings # => {host: "localhost", port: 3000}
host and port were never defined; method_missing turned them into
entries in the hash. Always pair it with respond_to_missing? so
respond_to? stays honest about what the object answers to.
Isolation, and a warning
instance_eval exposes the whole receiver to the block, not just your
verbs. Inside Quiz.build, the block could also call questions, or any
method Quiz inherits from Object. That’s usually harmless, but it can
occasionally surprise you.
When you want the DSL to expose only its keywords, run the block against a
dedicated context object that defines nothing else. For near-total isolation,
subclass BasicObject, which starts almost bare and defines little beyond
what you add to it yourself.
The deeper caveat is security. An internal DSL is real Ruby with no sandbox.
load "example.quiz" will happily run whatever is in that file: open
sockets, delete files, anything. That’s the trade for all the power: never
evaluate a DSL file you don’t trust.
You’ve seen this before
This is the same pattern you’ll find across the tools you use every day:
RSpec.describe Calculator do
it "adds two numbers" do
expect(add(1, 2)).to eq(3)
end
end
describe, it and expect are methods, run in a context object via
instance_eval. Rake’s task, Sinatra’s get, FactoryBot’s factory are
the same idea. Recognising the pattern makes those libraries far less
opaque, and puts the same technique in your own toolbox.
Wrapping up
An internal DSL in Ruby comes down to a few moves:
- verbs are just methods; the question is which object owns them.
instance_evalruns a block withselfset to that object, so the verbs resolve without a receiver.instance_execdoes the same but can pass arguments in.method_missing(withrespond_to_missing?) handles open-ended verbs.- a dedicated context object keeps the exposed surface small.
It’s very little code for a very expressive result. Just remember that the result is still Ruby, with all the reach that implies.
Have comments or want to discuss this topic?
Send an email to ~bounga/bounga.org-discuss@lists.sr.ht