The goal of metaprogramming in Ruby is often the creation of domain-specific languages, or DSLs. A DSL is just an extension of Ruby’s syntax (with methods that look like keywords) or API that allows you to solve a problem or represent data more naturally than you could otherwise. For our examples, we’ll take the problem domain to be the output of XML formatted data, and we’ll define two DSLs—one very simple and one more clever—to tackle this problem.[*]
We begin with a simple class named XML
for generating XML output. Here’s an
example of how the XML
can be
used:
pagetitle = "Test Page for XML.generate" XML.generate(STDOUT) do html do head do title { pagetitle } comment "This is a test" end body do h1(:style => "font-family:sans-serif") { pagetitle } ul :type=>"square" do li { Time.now } li { RUBY_VERSION } end end end end
This code doesn’t look like XML, and it only sort of looks like Ruby. Here’s the output it generates (with some line breaks added for legibility):
<html><head> <title>Test Page for XML.generate</title> <!-- This is a test --> </head><body> <h1 style='font-family:sans-serif'>Test Page for XML.generate</h1> <ul type='square'> <li>2007-08-19 16:19:58 -0700</li> <li>1.9.0</li> </ul></body></html>
To implement this class and the XML generation syntax it supports, we rely on:
Ruby’s block structure
Ruby’s parentheses-optional method invocations
Ruby’s syntax for passing hash literals to methods without curly braces
The method_missing
method
Example 8-11 shows the implementation for this simple DSL.
Example 8-11. A simple DSL for generating XML output
class XML # Create an instance of this class, specifying a stream or object to # hold the output. This can be any object that responds to <<(String). def initialize(out) @out = out # Remember where to send our output end # Output the specified object as CDATA, return nil. def content(text) @out << text.to_s nil end # Output the specified object as a comment, return nil. def comment(text) @out << "<!-- #{text} -->" nil end # Output a tag with the specified name and attributes. # If there is a block invoke it to output or return content. # Return nil. def tag(tagname, attributes={}) # Output the tag name @out << "<#{tagname}" # Output the attributes attributes.each {|attr,value| @out << " #{attr}='#{value}'" } if block_given? # This block has content @out << '>' # End the opening tag content = yield # Invoke the block to output or return content if content # If any content returned @out << content.to_s # Output it as a string end @out << "</#{tagname}>" # Close the tag else # Otherwise, this is an empty tag, so just close it. @out << '/>' end nil # Tags output themselves, so they don't return any content end # The code below is what changes this from an ordinary class into a DSL. # First: any unknown method is treated as the name of a tag. alias method_missing tag # Second: run a block in a new instance of the class. def self.generate(out, &block) XML.new(out).instance_eval(&block) end end
The XML
class of Example 8-11 is helpful for generating well-formed XML, but it
does no error checking to ensure that the output is valid according to
any particular XML grammar. In the next example, Example 8-12, we add some simple error checking (though not
nearly enough to ensure complete validity—that would require a much
longer example). This example is really two DSLs in one. The first is
a DSL for defining an XML grammar: a set of tags and the allowed
attributes for each tag. You use it like this:
class HTMLForm < XMLGrammar element :form, :action => REQ, :method => "GET", :enctype => "application/x-www-form-urlencoded", :name => OPT element :input, :type => "text", :name => OPT, :value => OPT, :maxlength => OPT, :size => OPT, :src => OPT, :checked => BOOL, :disabled => BOOL, :readonly => BOOL element :textarea, :rows => REQ, :cols => REQ, :name => OPT, :disabled => BOOL, :readonly => BOOL element :button, :name => OPT, :value => OPT, :type => "submit", :disabled => OPT end
This first DSL is defined by the class method XMLGrammar.element
. You use it by
subclassing XMLGrammar
to create a
new class. The element
method
expects the name of a tag as its first argument and a hash of legal
attributes as the second argument. The keys of the hash are attribute
names. These names may map to default values for the attribute, to the constant REQ
for required attributes, or to the
constant OPT
for optional
attributes. Calling element
generates a method with the specified name in the subclass you are
defining.
The subclass of XMLGrammar
you define is the second DSL, and you can use it to generate XML
output that is valid according to the rules you specified. The
XMLGrammar
class does not have a
method_missing
method so it won’t
allow you to use a tag that is not part of the grammar. And the
tag
method for outputting tags
performs error checking on your attributes. Use the generated grammar
subclass like the XML
class of
Example 8-11:
HTMLForm.generate(STDOUT) do comment "This is a simple HTML form" form :name => "registration", :action => "http://www.example.com/register.cgi" do content "Name:" input :name => "name" content "Address:" textarea :name => "address", :rows=>6, :cols=>40 do "Please enter your mailing address here" end button { "Submit" } end end
Example 8-12 shows the implementation of the XMLGrammar
class.
Example 8-12. A DSL for validated XML output
class XMLGrammar # Create an instance of this class, specifying a stream or object to # hold the output. This can be any object that responds to <<(String). def initialize(out) @out = out # Remember where to send our output end # Invoke the block in an instance that outputs to the specified stream. def self.generate(out, &block) new(out).instance_eval(&block) end # Define an allowed element (or tag) in the grammar. # This class method is the grammar-specification DSL # and defines the methods that constitute the XML-output DSL. def self.element(tagname, attributes={}) @allowed_attributes ||= {} @allowed_attributes[tagname] = attributes class_eval %Q{ def #{tagname}(attributes={}, &block) tag(:#{tagname},attributes,&block) end } end # These are constants used when defining attribute values. OPT = :opt # for optional attributes REQ = :req # for required attributes BOOL = :bool # for attributes whose value is their own name def self.allowed_attributes @allowed_attributes end # Output the specified object as CDATA, return nil. def content(text) @out << text.to_s nil end # Output the specified object as a comment, return nil. def comment(text) @out << "<!-- #{text} -->" nil end # Output a tag with the specified name and attribute. # If there is a block, invoke it to output or return content. # Return nil. def tag(tagname, attributes={}) # Output the tag name @out << "<#{tagname}" # Get the allowed attributes for this tag. allowed = self.class.allowed_attributes[tagname] # First, make sure that each of the attributes is allowed. # Assuming they are allowed, output all of the specified ones. attributes.each_pair do |key,value| raise "unknown attribute: #{key}" unless allowed.include?(key) @out << " #{key}='#{value}'" end # Now look through the allowed attributes, checking for # required attributes that were omitted and for attributes with # default values that we can output. allowed.each_pair do |key,value| # If this attribute was already output, do nothing. next if attributes.has_key? key if (value == REQ) raise "required attribute '#{key}' missing in <#{tagname}>" elsif value.is_a? String @out << " #{key}='#{value}'" end end if block_given? # This block has content @out << '>' # End the opening tag content = yield # Invoke the block to output or return content if content # If any content returned @out << content.to_s # Output it as a string end @out << "</#{tagname}>" # Close the tag else # Otherwise, this is an empty tag, so just close it. @out << '/>' end nil # Tags output themselves, so they don't return any content. end end
[*] For a fully realized solution to this problem, see Jim Weirich’s Builder API at http://builder.rubyforge.org.
3.137.183.210