ruby

Ruby Templating: Baking an Interpreter

Benedikt Deicke

Benedikt Deicke on

Ruby Templating: Baking an Interpreter

We hope you’ve got your stroopwafels warmed on top of your coffee because today we’re gluing things up with sticky stroop (the syrup that makes the two halves of a stroopwafel stick together). In the first two parts of our series, we baked a Lexer and a Parser and now, we’re adding the Interpreter and gluing things together by pouring stroop over them.

Ingredients

Alright! Let’s get the kitchen ready for baking and put our ingredients on the table. Our interpreter needs two ingredients or pieces of information to do its job: the previously generated Abstract Syntax Tree (AST) and the data we want to embed into the template. We’ll call this data the environment.

To traverse the AST, we’ll implement the interpreter using the visitor pattern. A visitor (and therefore our interpreter) implements a generic visit method that accepts a node as a parameter, processes this node and potentially calls the visit method again with some (or all) of the node’s children, depending on what makes sense for the current node.

1module Magicbars
2  class Interpreter
3    attr_reader :root, :environment
4
5    def self.render(root, environment = {})
6      new(root, environment).render
7    end
8
9    def initialize(root, environment = {})
10      @root = root
11      @environment = environment
12    end
13
14    def render
15      visit(root)
16    end
17
18    def visit(node)
19      # Process node
20    end
21  end
22end

Before continuing, let’s also create a small Magicbars.render method that accepts a template and an environment and outputs the rendered template.

1module Magicbars
2  def self.render(template, environment = {})
3    tokens = Lexer.tokenize(template)
4    ast = Parser.parse(tokens)
5    Interpreter.render(ast, environment)
6  end
7end

With this in place, we’ll be able to test the interpreter without having to construct the AST by hand.

1Magicbars.render('Welcome to {{name}}', name: 'Ruby Magic')
2# => nil

To no surprise, it currently doesn’t return anything. So let’s start implementing the visit method. As a quick reminder, here’s what the AST for this template looks like.

Article Illustration

For this template, we’ll have to process four different node types: Template, Content, Expression, and Identifier. To do this, we could just put a huge case statement inside our visit method. However, this will become unreadable pretty quickly. Instead, let’s make use of Ruby’s metaprogramming capabilities to keep our code a bit more organized and readable.

1module Magicbars
2  class Interpreter
3    # ...
4
5    def visit(node)
6      short_name = node.class.to_s.split('::').last
7      send("visit_#{short_name}", node)
8    end
9  end
10end

The method accepts a node, gets its class name, and removes any modules from it (check out our article on cleaning strings if you’re interested in different ways of doing this). Afterward, we use send to call a method that handles this specific type of node. The method name for each type is made up of the demodulized class name and the visit_ prefix. It’s a bit unusual to have capital letters in method names, but it makes the method’s intent pretty clear.

1module Magicbars
2  class Interpreter
3    # ...
4
5    def visit_Template(node)
6      # Process template nodes
7    end
8
9    def visit_Content(node)
10      # Process content nodes
11    end
12
13    def visit_Expression(node)
14      # Process expression nodes
15    end
16
17    def visit_Identifier(node)
18      # Process identifier nodes
19    end
20  end
21end

Let’s start by implementing the visit_Template method. It should just process all the statements of the node and join the results.

1def visit_Template(node)
2  node.statements.map { |statement| visit(statement) }.join
3end

Next, let’s look at the visit_Content method. As a content node just wraps a string, the method is as simple as it gets.

1def visit_Content(node)
2  node.content
3end

Now, let’s move on to the visit_Expression method where the substitution of the placeholder with the real value happens.

1def visit_Expression(node)
2  key = visit(node.identifier)
3  environment.fetch(key, '')
4end

And finally, for the visit_Expression method to know what key to fetch from the environment, let’s implement the visit_Identifier method.

1def visit_Identifier(node)
2  node.value
3end

With these four methods in place, we get the desired result when we try to render the template again.

1Magicbars.render('Welcome to {{name}}', name: 'Ruby Magic')
2# => Welcome to Ruby Magic

Interpreting Block Expressions

We wrote a lot of code to implement what a simple gsub could do. So let’s move on to a more complex example.

