loading...

Write a simple DSL in Ruby

vinistock profile image Vinicius Stock ・4 min read

What is a DSL and what is it used for?

A DSL (Domain Specific Language) can be thought of as a dialect built for a unique purpose. At the bottom, it is a set of functions that are made available to be used inside the given domain.

The syntax used in the Gemfile, Rakefile or in tests written with rspec are some examples of DSLs in the Ruby world. They aim to provide well-defined interfaces for their tasks - declaring dependencies, defining make like tasks and writing unit tests, respectively.

Let's go through how one could go about creating a super minimalistic implementation of a bundler like tool for read a Gemfile. Please notice that the intention is to grasp the concepts and not have a working version of a package manager by the end.

How to create a DSL in Ruby

Surprisingly (at least for me), it isn't complex to define your own DSL with Ruby. Quite the opposite in fact. In our package manager example, it involves the following steps:

  1. Creating a class that defines all methods available in the DSL
  2. Reading the Gemfile
  3. Evaluating the Gemfile within the execution context of a given instance
  4. Using the evaluated information for installing the gems

These steps may sound obscure at first, but sit tight and they will soon make sense. Let's begin by creating the DSL class implementation. This class will contain all the methods that will be available for the Gemfile.

For simplicity, our DSL will only contain the gem directive - used to add a dependency. The implementation for other methods such as group or optional arguments such as require: false will be left out for simplicity. However, the concepts presented here should be a good starting point to reach richer functionality.

# dsl.rb

class Dsl
  attr_reader :gems

  def initialize
    # Initialize the list of gems
    # as an empty array.
    @gems = []
  end

  def read_gemfile
    # Read the contents of the gemfile as a string.
    contents = File.read("Gemfile")

    # Evaluate the Ruby string within the context
    # of the Dsl instance. That is, execute whatever
    # code found in the Gemfile using the state of
    # the current Dsl object.
    instance_eval(contents)
  end

  private

  # Gem directive definition
  # Adds the given gem_name to the list of
  # gems to be installed.
  #
  # This is the method executed whenever the Gemfile
  # contains the directive "gem 'something'"
  def gem(gem_name)
    @gems << gem_name
  end
end

We now have a minimal implementation of our Dsl class that can read the contents of the Gemfile and execute gem directives by adding the gem names to the @gems array.

All that is needed to add new directives to the DSL is creating new methods inside the DSL class. Every method in the class is made available to be used in our Gemfile. Furthermore, we can define a custom method_missing to choose how we want to handle invocations that don't exist in our DSL.

The extra succinct Gemfile below makes use of our gem directive and also uses some regular Ruby code. Notice that because we're executing the contents of the Gemfile within the Dsl instance, any Ruby code is valid.

# Gemfile

# Add 'rails' to the list of gems in the Dsl class.
gem "rails"

# Add 'rubocop' to the list of gems only if the environment
# variable RAILS_ENV matches 'development'.
if ENV["RAILS_ENV"] == "development"
  gem "rubocop"
end

Now that we have our Dsl implementation and our example Gemfile, we can write some pseudo-code that represents the much more complex installation process performed by [bundler].

# Create Dsl instance.
dsl = Dsl.new

# Read and evaluate the Gemfile populating
# the @gems variable.
dsl.read_gemfile

# For each defined gem, install it
# which usually involves download the code from
# the package registry (rubygems.org) and placing it
# inside the Ruby folder structure.
dsl.gems.each do |gem|
  Bundler.install_gem(gem)
end

Conclusion

That's all! I hope these few lines of code can shine a light on how easily a DSL can be created using Ruby. In the real world, DSLs can be much more complex, have a lot of elaborate syntax and numerous classes for achieving the full desired functionality.

Additionally, there are many security implications we haven't covered in this article. Because this approach executes Ruby code from a string, we need to be extra careful with any user input that might end up being evaluated by the DSL. Otherwise, it could lead to remote code execution and cause all sorts of vulnerabilities.

I hope this quick introduction provides enough context to play around and fosters interest around this topic. Let me know what you think in the comments. Have you written DSLs before?

Offtopic

I would like to thank everyone who reads my articles. I just reached 2000 followers and I'm stoked!

Be sure to also follow me on Twitter if you'd like bit sized programming comments or random pictures.

Posted on by:

vinistock profile

Vinicius Stock

@vinistock

Dev @ Shopify. Ruby & Rails open source contributor

Discussion

markdown guide