Building DSL with Ruby

The DSL shortcut stands for Domain Specific Language. At first glance, it might sound a little bit mysterious, but the truth is you use it daily as a Ruby developer.

Example? The config/routes.rb file that lives in every Ruby on Rails project:

Rails.application.routes.draw do
  root to: 'home#index'
  resources :users
end

In the above example, the "Domain-specific" means the routes definition. Rails provides a special language (which is, of course, Ruby code) to allow you to define routes more efficiently. Instead of the complex definition with request types, path, and params, you can just use the resources method, and all the parsing is done under the hood.

This article will cover more examples and show you how you can build your DSL from scratch.

DSL’s around you

Before diving into the technical aspects of building DSL in Ruby, let’s explore more examples for domain-specific languages used in popular Ruby solutions and libraries.

Database migrations

Creating and updating databases with Rails is an effortless and straightforward thing. A special DSL allows you to define columns, types, indexes, and many more. The definition is later translated to the SQL code and executed on your database.

class Migration < ActiveRecord::Migration[6.1]
  def change
    add_column :users, :first_name, :string
  end
end

Configurations

A straightforward but efficient configuration convention is used in most of the Ruby gems. The simple DSL allows you to configure the solution by using a simple block and assignments:

MyGem.configure do
  config.some_setting = 'value'
end

Testing with RSpec

RSpec provides one of the DSL types without which it would be tough to write the code efficiently. With the domain-specific language for tests, we can write a code that is super readable and easy to maintain:

require 'rails_helper'

RSpec.describe User do
  describe '#first_name' do
    let(:user) { User.new('John Doe') }

    it 'returns first name' do
      expect(user.first_name).to eq('John')
    end
  end
end

Configuring servers

Chef is a Ruby automation tool that helps to configure, deploy and manage servers. It’s using DSL to provide an easy interface for building the automation flow:

#
# Cookbook Name:: name
# Recipe:: default
#
#

execute "update-upgrade" do
  command "sudo apt-get update"
  action :run
end

Rake tasks

The last example of the DSL in a Ruby world that I’m going to cover is a Rake task. You can define a task using a simple DSL:

namespace :users do
  desc "Some task"
  task :task => :environment do
    # do something
  end
end

How DSL is built

A standard part of all DSL implementations provided above is a simple block. Block is the first part of DSL’s core and the second part is the instance_eval expression.

If you are not yet familiarized with the instance_eval expression, it’s a method that allows you to execute a given piece of code in the context of the receiver:

class User
  def first_name
    "John"
  end
end

user = User.new
user.instance_eval { first_name }
# => "John"

Inside the instance eval block, you can use the same expression you can use inside the User class. You can also access private methods, variables, etc.

Having the above knowledge, we can quickly build a simple configuration class that you can later use for a gem:

module SomeGem
  class << self
    attr_accessor :config

    def configure(&block)
      self.config ||= Config.new
      instance_eval(&block)
    end
  end
  
  class Config
    attr_accessor :api_key, :app_name
  end
end

We can now test it to see that it’s working as expected:

SomeGem.configure do
  config.api_key = 'key'
  config.app_name = 'name'
end

SomeGem.config.api_key # => 'key'
SomeGem.config.app_name # => 'name'

It’s simple as that. In most cases, it’s all about building an interface and then evaluating the block on the interface’s instance to use its features.

Build your own DSL

Since we know how to build a simple DSL and understand the examples of DSLs that Ruby developers widely use worldwide, we can develop our own solution.

The challenge

We would like to build our own parser for the Gemfile’s DSL:

source "rubygems.org"

gem "nokogiri"
gem "rake", "10.3.2"

group :production do
  gem "unicorn"
end

group :development, :test do
  gem "pry"
end

Let’s get our hands dirty and write our DSL for parsing the Gemfile.

The solution

The process of creating the DSL parser will consist of three steps:

  • parsing the source
  • parsing the base gem definition
  • parsing the gem definition for a given group

The preparation

Start with creating the Gemfile and putting the contents presented above. Now it’s time to create a skeleton for our parser:

class GemfileParser
  def source(address); end
  def gem(name, version = nil); end
  def group(*names); end
end

we would use the following snippet to check the result of parsing the Gemfile:

contents = File.read("./Gemfile")
parser = GemfileParser.new
parser.instance_eval(contents)

Right now, the parser does nothing, but we won’t get any errors when parsing the Gemfile’s source.

Parsing source

Each Gemfile can have multiple sources, so we have to keep sources as an array. Since the source method simply accepts the address of the source, all we have to do is to add the source to the array of sources:

class GemfileParser
  def initialize
    @sources = []
  end
 
  def source(address)
    @sources << address
  end
 
  def gem(name, version = nil); end
  def group(*names); end
end

Now we can test our solution to see that the source is added:

contents = File.read("./Gemfile")
parser = GemfileParser.new
parser.instance_eval(contents)
parser # => #<GemfileParser:0x00007fbd26a3a490 @sources=["rubygems.org"]> 

Parsing gems

The next step is to implement the body for the gem method. For now, we would simply add each gem to the array as a hash with the gem name and version:

class GemfileParser
  def initialize
    @sources = []
    @gems = []
  end
 
  def source(address)
    @sources << address
  end
 
  def gem(name, version = nil)
    @gems << { name: name, version: version }
  end
 
  def group(*names); end
end

You can test the class again, and after evaluating the Gemfile source, the code would populate the @gems array with two gems.

Supporting groups

The last step is to add support for the groups. This step is tricky, and I wasn’t sure how to do it properly. I looked into the Gemfile source code to find out, but if you know how to do it more elegantly, let me know.

class GemfileParser
  def initialize
    @sources = []
    @gems = []
    @current_groups = []
  end
 
  def source(address)
    @sources << address
  end
 
  def gem(name, version = nil)
    @gems << { name: name, version: version, groups: @current_groups }
  end
 
  def group(*names, &block)
    @current_groups = names
    yield
  ensure
    @current_groups = []
  end
end

If the group is not specified, we will assign an empty array, but if groups are identified, we would set those groups and clear the @current_groups variable after the assignment.

Our solution is now complete, and the Gemfile is parsed. In the real-world situation, you would now go through the @gems array and install each gem, but for this article, it’s enough; the goal of building DSL for Gemfile is completed.

What to do next

Domain Specific Language is a simple but compelling concept. It’s pretty easy to implement, but the naming part is also tricky. It’s sometimes hard to name things correctly, especially when you don’t have many years of building software experience.

To better understand the process of building DSLs, you can investigate the source code behind popular solutions that utilize DSL, like Rails migrations and routes.

However, you must be aware that the more complicated and advanced your DSL will become, the harder it would be to test and maintain the code.