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.