Ruby DSL Blocks

Ruby DSL Blocks

Micah Martin
Micah Martin

May 20, 2007

There’s a common pattern I’ve seen for developing DSL in Ruby. It’s used in RSpec, the Statemachine Gem, and Unclebob’s Clean Code talk at RailsConf 2007. I haven’t seen a name for this pattern so I’ll call it the DSL Block Pattern.

RSpec

1describe "Bowling Game" do
2 it "should score 0 on a gutter game" do
3 game = Game.new
4 20.times { game.roll(0) }
5 game.score.should eql(0)
6 end
7end

Statemachine

1sm = Statemachine.build do
2 trans :locked, :coin, :unlocked
3 trans :locked, :pass, :locked
4 trans :unlocked, :pass, :locked
5 trans :unlocked, :coin, :unlocked
6end

Parser

1parser = Args.expect do
2 boolean "l"
3 number "p"
4 string "d"
5end

Here’s the problem. You’ve got to write code for specific domain such as writing specifications (RSpec), defining a Statemachine, or defining command line arguments (Unclebob’s Clean Code talk).

These domains have a contained and well defined terminology set. Often the cleanest, most elegant way to express this code is to create a DSL.

Before diving into the example, let me say that I like coffee as much as the next guy. But I feel lost when ever I go to a Starbucks. As you know, Starbucks has a it’s own language, DSL if you will, for ordering coffee. What follows is a DSL Block for ordering Starbucks coffee.

Starbucks cup
The general grammar for ordering coffee is: Size, Adjective (optional), Type of Coffee.

The general grammar for ordering coffee is: Size, Adjective (optional), Type of Coffee. This is by no means comprehensive but it’s sufficient for the example. So if you wanted to order a large coffee, for example, you would say, Grande Coffee.

A small espresso: Short Americano. An extra large mixture of regular and decaffeinated coffee with some half and half: Venti Breve Half Caff.

Given the task to code these coffee orders, I’d like to code it like this:

1Starbucks.order do
2 grande.coffee
3 short.americano
4 venti.breve.half_caff
5end

Ok that looks good, but as you look closely, you’ll start to wonder about those methods, grande, short, and venti “Do they have to be defined on the Kernel?” you may ask.

Defining them on the Kernel is a scary prospect. And that may convince you to clutter the syntax by passing an object into the block like this:

1Starbucks.order do |order|
2 order.grande.coffee
3 order.short.americano
4 order.venti.breve.half_caff
5end

This would allow you to define the grande, short, and venti methods on the object passed into the block. Although you do need an object where grande, short, and venti will be defined, you don’t need to add an argument to the block.

You’ll find code out there, such as migrations, that uses this less optimal route. It’s not necessary. The trick to get rid of the argument is below:

 1module Starbucks
 2
 3 def self.order(&block)
 4 order = Order.new
 5 order.instance_eval(&block)
 6 return order.drinks
 7 end
 8
 9 class Order
10
11 attr_reader :drinks
12
13 def initialize
14 @drinks = []
15 end
16
17 def short
18 @size = "small"
19 return self
20 end
21
22 def grande
23 @size = "large"
24 return self
25 end
26
27 def venti
28 @size = "extra large"
29 return self
30 end
31
32 def coffee
33 @drink = "coffee"
34 build_drink
35 end
36
37 def half_caff
38 @drink = "regular and decaffeinated coffee mixed together"
39 build_drink
40 end
41
42 def americano
43 @drink = "espresso"
44 build_drink
45 end
46
47 def breve
48 @adjective = "with half and half"
49 return self
50 end
51
52 private
53
54 def build_drink
55 drink = "#{@size} cup of #{@drink}"
56 drink << " #{@adjective}" if @adjective
57 @drinks << drink
58
59 @size = @drink = @adjective = nil
60 end
61 end
62
63end

You can see that the Order object is doing all the work. It’s got the responsibility of interpreting the DSL, so let’s call it the Interpreter Object. The Module::order method simply creates an instance of Order and calls instance_eval on it.

This causes the block to execute using the binding of the Order instance. All of the methods on Order will be accessible to the block.

The Interpreter Object can do any number of things as it interprets the DSL. In this case it simply generates a translation for Starbucks newbies. But, the sky’s the limit really.

Show all the source code.