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.