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_eval runs a block with self set to that object, so the verbs resolve without a receiver.
  • instance_exec does the same but can pass arguments in.
  • method_missing (with respond_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