Two simple ruby DSL examples

Introduction

Domain Specific Languages (DSL) become quite popular in last decade. DSL could be external (as a separate language) or internal (as a specific way to write a program within existing programming language). In this article we are interested more in internal DSL because of some advantages:

  • it is a way to expose functionality in a simple, readable format.

  • it is like a miniature specialized programming language within parent language.

  • being written in host language, it still could use full power of parent language.

  • it introduces notions that are close to the lexicon of target domain, allowing us easily express logic specific to a particular problem.

Programming languages provide different levels of convenience/abstraction in writing DSLs. Ruby is one of recognizable languages that lets writing DSL quite easy, because of built-in abilities for writing DSLs based on metaprogramming and first-class functions.

Metaprogramming (and especially class body evaluation) let us to create directives that look like they are part of the language itself. First class functions lets us treat methods as parameters.

You can see other articles about DSL here and here.

Example

Let’s have some simple class for building arrays:

class Collector

  def initialize
    @array = []
  end

  def add element
    @array << element
  end

  def remove element
    @array.delete(element)
  end

  def to_a
    @array
  end
end

We can use this class now:

collector = Collector.new

collector.add "1"
collector.add "2"
collector.add "3"

This is regular program and as any other program it’s verbose. We can remove noise by applying some metaprogramming tricks. We’ll try to remove method receiver: “collector.” from the code by making it implicit.

Implementation

One way of doing DSL in Ruby is based on instance_eval method. It takes block of code as input parameter and evaluates it in the context of the calling object:

class DSL
  def build object, &code
    object.instance_eval &code
  end
end

dsl = DSL.new

Now, let’s test our code with this example:

collector = Collector.new

# one way of doing DSL with direct code block

dsl.build(collector) do
  add "1"
  add "2"
  add "3"
end

# another way of doing DSL with lambda

code = lambda do |_|
  add "4"
  add "5"
  add "6"
end

dsl.build(collector, &code)

puts collector.to_a.join(' ') # $ 1 2 3 4 5 6

You have to keep in mind two thing though:

  • instance_eval changes evaluation context, so code passed for evaluation is treated as original code of the object. As a result, all private methods and fields are available:
dsl.build(collector) do
  puts @array # this field is accessible,
              # self is pointing to 'collector' object
  puts self   # "collector" instance
end
  • Because of context switch, you loose access to the calling context:
class DSL
  def dsl_method
    "dsl_method"
  end
end

dsl = DSL.new

collector = Collector.new

dsl.build(collector) do
  begin
    puts dsl_method # raises exception
  rescue
    puts "'dsl_method' not available"
  end
end

We cannot do anything with accessing private methods/fields, but we can make methods from calling context available. if you provide parent context as an additional parameter, you can redirect calls to missing object directly to the parent (proxy object):

module Proxy
  def subject= subject
    @subject = subject
  end

  def method_missing(name, *args, &block)
    @subject.send(name, *args, &block)
  end

  def respond_to?(name, include_private = false)
    @subject.respond_to?(name) || super
  end
end

Here is our modified DSL class:

class DSL
  def build object, parent, &code
    object.extend Proxy
    object.subject = parent

    object.instance_eval &code
  end
end

If you know that code block is coming from the parent scope, you can calculate parent dynamically, this way you may reduce amount of input parameters:

class DSL
  def build object, &code
    parent = code.binding.eval 'self'

    object.extend Proxy
    object.subject = parent

    object.instance_eval &code
  end
end

Building something useful

Now, let’s apply our knowledge to build something useful. We will try to build DSL for building zip archive and regular directory in same fashion. The idea is to build API identical, so you can use similar code for building zip files and for copying files.

As a result, we have 2 gems: ZipDSL and DirDSL.

ZipDSL gem

ZipDSL is library for working with zip files in DSL way.

Install new gem:

$ gem i zip_dsl

Now you can create new archive:

require 'zip_dsl'

zip_file = "test.zip"
from_dir = "."

zip_builder = ZipDSL.new zip_file, from_dir

zip_builder.build do
  # files from 'from_dir'
  file :name => "Gemfile"
  file :name => "Rakefile", :to_dir => "my_config"
  file :name => "spec/spec_helper.rb",
       :to_dir => "my_config"

  # create empty directory
  directory :to_dir => "my_config"

  # copy from one directory to another
  directory :from_dir => "spec", :to_dir => "my_spec"

  # create zip entry from arbitrary source:
  # string or StringIO
  content :name => "README",
          :source => "My README file content"
end

or update existing archive:

zip_builder.update do
  file :name => "README.md"
  directory :from_dir => "lib"
end

You can also display all entries from archive’s folder:

zip_builder.list("lib/zip_dsl")

or display entries:

zip_builder.each_entry("lib/zip_dsl") do |entry
  puts entry.name

  content = entry.get_input_stream.read

  puts content
end

When you work with zip file, you have to decide how to allocate/release resources (zip files) in order to avoid memory leaks. One possibe solution could be using modified DSL class implementation:

class DSL
  def build create_code, destroy_code=nil, &code
    parent_binding = code.binding
    parent = parent_binding.eval 'self'

    object = create_code.kind_of?(Proc) ? create_code.call : create_code

    object.extend Proxy
    object.subject = parent

    object.instance_eval &code
  ensure
    destroy_code.call(object) if destroy_code && object
  end
end

For example, we can wrap ZipInputStream in this way:

class Reader
  include DSL

   def each_entry name, &code
    create_code = lambda {
      Zip::ZipInputStream.new("#{name}")
    }
    destroy_code = lambda {|zis| zis.close }
    execute_code = lambda do |zis|
      zis.rewind

      while (entry = zis.get_next_entry)
        next if entry.name =~ %r{\.\.}
        code.call(entry)
      end
    end

    build(create_code, destroy_code, execute_code)
   end
end

DirDSL gem

DirDSL is library for working with files and directories (create, copy) in DSL way.

Install it with this command:

$ gem install dir_dsl

You can create new directory now:

require 'dir_dsl'

from_dir = "."
to_dir = "build"

dir_builder = DirDSL.new from_dir, to_dir

dir_builder.build do
  # files from 'from_dir'
  file :name => "Gemfile"
  file :name => "Rakefile", :to_dir => "my_config"
  file :name => "spec/spec_helper.rb",
       :to_dir => "my_config"

  # create empty directory
  directory :to_dir => "my_config"

  # copy from one directory to another
  directory :from_dir => "spec",
            :to_dir => "my_spec"

  # create zip entry from arbitrary source:
  # string or StringIO
  content :name => "README",
          :source => "My README file content"
end

And display all entries from the folder:

dir_builder.list("lib/zip_dsl")

Some other examples of DSL can be found here, here, here, here and here.