Demystifying and writing own DSL in Ruby by examples - a practical guide
Writing a domain-specific language (DSL) is a powerful way to abstract complex logic and make it accessible and usable. Ruby, with its expressive syntax and meta-programming capabilities, is one of the most suitable languages for developing DSLs. In this article, we'll dive deep into creating DSLs in Ruby, exploring various aspects and examples.
Introduction to DSLs
Domain-specific languages are mini-languages created to solve problems in a specific domain. They are designed to express the semantics of a particular domain, often abstracting complex implementation details. This makes them very user-friendly for people working in that domain.
Example: A Minitest-like Framework
To dive into the practical aspect of creating a DSL, let's start by building a Minitest-like framework in Ruby. Below is the sample code.
class MyMinitestLikeTestFramework
class Test
attr_accessor :name, :block
def initialize(name, block)
@name = name
@block = block
end
def run
instance_eval(&block)
end
def assert_equal(a, b)
if a == b
print "\r."
else
puts "\nFailed: #{a} != #{b} for '#{@name}'"
end
end
def assert_not_equal(a, b)
if a != b
print "\r."
else
puts "\nFailed: #{a} != #{b} for '#{@name}'"
end
end
end
class << self
def test(name, &block)
Test.new(name, block).run
end
end
end
class Abcd < MyMinitestLikeTestFramework
test 'it should work' do
assert_equal 1, 1
assert_equal "a", "a"
end
test 'it should not work' do
assert_not_equal 1,2
assert_not_equal "a", "b"
assert_not_equal "a", "a" # raise error
end
end
In this example, we define a Test
class that encapsulates the logic and assertions of each test. It's designed to be instantiated with a name
and a block
of code, which it then instance_eval
's in a run method to execute the test. Two assertion methods, assert_equal
and assert_not_equal
, are provided, and below we use this framework to create a test suite, Abcd
. Using the Test
method, we define two tests. When this code is run, our little test framework will output a series of dots for each successful assertion and an error line for each failed assertion, much like Minitest does.
Example: A Routes-like framework
Next, let's build something similar to a Routes configuration DSL as seen in Rails:
module Drawer
module FieldReader
def field(name, required: false, &block)
nested_field = Field.new(name, required)
nested_field.add_args_and_fields(&block)
nested_fields << nested_field
end
def add_args_and_fields(&block)
instance_eval(&block)
end
end
class Argument
attr_accessor :name, :required, :field
def initialize(name, required, field)
@name = name
@required = required
@field = field
end
end
class Field
include FieldReader
attr_accessor :field_name, :args, :nested_fields, :required
def initialize(field_name, required = false)
@field_name = field_name
@args = []
@nested_fields = []
@required = required
end
def arg(argument_name, required:)
args << Argument.new(argument_name, required, field_name)
end
end
class Base
include FieldReader
attr_accessor :nested_fields
def initialize
@nested_fields = []
end
def draw(&block)
instance_eval(&block)
end
def call(fields = @nested_fields, indent = 0)
fields.each do |field|
puts "#{' ' * indent}field: #{field.field_name}#{':required' if field.required}"
field.args.each do |arg|
puts "#{' ' * (indent + 2)}arg: #{arg.name}#{':required' if arg.required} -> #{arg.field}"
end
call(field.nested_fields, indent + 2) unless field.nested_fields.empty?
end
end
end
end
service = Drawer::Base.new
service.draw do
field :comments, required: true do
arg :id, required: true
arg :post_id, required: true
field :user, required: false do
arg :id, required: true
arg :name, required: true
field :company, required: false do
arg :name, required: true
end
end
end
field :user, required: true do
arg :id, required: true
arg :name, required: false
end
end
service.call
Here we decompose our code into a Base
class and two supporting classes - Argument
and Field
. This allows us to encapsulate our code into semantically meaningful units. The draw
method allows us to specify the fields and nested fields are captured within nested_fields
and illustrated using the call
method. As output, we will draw the tree of nested fields with arguments. It's useful if you want to write a class that will have a similar config schema like routes in Rails or a config file like popular gems have.
Example: An RSpec-like framework
Finally, let's look at an example of a test DSL that mimics the popular RSpec library:
module MyTestFramework
@its = []
class Equal
attr_accessor :value
def initialize(value)
self.value = value
end
def match?(value)
self.value == value
end
end
class To
attr_reader :value
def initialize(value)
@value = value
end
def to(equal)
unless equal.match?(value)
raise "Failed to match #{value}"
end
end
end
class It
attr_accessor :block, :name
def initialize(name, &block)
@block = block
@name = name
end
def expect(value)
To.new(value)
end
def eq(value)
Equal.new(value)
end
def execute!
puts name
instance_eval(&@block)
end
end
def self.describe(&block)
instance_eval(&block)
run_tests
end
def self.run_tests
@its.each(&:execute!)
end
def self.it(test_name, &block)
@its << It.new(test_name, &block)
end
end
def divide_me(mywords)
if mywords.is_a?(String)
p mywords.gsub(',', ' ').split(' ')
else
p 'errror!'
end
end
MyTestFramework.describe do
it 'it works for the test punctiation' do
expect(divide_me("one, two")).to(eq(["one", "two"]))
expect(1).to eq 1
end
end
Within the MyTestFramework
module, tests are executed in the context of a well-structured DSL similar to the syntax of RSpec. This syntax uses the structure describe -> it -> expect -> to -> eq.
- describe
method: It's a class method that accepts a block, it runs all the tests defined in that block. After defining the tests, the run_tests
method is called, which triggers all the tests.
- it
method: The it
method basically defines a test. It takes a name
or description
for the test and a block of code for the test. The block of test code is not executed immediately. Instead, it is stored in 'its' array for later execution.
- expect
method: expect
is an instance method within the It
class that takes a value
and instantiates the To
class with that value. This method is used to create an expectation by passing the result of a calculation to it.
- to
method: The to
method belongs to the To
class and is used in conjunction with expect
. It is used to accept a comparison value and confirm whether it matches the value passed in expect.
- eq
method: This method belongs to the It
class. It accepts a value and returns an instance of the Equal
class. This method is used in combination to
to specify the expected value of the comparison in a test.
Essentially, the execution of a test case is delayed until all test cases within the block passed to describe are defined. Once all the tests have been defined, they are executed individually as part of the run_tests
call. If a test passes, nothing happens. If a test fails (that is, the actual and expected values don't match), an error is raised with a fail message.
Summary
Creating a DSL in Ruby can be a powerful tool in the programmer's arsenal because of the way it optimizes readability and productivity. It demonstrates Ruby's versatility and its ability to abstract complex logic into clear, expressive syntax. The examples presented here are just a simple starting point, but the sky's the limit when it comes to what can be achieved with DSLs in Ruby.