The Artima Developer Community
Sponsored Link

Ruby Code & Style
Creating DSLs with Ruby
by Jim Freeze
March 16, 2006

<<  Page 2 of 4  >>

Advertisement

Third Time’s a Charm

When we were building our DSL for the stackup file we solved the problem three times. First, we wrote our own parser and decided it was too much work to maintain. Not only the code, but also the documentation. Since our DSL was sufficiently complicated, it wasn’t obvious how to use all its features and therefore it had to be copiously documented.

Next, for a short period, we implemented the DSL in XML. This removed the need for us to write our own parser, as XML is universally understood, but it contained too much noise and obscured the contents of the file. Our engineers found it too difficult to mentally task-switch between thinking about the meaning of the stackup and mentally parsing XML. For me, the lesson learned was that XML is not to be read by humans and probably a bad choice for a DSL, regardless of the parsing benefits.

Finally, we implemented the DSL in Ruby. The implementation was quick since Ruby provides the parsing. Documentation on the parser (i.e. Ruby) was not required since it is already available. And the final DSL was easily understood by humans, yet compact and versatile.

So, let’s build a DSL in Ruby that lets us define ‘parameter = value’ statements. Consider the following hypothetical DSL file.

% cat params_with_equal.dsl 
name = fred
parameter = .55

This is not valid Ruby code, so we need to modify the syntax slightly so Ruby accepts it. Let’s change it to:

% cat params_with_equal.dsl
name      = "fred" 
parameter = 0.55

Once we get the DSL to follow valid Ruby syntax, Ruby does all the work to parse the file and hold the data in a way that we can operate on it. Now let’s write some Ruby code to read this DSL.

First we want to encapsulate these parameters somehow. A good way is to put them into a class. We’ll call this class MyDSL.

% cat mydsl.rb
class MyDSL
  ...
end#class MyDSL

From the developer’s perspective, we want a simple and straightforward way to parse the DSL file. Something like:

my_dsl = MyDSL.load(filename)

So, let’s write the class method load :

def self.load(filename)
    dsl = new
    dsl.instance_eval(File.read(filename), filename)
    dsl
end

The class method load creates a MyDSL object and calls instance_eval on the DSL file (params_with_equal.dsl above). The second argument to instance_eval is optional and allows Ruby to report a filename on parse errors. An optional third argument (not shown) gives you the ability to provide a starting line number for parse error reporting.

Is this code going to work? Let’s see what happens:
% cat dsl-loader.rb
require 'mydsl'

my_dsl = MyDSL.load(ARGV.shift) # put the DSL filename on the command line
p my_dsl
p my_dsl.instance_variables
% ruby dsl-loader.rb params_with_equal.dsl
#<MyDSL:0x89cd8>
[]
What happened? Where did name and parameter go? Well, since name and parameter are on the left hand side of the equals sign, Ruby thinks they are local variables. We can tell Ruby otherwise by writing self.name = "fred" and self.parameter = 0.55 in the DSL file or we can impose upon the user to do this using the '@' symbol:
@name      = "fred" 
@parameter = 0.55

But that is kind of ugly and, to me, about the same as if we had written

$name      = "fred" 
$parameter = 0.55

Another way to let Ruby know the context of these methods is to declare the scope explicitly by yielding self (the MyDSL object instance) to a block. To do this, we will need to add a top level method to jump start our DSL and put the contents inside of the attached block. Our modified DSL now looks like:

% cat params_with_equal2.dsl
define_parameters do |p|
  p.name      = "fred" 
  p.parameter = 0.55
end

where we have defined define_parameters as an instance method:

% cat mydsl2.rb
class MyDSL
  def define_parameters
    yield self
  end

  def self.load(filename)
    dsl = new
    dsl.instance_eval(File.read(filename), filename)
    dsl
  end
end#class MyDSL

And we change the require in dsl-loader to use the new version of the MyDSL class in mydsl2.rb:

% cat dsl-loader.rb
require 'mydsl2'

my_dsl = MyDSL.load(ARGV.shift)
p my_dsl
p my_dsl.instance_variables

Theoretically, this should work, but let’s test it out just to make sure.

% ruby dsl-loader.rb params_with_equal2.dsl
params_with_equal2.dsl:2:in `load': undefined method `name=' for #<MyDSL:0x26300> (NoMethodError)

Oops. We forgot the accessors for name and parameter . Let’s add those and look at the complete program:

% cat mydsl2.rb
class MyDSL
  attr_accessor :name, :parameter

  def define_parameters
    yield self
  end

  def self.load(filename)
    # ... same as before
  end
end
Now, let's test it again.
% ruby dsl-loader.rb params_with_equal2.dsl
#<MyDSL:0x25ec8 @name="fred", @parameter=0.55>
["@name", "@parameter"]

Success! This now works, but we have added two extra lines to the DSL file and have added some noise with the ‘p.’ notation. This notation is better suited when there exists multiple levels of hierarchy in the file and there is actually a need for and a benefit from explicitly specifying context. In our simple case we can implicitly define context and leave no doubt for Ruby that name and parameter are methods. We do this by removing the ’=’ sign and write the DSL file as

% cat params.dsl
name      "fred" 
parameter 0.55

<<  Page 2 of 4  >>


Sponsored Links



Google
  Web Artima.com   
Copyright © 1996-2014 Artima, Inc. All Rights Reserved. - Privacy Policy - Terms of Use - Advertise with Us