Friday, February 15, 2008

Implementing an internal DSL in Ruby

On Christmas Day 2007 I released the 0.0.1 version of expectations. Expectations is the result of several months of designing what I wanted the Domain Specific Language (DSL) to look like, and a few days of making it executable. To design the DSL, I created what I was looking for without worrying about implementation. This entry is going to focus on what it took to take the DSL from looking good to actually executing.

The following code is an example of what I decided I wanted the DSL to look like (for a state based test, expectations also has support for behavior based testing).

Expectations do
expect :expected do
:expected
end
end

When making a DSL executable I generally begin by building up objects with the values expressed in the DSL. For example, the above code creates a test suite containing 1 expectation where the expected value is :expected and the actual value can be determined by evaluating the block. While designing expectations I tested it using expectations, but that's probably more complex than is necessary for this example, so I'll simply print values instead.

The first step is ensuring that an Expectations::Suite is created when Expectations is called with a block. The following code demonstrates that an Expectations::Suite is created successfully. The code executes since the block given to Expectations is never executed.

require 'singleton'
module Expectations; end

class Expectations::Suite
include Singleton
end

def Expectations(&block)
Expectations::Suite.instance
end

Expectations do
expect :expected do
:expected
end
end

Expectations::Suite.instance # => #<Expectations::Suite:0x1d4fc>

note: Expectations::Suite is a singleton so that it can be appended to multiple times and later used for execution without being stored within another object.

The next step is to verify the number of expectations the suite stores for later execution. The following code verifies that one object is stored in the expectations array.

require 'singleton'
module Expectations; end

class Expectations::Suite
include Singleton
attr_accessor :expectations

def initialize
self.expectations = []
end

def expect(expected, &actual)
expectations << :expectation
end

end

def Expectations(&block)
Expectations::Suite.instance.instance_eval(&block)
end

Expectations do
expect :expected do
:expected
end
end

Expectations::Suite.instance # => #<Expectations::Suite:0x1c82c @expectations=[:expectation]>
Expectations::Suite.instance.expectations.size # => 1

The final step is to create an object for each expectation and verify the values of both the expected and the actual.

require 'singleton'
module Expectations; end

class Expectations::Suite
include Singleton
attr_accessor :expectations

def initialize
self.expectations = []
end

def expect(expected, &actual)
expectations << Expectations::Expectation.new(expected, actual)
end

end

class Expectations::Expectation
attr_accessor :expected, :actual

def initialize(expected, actual)
self.expected, self.actual = expected, actual.call
end
end

def Expectations(&block)
Expectations::Suite.instance.instance_eval(&block)
end

Expectations do
expect :expected do
:expected
end
end

Expectations::Suite.instance # => #<Expectations::Suite:0x1b184 @expectations=[#<Expectations::Expectation:0x1b0e4 @expected=:expected, @actual=:expected>]>
Expectations::Suite.instance.expectations.size # => 1
Expectations::Suite.instance.expectations.first.expected # => :expected
Expectations::Suite.instance.expectations.first.actual # => :expected

note: expectations actually defers determining the value of actual until the suite is executed, but that's outside the scope of this example.

The bottom two lines of the last example show that the Expectations::Expectation instance is being populated successfully.

That's it, at this point the DSL can successfully be parsed. Of course, making the expectations run to verify that the values match still needs to be done, but that's no different than working with any plain old ruby code. The DSL can be evaluated and the framework can work with the objects that have been created.

As the example demonstrates, designing an internal DSL in Ruby is very easy largely due to open classes, class methods, and the ability to eval. The above code for evaluating the DSL is less than 30 lines of code.
Post a Comment