Friday, May 26, 2006

Ruby Stunt Double

In the DSL focused system I'm currently working on we provide what I previously detailed as level 2 syntax checking. The DSL that we've developed is an internal DSL and requires the business user to call methods on objects (not that they have any idea what that means). The system depends on syntax similar to:
open.a.table.today.at 2
The users of the system requested some documentation to help them in building their business rules. Obviously we could have written or generated some documentation, but I thought displaying valid options within the system was a better choice.

The solution involves executing your DSL code in the syntax checking context and having key methods return Stunt Double instances. In the above code open is clearly a DSL keyword. In the syntax checking context open would be defined as:
def open
OpenStuntDouble.new(@messenger)
end
OpenStuntDouble is a class that inherits from StuntDouble and responds to all public instance methods of Open.
class OpenStuntDouble
def initialize(block)
super("open", &block)
end

stand_in_for(ThoughtWorks::Open) { AStuntDouble.new(@messenger) }
end
Any method that is executed on OpenStuntDouble will return a new instance of AStuntDouble. However, any method call that results in method_missing being executed will be reported as an invalid method call. Each invalid method call will also report the full list of available methods. Using this error you can provide your users with feedback on valid syntax.

Of course, the majority of the magic is in the StuntDouble class.
class StuntDouble

alias __methods__ methods
alias __class__ class

def initialize(name, &block)
raise "a messenger proc is required for stunt double" unless block_given?
@messenger = block
@name = name
end

def self.stand_in(*array)
block = block_given? ? Proc.new : Proc.new {}
array.each do |element|
define_method(element.to_sym, &block)
end
end

def self.stand_in_for(mod)
block = block_given? ? Proc.new : Proc.new {}
stand_in(*mod.public_instance_methods, &block)
end

def method_missing(sym, *args)
@notify_block.call "#{does_not_support(sym)} #{does_support}"
end

private

attr_reader :name

def does_support
return "Supported: #{valid_methods}." if valid_methods != ''
"Nothing is supported."
end

def does_not_support(sym)
"#{name} does not support #{sym.to_s}."
end

def valid_methods
result = self.__class__.public_instance_methods.sort.select do |operation|
!@@excluded.include? operation
end
result.join(', ')
end


@@excluded = public_instance_methods.select { |method| method =~ /^__.+/ }
@@excluded += %w(to_s inspect method_missing instance_eval)

instance_methods.each { |m| undef_method m unless @@excluded.include? m }

end
Of course, no code is complete without tests.
require File.expand_path(File.dirname(__FILE__) + "/../test_helper")

class StuntDoubleTest < Test::Unit::TestCase
class Inner < BlankSlate
def included_from_elsewhere
end
end

class FooStuntDouble < StuntDouble
end

class BarStuntDouble < StuntDouble
end

def setup_foo
FooStuntDouble.stand_in(:bar) { }
FooStuntDouble.stand_in_for(Inner)
end

def test_name_is_returned_from_message
setup_foo
stunt = FooStuntDouble.new("foo") do |error|
assert_equal 'foo does not support zeb. Supported: bar, included_from_elsewhere.', error
return
end
stunt.zeb
fail 'assertion skipped'
end

def test_name_is_returned_from_message_but_nothing_supported
stunt = BarStuntDouble.new("bar") do |error|
assert_equal 'bar does not support zeb. Nothing is supported.', error
return
end
stunt.zeb
fail 'assertion skipped'
end

def test_methods_are_added_from_array
setup_foo
stunt = FooStuntDouble.new("foo") do |error|
fail "error"
end
stunt.bar
end

def test_methods_are_added_from_class
setup_foo
stunt = FooStuntDouble.new("foo") do |error|
fail "error"
end
stunt.included_from_elsewhere
end

end
The end result is user friendly errors that can be displayed directly to your users.
open does not support the. Supported: a.
An advantage to this form of syntax help is that as the system evolves so will the help without any additional effort.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.