nanoc has recently gained an entirely new way of specifying how pages should be processed. Instead of describing processing instructions in YAML, they are now described using pure Ruby, like this:

compile '/articles/*/' do
  filter :erb
  filter :markdown

  layout 'article'

  filter :rubypants
end

These rules are located in a file called Rules, which is loaded by nanoc on startup. This blog article explains how these rules are implemented so that you can use the same idea in your projects.

There are two slightly different ways of implementing such a DSL, and I’ll describe them both in this post. The first iteration of nanoc used a DSL based on method #1, but in the second iteration I switched to the cleaner, prettier method #2.

For demonstration purposes, this blog post will not use nanoc’s domain-specific language, but instead use a much more minimal language that uses a #process method to define rules. The Rules file in the example will look like this:

process /oo/ do
  puts "I am rule /oo/ and I am processing #{item.inspect}!"
end

Method #1

This method uses a DSL that looks slightly different: it has an explicit item parameter, and all methods are called explicitly on this item. A Rules file written using method #1 looks like this:

process /oo/ do |item|
  puts "I am rule /oo/ and I am processing #{item.inspect}!"
end

The #process function creates a rule (I’ll show you how in a bit) which can be applied to an item. The first function argument is a pattern that specifies which items this rule can be applied to. When nanoc loads the Rules file, it creates a Rule instance containing this pattern as well as a block containing the actual code. Something like this:

class Rule

  def initialize(pattern, block)
    @pattern = pattern
    @block   = block
  end

  def applicable_to?(item)
    item.identifier =~ @pattern
  end

  def apply_to(item)
    @block.call(item)
  end

end

The #applicable_to? method is used for determining whether the rule is able to process a given item, and the #apply_to method performs the actual processing by calling the block and giving the item as a parameter.

The Item class is, in this case, rather simple: it only contains an identifier attribute. It is implemented like this:

class Item

  attr_reader :identifier

  def initialize(identifier)
    @identifier = identifier
  end

end

Here’s how the rules file is loaded and parsed:

class App

  def initialize
    @rules = []
  end

  def load_rules
    rules_content = File.read('Rules')
    dsl = DSL.new(@rules)
    dsl.instance_eval(rules_content)
  end

end

The #instance_eval method takes a block or a string and evaluates it in the content of the object on which the method is called. The last line of the code example above evaluates the string, which contains the content of the rules file, in the context of a DSL instance.

The idea is to have the #process method implemented in this DSL instance. This method then generates a Rule instance using the pattern and the block given to the #process call, like this:

class DSL

  def initialize(rules)
    @rules = rules
  end

  def process(pattern, &block)
    @rules << Rule.new(pattern, block)
  end

end

The rules array contains all rules. To actually use these rules, i.e. find a rule for an item and apply it, you’d use something like this (no error checking implemented):

class App

  def process(item)
    rule = rules.find { |r| r.applicable_to?(item) }
    rule.apply_to(item)
  end

end

And that’s how the DSL is implemented using method #1.

Method #2

The second method gets rid of the extra block parameter to the #process call. Using method #2, the Rules file looks like this:

process /oo/ do
  puts "I am rule /oo/ and I am processing #{item.inspect}!"
end

To get rid of the extra item parameter, the block will have to be evaluated in the context of an object that provides access to the item in a different way. One way of doing this is to provide an @item variable in a RuleContext object, which looks like this:

class RuleContext

  def initialize(item)
    @item = item
  end

end

Applying a rule to an item no longer involves calling the block and giving the item as a parameter. Instead, a RuleContext is created with a given item, and then the block is evaluated in the context of this rule context, like this:

class Rule

  def apply_to(item)
    rule_context = RuleContext.new(item)
    rule_context.instance_eval(&@block)
  end

end

Accessing the item is now done using @item. If you want to be able to use item without that @ sigil, define an accessor:

class RuleContext

  attr_reader :item

end

And that’s how method #2 works.

You can get the source of the example implementation over here. It contains two directories; one for each method. Each directory contains a dsl.rb file which contains the library code, a test.rb file which can be executed, and of course a Rules file which contains the rules.