Exploring Metaprogramming Idioms similar to RSpec


I have been learning Ruby for 2-3 months. At the beginning, I was very shocked by some approaches being used there.
Recently, I have been very excited about the powerful meta-programming features working very reliably. By the way, a few years ago, I tried to explain to people how difficult it is to deal with such dynamic languages like Javascript (and other ones as well) and how lucky Java programmers are because the have a compiler.

I think I have to audit my opinion since I made some experiments with Rails and I was surprised about the beauty of ActiveRecord and RSpec. The latter one emphasizes the power of Ruby by defining it’s own Domain Specific Language (DSL). I got very curious and spent some time with exploring several Ruby mechanisms which make possible constructs like:

describe Something do
    it "should be possible!" do
        ...
    end
end

I decided to write my own DSL in order to learn more about the mechanism making all that possible. The source code is available via GitHub (see below).

I had some knowledge about methods and blocks so that it wasn’t difficult to guess that there are two (global?) methods being used as keywords
(from the user’s point of view):

  • describe
  • it

Everything that follows the (built-in) keyword “do” is a block.

After a while I had an idea about the purpose of this new DSL. It has to support people choosing an appropriate candidate for a job ;-).

To avoid some conficts to RSpec I selected different “keywords”. The usage of the resulting (small and humble) framework should look like this:

 specify Candidate do

   she "should be young and motivated"  do
     @c = Candidate.new
     @c.name = "Anonymous"
     @c.age = 30
     @c.should_not be_old
   end

 end

I was done with that after a couple of hours. The framework consists of 80 (!) lines of code (including empty ones). I’m sure that there are many developers who are able to provide a better solution. For now, though, I’m very satisfied with this solution.

Let’s take a look at the architecture:

Implementing “specify”:

The (global?) method specify is able to process a “thing” that normally is (and must be in this implementation) a Class object. The argument thing and the passed block are stored in a global Hash @things. Consider that the block is converted into a Proc object to be evaluated later (well-known as Deferred Evaluation). Additionally, we use the beautiful syntax of << to define accessors for variables descs (used for storing test descriptions) and results (used for storing test results).

def specify(thing, *args, &block)
  if( defined? thing != nil && thing.class == Class )
    @things[thing] = Proc.new(&block)

    class << thing
      attr_accessor :descs
      attr_accessor :results
    end
    thing.descs = {} if thing.descs.nil?
    thing.results = [] if thing.results.nil?
    inject_sugar thing
  else
    raise "#{thing} isn't suitable for 'specify'"
  end

  @things.each do |k,v|
    k.instance_eval &v
    k.descs.each do |key,val|
      #print the description
      puts key.inspect
      k.instance_eval &val
    end
    #print the result of should/should_not
    k.results.each do |val|
      puts "\t * #{val}"
    end
  end
  @things.clear
end

Each time when something is “specified” by the method “specify”, all the specifications are stored in a Hash and finally evaluated in context of the class. The nested “he”/”she” keywords are evaluated in context of the class as well (see the nested loop). Why? If you consider the usage snippet you can see that we invoke the method called be_old. But this method doesn’t exist at all. Method invocations which look like “be_old” are sometimes described as “Class Macros” (see also Metaprogramming Ruby Book by Paolo Perotta). So I thought it’s a good idea to evaluate the nested he/she blocks in Class’s scope as well.

Implementing “he”/”she”:

That’s pretty simple. Since, the block passed in “specify” is evaluated in scope of the Class Candidate, the keyword “self” is the Class itself when Ruby enters “he” or “she”. As you can see the Class itself (here: Candidate) has got the attribute “descs” that has been created before inside “specify” (by the strange syntax <<). Here is where I store the mapping of descriptions ("should be young and motivated") and the related Procs (for deferred evaluation).

 def he(desc, *args, &block)
   person(desc, *args, &block);
 end

 def she(desc, *args, &block)
   person(desc, *args, &block);
 end

 def person(desc, *args, &block)
   self.descs[desc] = Proc.new(&block)
 end

Note: “he” and “she” have the same method body (just for simplicity I set gender-related details aside ;-)).

Implementing “inject_sugar”:

Maybe this is the most puzzling part of the solution but this is what metaprogramming is about: writing code that writes code.
This is how methods like should, should_not and be_old … are born.

The method “inject_sugar” is defined in the same scope like “specify”. It receives the Class itself as an argument. This is necessary for defining some syntactic sugar like dynamic instance and class methods.

The methods “be_old”, “be_young”,… are executed in the Class’s scope and they aren’t defined. So we’ll need a mechanism that is able to catch invalid method invocations silently. This is what method_missing is for.
Note: we could get rid of that when using :be_old or be_old but instead we do that the same way that RSpec does 😉
My implementation of method_missing returns the name of the method itself. After all, the return value of “be_old” is passed as an argument for should and should_not.

  def clazz.method_missing method_name, *args, &block
    if method_name.to_s.match(/be_.*/)
      method_name
    else
      super
    end
  end

The “should” and “should_not” methods are defined as instance methods at runtime by calling Ruby’s “send” method:

  clazz.send(:define_method, 'should') do |*args|
    args << true
    self.validate(*args)
  end

  clazz.send(:define_method, 'should_not') do |*args|
    args << false
    self.validate(*args)
  end

The only difference between them is that an additional parameter (true or false) is added because “should” checks for “true” and “should_not” checks for false.

The “validate” method detects the real instance method name. The create class macro “be_old” becomes “old?”, “be_young” becomes “young?” and so on. This is the mechanism of detecting the ordinary method to be called and tested.

Depending on the final check the method returns “SUITABLE…” or “NOT SUITABLE…”. Remember the dealing with deferred evaluation inside “specify”. This is the place where the value is returned and extended by the prefix “SUITABLE…” or “NOT SUITABLE…”.

  def validate(*args)
    target_method, val = *args
    underline_pos = target_method.to_s.index('_')
    raise "call #{target_method.to_s.inspect} not supported" if underline_pos.nil?
    underline_pos += 1
    real_instance_method_name = target_method.to_s[underline_pos..-1]
    real_instance_method = self.method "#{real_instance_method_name}?"

    msg = "#{real_instance_method_name} SHOULD BE #{val}"
    if real_instance_method.call == val
      self.class.results << ("SUITABLE    : " + msg + "\n\t\t\t for " + self.inspect )
    else
      self.class.results << ("NOT SUITABLE: " + msg + "\n\t\t\t for " + self.inspect )
    end
  end

Tested class:

#... more code
class Developer < Candidate
  attr_accessor :programming_languages
  def clever?
      self.programming_languages.include?('Ruby') && (!self.programming_languages.include?('Visual Basic'))
  end
end
#... more code

Usage:

specify Developer do
  he "should be a clever developer" do
    @d = Developer.new
    @d.name = "Arnie"
    @d.programming_languages = %w[VisualBasic C#]
    @d.should be_clever

    @d2 = Developer.new
    @d2.name = "LuckyLuke"
    @d2.programming_languages = %w[Ruby Javascript]
    @d2.should be_clever
  end
end

Produced output:

"should be a clever developer"
	 * NOT SUITABLE: clever SHOULD BE true
			 for #<Developer:0x00000001197ea8 @name="Arnie", @programming_languages=["VisualBasic", "C#"]>
	 * SUITABLE    : clever SHOULD BE true
			 for #<Developer:0x00000001197958 @name="LuckyLuke", @programming_languages=["Ruby", "Javascript"]>

Public GitHub Repository:

https://github.com/jepetko/dsl_app

Advertisements

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s