Friday, April 28, 2006

The Way of Meta - Part IV - Hijacking Local Variables in DSLs

The Way of Meta

~

V. 0.0.4


Not long ago I have read the 'Creating DSLs with Ruby' article written by Jim Freeze, where he explains how to forge a DSL step by step, creating a new human readable formalism on top of ruby.  I really enjoyed the article as it went along some of the lines of thought that I have been working on for a while.

In the article there was a point that was left somewhat open: how to allow developers using a DSL to assign values to new concepts that they define on the spot, and then get access to both these concepts and their values from within the DSL.

I realize I have been a bit vague here, so I'll give you a straightforward example.  Let's say we want to define a DSL to describe the shopping list with all the ingredients needed to make a certain recipe..

Ideally we would like to write something like:

shopping_list_for "BLT sandwiches for the picnic" do
  sliced_bread = 3
  lettuce = 2
  tomatoes = 6, :red
  bacon = 4
end

The do..end block delimits a block context that gets passed directly to the shopping_list_for method.
This method should then extract the various ingredients and their quantity and description.

How can it do that?  One way is to run the block within a context where those ingredients already exist as methods.  This context would be a class with -for example- the sliced_bread= method overridden to accept the DSL input.

What about new ingredients that have not been specified a priori ?  Your metaprogramming instincts will tell you to go for a method_missing based technique.  Wrong.  Unless you specify a self (or other object) in front of sliced_bread, ruby will simply assume that it is a local variable rather than a missing method.  Highly annoying if you ask me, but it is necessary to avoid even worst problems and ambiguities when dealing with stuff like mixins.

You are left with these three choices:


shopping_list_for "BLT sandwiches for the picnic" do
  self.sliced_bread = 3
  self.lettuce = 2
  self.tomatoes = 6, :red
  self.bacon = 4
end

Argh!


shopping_list_for "BLT sandwiches for the picnic" do |recipe|
  recipe.sliced_bread = 3
  recipe. lettuce = 2
  recipe. tomatoes = 6, :red
  recipe. bacon = 4
end

Just a tad annoying


shopping_list_for "BLT sandwiches for the picnic" do
  @sliced_bread = 3
  @lettuce = 2
  @tomatoes = 6, :red
  @bacon = 4
end

The best so far.. you can visualize the @ as bullet points ant it is kind of ok..
Kind of.

Jim, at this point, proposes to drop the equal sign to force ruby to recognize the new keyword as a method that gets routed through method_missing.

shopping_list_for "BLT sandwiches for the picnic" do
  sliced_bread 3
  lettuce 2
  tomatoes 6, :red
  bacon 4
end

I'll admit it, this is not bad at all.  Yet it annoys me to no end that I cannot just get those variables that I need from another context.

Thinking about there is a way to get variables, and that is the local_variables method, but you have to execute it within a specific context.  There is also a method to pass around contexts, and that is binding.  What we have to do is to get the binding of the block and to get the local variables within the context underlying the binding.

I tried to call the block and then get the binding of the block, but it didn't work.  The block gets executed and the variables fall out of scope and become unavailable. 

I had to get the block itself to return the binding.  This can be done and it works, but the binding looks totally out of place..

shopping_list_for "BLT sandwiches for the picnic" do
  sliced_bread = 3
  lettuce = 2
  tomatoes = 6, :red
  bacon = 4 
 
  binding
end

Here it hit me that I could alias the word binding to something that would look good with end.  What about the end ?  I know it's tacky, but in its own tacky way it works and sounds credible:

shopping_list_for "BLT sandwiches for the picnic" do
  sliced_bread = 3
  lettuce = 2
  tomatoes = 6, :red
  bacon = 4 
the end

This is the code that allows the DSL to work and to print out the shopping list:

def report recipe, ingredients

    puts "to make #{recipe} you should buy:"

    ingredients.each_pair do

        |ingredient,description|

        puts " * #{Array(description).join ' '} #{ingredient}"

    end

    puts

end
 

def shopping_list recipe

    shopping_binding = yield

    ingredients = {}

    eval("local_variables" , shopping_binding).

    each do |var|

        ingredients[var] = eval "#{var}" , shopping_binding

    end

    report recipe, ingredients   

end

 

alias the binding
 

shopping_list "english breakfast" do

    tomatoes = 2, :green

    sausages = 3

    eggs = 2, :big

    bacon = 4

the end

 

shopping_list "banana milkshake" do

    milk = 1

    bananas = 2   

the end


When you run it you get:

to make english breakfast you should buy:
 * 2 green tomatoes
 * 3 sausages
 * 4 bacon
 * 2 big eggs

to make banana milkshake you should buy:
 * 2 bananas
 * 1 milk




Comments:
Very cool!
I'm not a Ruby hacker, but while reading your article I was thinking about using the recipe name string as an object to route the messages coming from the ingredients and then catching them by implementing missing_method in the string object...
So, for:

recipe "italian breakfast" do
caffe = 1, :macchiato
end

is there a way to translate it into:

"italian breakfast".caffe(1, :macchiato) ?

maybe you still have the issue that by using the equal sign you'll get variables and not function calls?
 
Federico, I have a big fat reply to your comment :-) Check it out here: http://liquiddevelopment.blogspot.com/2006/04/twisting-and-shaping-dsls-using-ruby.html
 
Post a Comment

Links to this post:

Create a Link



<< Home

This page is powered by Blogger. Isn't yours?