DSLs FTW!

I love programming in Ruby. It’s amazing how powerful and expressive the language can be. One of the best parts about Ruby is how easy it is to whip up a DSL (Domain Specific Language) to help clean up you code and hopefully make it more enjoyable (readable, understandable) to work with. I recently had the chance to cleanup some messy, hard to understand code with a really simple and easy to understand DSL and I thought it might be useful to share the basics doing something like this.

What We’re Working With

For this example lets say you’re writing some code that builds an html page. Lets say you have a class for each html element and a page is made up of a heiarchy of these html elements. The code to build a simple page might look something like this:

page = HTMLPage.new

form = FormElement.new(:id=>"new-user", :action=>"/user/new", 
  :parent=>page)
wrapper = DivElement.new(:class=>"wrapper", :parent=>form)

LabelElement.new(:text=>"Name", :parent=>wrapper)
InputElement.new(:name=>"name", :type=>"text", :parent=>wrapper)

InputElement.new(:text=>"Save", :type=>"submit", :parent=>form)

Okay, so you might not actually do something like this in reality but if you did and you were defining a bunch of pages and wanted to adjust each of them it would probably get confusing really fast. Even this simple example is a little hard to read as is…the Input is in the Form? Oh ya, it’s in the wrapper Div which is inside the Form. Confusing!

What will the DSL Look Like?

When I’m looking at some ugly code that I want to clean up I like to step back a little and write some code the way I want it to look and then work on making my DSL or whatever work towards that goal.

I would much rather be writing the stuff about in a way that looks something like this:

page do
  form "/user/new", :id=>"new-user" do
    div :class=>"wrapper" do
      label "Name"
      input "text", :name=>"name"
    end

    input "submit", :text=>"Save"
  end
end

To me something like that is a lot easier to follow, especially with something hierarchical like html or xml.

So that’s all well and good, but this looks pretty different than the code we had earlier, it might be a lot of work to build something to do this. Is it really work all that effort just so our code looks a little better? In all honesty it depends on the situation. If you have code like this on one little spot it might not be worth the effort. But writting a lot of unclear code all over the place then replacing it with a readable DSL will gain you many High Fives from future developers. Besides, it really isn’t that much work and DSLs are too cool to pass up, so lets get to it.

Building the DSL

Lets start out with something simple that will just define and return a couple of our elements.

class HTMLPageDSL
  def page(&block)
    page = HTMLPage.new
    yield
  end

  def form(options, &block)
    form = FormElement.new(options)
    yield
  end

  def div(options, &block)
    div = DivElement.new(options)
    yield
  end

  def input(options)
    InputElement.new(options)
  end

  def label(options)
    LabelElement.new(options)
  end
end

This little bit of code gives us the main structure of our DSL. It doesn’t quit work yet since there’s no way to set the parent attribute on each of the elements. Lets take care of that.

class HTMLPageDSL
  def page(&block)
    last = @current
    page = HTMLPage.new
    @current = page
    yield
    @current = last
  end

  def form(options, &block)
    last = @current
    form = FormElement.new(options.merge(:parent=>@current))
    @current = form
    yield
    @current = last
  end

  def div(options, &block)
    last = @current
    div = DivElement.new(options.merge(:parent=>@current))
    @current = div
    yield
    @current = last
  end

  def input(options)
    InputElement.new(options.merge(:parent=>@current))
  end

  def label(options)
    LabelElement.new(options.merge(:parent=>@current))
  end
end

Okay, wo what I’m doing here is just keeping track of whatever the current container element is and setting it to @current so that the elements being defined within the block can set it’s parent attribute. Lets take a look at what using our DSL might look like so far:

page do
  form :action=>"/user/new", :id=>"new-user" do
    div :class=>"wrapper" do
      label :text=>"Name"
      input :type=>"text", :name=>"name"
    end

    input :type=>"submit", :text=>"Save"
  end
end

That looks pretty close now! I have to be honest though, I cheated and made a little change so that this would be possible:

def page(&block)
  HTMLPageDSL.new(HTMLPage.new).instance_eval(&block)
end

class HTMLPageDSL
  def initialize(current)
    @current = current
  end
end

So I moved the page method outside of our DSL class and made it create an instance of HTMLPageDSL. We then call instance_eval giving the HTMLPageDSL object the block to evaluate.

The last thing I want to do is add a bit of syntax magic so we don’t have to type quite as much. At the moment we’re passing all the attributes as a hash, but in my initial example I could pass some options directly, without a hash.

First we add the following methods to HTMLPageDSL

class HTMLPageDSL
  private
    def setup_options(args, keys, current=nil)
      options = extract_options!(args)
      keys.each_with_index do |k, index|
        options.merge!(k.to_sym=>args[index]) if args[index]
      end
      current ? {:parent=>current}.merge(options) : options
    end

    # From Rails ActiveSupport
    def extract_options!(args)
      args.last.is_a?(::Hash) ? args.pop : {}
    end
end

After that we can use it in our DSL methods to let us pass array arguments or a hash to the method call.

class HTMLPageDSL
  def input(*args)
    options = setup_options(args, %w(type), @current)
    InputElement.new(options.merge(:parent=>@current))
  end
end

And that’s it. Now we can pass the type as the first argument to the call to input and any other options as a hash, or everything as a hash if we want.

Conclusion

So there we have it, a nice simple example of creating a DSL. This example isn’t perfect, that’s for sure, but it does show how to get started and how simple it can be.

Full Example Code:

def page(&block)
  HTMLPageDSL.new(HTMLPage.new).instance_eval(&block)
end

class HTMLPageDSL
  def initialize(current)
    @current = current
  end

  def form(*args, &block)
    options = setup_options(args, %w(action), @current)
    last = @current
    form = FormElement.new(options.merge(:parent=>@current))
    @current = form
    yield
    @current = last
  end

  def div(options, &block)
    last = @current
    div = DivElement.new(options.merge(:parent=>@current))
    @current = div
    yield
    @current = last
  end

  def input(*args)
    options = setup_options(args, %w(type), @current)
    InputElement.new(options.merge(:parent=>@current))
  end

  def label(*args)
    options = setup_options(args, %w(text), @current)
    LabelElement.new(options.merge(:parent=>@current))
  end

  private
    def setup_options(args, keys, current=nil)
      options = extract_options!(args)
      keys.each_with_index do |k, index|
        options.merge!(k.to_sym=>args[index]) if args[index]
      end
      current ? {:parent=>current}.merge(options) : options
    end

    # From Rails ActiveSupport
    def extract_options!(args)
      args.last.is_a?(::Hash) ? args.pop : {}
    end
end

# These classes added for completness so the example will run
class HTMLPage
  attr_accessor :children
  def initialize
    @children = []
  end
end

class HTMLElement
  def initialize(options={})
    @options = options
    options[:parent].children << self if options[:parent]
  end
end

class FormElement < HTMLElement
  attr_accessor :children
  def initialize(options={})
    @children = []
    super
  end
end
class DivElement < HTMLElement
  attr_accessor :children
  def initialize(options={})
    @children = []
    super
  end
end
class InputElement < HTMLElement; end
class LabelElement < HTMLElement; end

# Example Usage
p = page do
  form "/user/new", :id=>"new-user" do
    div :class=>"wrapper" do
      label "Name"
      input "text", :name=>"name"
    end

    input "submit", :text=>"Save"
  end
end
Posted May 30, 2010

Comments

comments powered by Disqus