1Welcome to {{name}}!
2
3{{#if subscribed}}
4  Thank you for subscribing to our mailing list.
5{{else}}
6  Please sign up for our mailing list to be notified about new articles!
7{{/if}}
8
9Your friends at {{company_name}}

As a reminder, here’s what the corresponding AST looks like.

Article Illustration

There’s only one node type that we don’t yet handle. It’s the visit_BlockExpression node. In a way, it is similar to the visit_Expression node, but depending on the value it either continues to process the statements or the inverse_statements of the BlockExpression node.

1def visit_BlockExpression(node)
2  key = visit(node.identifier)
3
4  if environment[key]
5    node.statements.map { |statement| visit(statement) }.join
6  else
7    node.inverse_statements.map { |statement| visit(statement) }.join
8  end
9end

Looking at the method, we notice that the two branches are very similar, and they also look similar to the visit_Template method. They all handle the visiting of all nodes of an Array, so let’s extract a visit_Array method to clean things up a bit.

1def visit_Array(nodes)
2  nodes.map { |node| visit(node) }
3end

With the new method in place, we can remove some code from the visit_Template and visit_BlockExpression methods.

1def visit_Template(node)
2  visit(node.statements).join
3end
4
5def visit_BlockExpression(node)
6  key = visit(node.identifier)
7
8  if environment[key]
9    visit(node.statements).join
10  else
11    visit(node.inverse_statements).join
12  end
13end

Now that our interpreter handles all node types, let’s try and render the complex template.

1Magicbars.render(template, { name: 'Ruby Magic', subscribed: true, company_name: 'AppSignal' })
2# => Welcome to Ruby Magic!
3#
4#
5#  Please sign up for our mailing list to be notified about new articles!
6#
7#
8# Your friends at AppSignal

That almost looks right. But on a closer look, we notice that the message is prompting us to sign up for the mailing list, even though we provided subscribed: true in the environment. That doesn’t seem right…

Adding Support for Helper Methods

Looking back at the template, we notice that there’s an if in the block expression. Instead of looking up the value of subscribed in the environment, the visit_BlockExpression is looking up the value of if. As it is not present in the environment, the call returns nil, which is false.

We could stop here and declare that we’re not trying to imitate Handlebars but Mustache, and get rid of the if in the template, which will give us the desired result.

1Welcome to {{name}}!
2
3{{#subscribed}}
4  Thank you for subscribing to our mailing list.
5{{else}}
6  Please sign up for our mailing list to be notified about new articles!
7{{/subscribed}}
8
9Your friends at {{company_name}}

But why stop when we are having fun? Let’s go the extra mile and implement helper methods. They might come in handy for other things as well.

Let’s start by adding a helper method support to simple expressions. We’ll add a reverse helper, that reverses strings passed to it. In addition, we’ll add a debug method that tells us the class name of a given value.

1def helpers
2  @helpers ||= {
3    reverse: ->(value) { value.to_s.reverse },
4    debug: ->(value) { value.class }
5  }
6end

We use simple lambdas to implement these helpers and store them in a hash so that we can look them up by their name.

Next, let’s modify visit_Expression to perform a helper lookup before trying a value lookup in the environment.

1def visit_Expression(node)
2  key = visit(node.identifier)
3
4  if helper = helpers[key]
5    arguments = visit(node.arguments).map { |k| environment[k] }
6
7    return helper.call(*arguments)
8  end
9
10  environment[key]
11end

If there is a helper matching the given identifier, the method will visit all the arguments and try to lookup values for them. Afterward, it will call the method and pass all the values as arguments.

1Magicbars.render('Welcome to {{reverse name}}', name: 'Ruby Magic')
2# => Welcome to cigaM ybuR
3
4Magicbars.render('Welcome to {{debug name}}', name: 'Ruby Magic')
5# => Welcome to String

With that in place, let’s finally implement an if and an unless helper. In addition to the arguments, we’ll pass two lambdas to them so that they can decide if we should continue interpreting the node’s statements or inverse_statements.

1def helpers
2  @helpers ||= {
3    if: ->(value, block:, inverse_block:) { value ? block.call : inverse_block.call },
4    unless: ->(value, block:, inverse_block:) { value ? inverse_block.call : block.call },
5    # ...
6  }
7end
8

The necessary changes to visit_BlockExpression are similar to what we did with visit_Expression, only this time, we also pass the two lambdas.

1def visit_BlockExpression(node)
2  key = visit(node.identifier)
3
4  if helper = helpers[key]
5    arguments = visit(node.arguments).map { |k| environment[k] }
6
7    return helper.call(
8      *arguments,
9      block: -> { visit(node.statements).join },
10      inverse_block: -> { visit(node.inverse_statements).join }
11    )
12  end
13
14  if environment[key]
15    visit(node.statements).join
16  else
17    visit(node.inverse_statements).join
18  end
19end

And with this, our baking is done! We can render the complex template that started this journey into the world of lexers, parsers, and interpreters.

1Magicbars.render(template, { name: 'Ruby Magic', subscribed: true, company_name: 'AppSignal' })
2# => Welcome to Ruby Magic!
3#
4#
5#  Thank you for subscribing to our mailing list.
6#
7#
8# Your friends at AppSignal

Only Scratching the Surface

In this three-part series, we covered the basics of creating a templating language. These concepts can also be used to create interpreted programming languages (like Ruby). Admittedly, we glossed over a couple of things (like proper error handling 🙀) and only scratched the surface of the underpinnings of today’s programming languages.

We hope you enjoyed the series and if you want more of that, subsribe to the Ruby Magic list. If you are now hungry for stroopwafels, drop us a line and we might be able to fuel you with those as well!

Share this article

RSS
Benedikt Deicke

Benedikt Deicke

Guest author Benedikt Deicke is a software engineer and CTO of Userlist. On the side, he’s writing a book about building SaaS applications in Ruby on Rails.

All articles by Benedikt Deicke

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps