In Progress
Unit 1, Lesson 21
In Progress

Building a REPL

If you’re anything like me you use a read-eval-print-loop such as IRB or Pry as a daily assistant to your Ruby development process. But how do these tools work, anyway? In today’s episode, guest chef Adam Fernung is going to show you how to build a simple REPL of your own. In the process, you’ll learn more about how Ruby’s dynamic variable reflection works—which will give you a better idea of how to debug Ruby code and create development tools of your own. Enjoy!

Video transcript & code

Debugging applications is part of every developer's job and tools like Pry and IRB are invaluable tools in many Ruby developers' tool box. That said, it's easy to take these tools for granted as they've become ubiquitous across Ruby environments. In today's episode, we're going to take a look at how seemingly magical statements such as binding.pry and binding.irb use simple constructs to build powerful debugging environments.


my_binding = 'RubyTapas'.instance_eval('binding')  => #

I'm sure it comes as no suprise that at the heart of statements such as binding.irb is the Binding class. This class is responsible for encapsulating the context in which the Kernel#binding method is called. When we call this from within an object, we'll receive an instance of Binding that contains references to local variables, methods and the value of self.


my_binding.eval('self') => 'RubyTapas'
my_binding.eval('upcase') => 'RUBYTAPAS'

We can then use this object to evaluate statements as if we were executing them from within the object directly.

Let's see how we can use this class to build a simple REPL.


class TapasRepl
end

First, let's define a TapasRepl class that will encapsulate our REPL functionality.


class TapasRepl
  attr_accessor :current_expression, :available_bindings
end

It'll keep track of the expression we're currently evaluating and the bindings that are available for us to evaluate against.


class TapasRepl
  attr_accessor :current_expression, :available_bindings

  def initialize(initial_binding)
    @available_bindings = [initial_binding]
  end
end

Next, we'll define an initializer that will accept one argument, which will be main's binding. This will be the first binding that our REPL will be able to evaluate statements against.


class TapasRepl
  attr_accessor :current_expression, :available_bindings

  ...

  def start
  end
end

Next, we'll want to define a start method that will be responsible for the orchestration of the overall process of Read, Evaluate, Print and Loop.

We start with a loop.


def start
  loop do
  end
end

Inside the loop, we first print the REPL's prompt


def start
  loop do
    print(prompt)
  end
end

and then gather the user's input while stripping any new line characters from the end.


def start
  loop do
    print(prompt)
    return if (self.current_expression = gets.strip.chomp) == "!!!"
  end
end

Finally, we compute the result


def start
  loop do
    print(prompt)
    return if (self.current_expression = gets.strip.chomp) == "!!!"
    result = compute_result
  end
end

and print it, and start the the process over again.


def start
  loop do
    print(prompt)
    return if (self.current_expression = gets.strip.chomp) == "!!!"
    result = compute_result
    puts result.to_s
  end
end

Let's first define a prompt method that will return a string that the user will see as the prompt of our REPL.


   def prompt
   end

If there is only 1 available binding,


   def prompt
    current_position = available_bindings.length === 1
   end

then we know it must be main since that's what we initialized our TapasRepl instance with.


   def prompt
    current_position = available_bindings.length === 1 ? 'main'
   end

Otherwise, we can leverage Binding#receiver to obtain the class name from the object in which we obtained our Binding instance.


   def prompt
    current_position = available_bindings.length == 1 ? 'main' : current_binding.receiver.class.name
    "tapas(#{current_position})> "
   end

Next, we'll define a compute_result method.


def compute_result
end

We'd like our REPL to allow users to cd into objects much like they would a directory in a file system.

When we compute the result of the user's input, we'll first check to see if they're looking to cd into an object.


def compute_result
  if current_expression.match?(/cd\s*/)
end

If so, we need to obtain the binding from the object they're trying to cd into and push it into the available_bindings array.


def compute_result
  if current_expression.match?(/cd\s*/)
    push_new_binding && nil
end

We'll do that in the push_new_binding method.


  def push_new_binding
  end

This method will first parse a user's input that we expect to look something like cd {some object}


# cd 

In order to do that, we use sub with a regular expression to extract the target object name.


  def push_new_binding
    current_expression.sub(/cd\s+/, '')
  end

Then we evaluate the resulting string within the binding to obtain access to the object we want to drop into.


  def push_new_binding
    target_obj = current_binding.eval(current_expression.sub(/cd\s+/, ''))
  end

Next we'll retrieve the binding using instance_eval since it's a private method and then push it into the available_bindings array.


  def push_new_binding
    target_obj = current_binding.eval(current_expression.sub(/cd\s+/, ''))
    available_bindings << target_obj.instance_eval('binding')
  end

Next, if the current expression is 'exit', it means the user would like to leave the current context in which they're evaluating.


def compute_result
  if current_expression.match?(/\Acd\s+.*\z/)
    push_new_binding && nil
  elsif current_expression == 'exit'
  end
end

In this case, we need to remove the most current binding from available_bindings

We'll do this in the pop_binding method.


  def pop_binding
    available_bindings.pop
  end

Finally, if none of the conditions apply, we should simply evaluate the current expression against the most recent binding.


def compute_result
  if current_expression.match?(/\Acd\s+.*\z/)
    push_new_binding && nil
  elsif current_expression == 'exit'
    pop_binding && nil
  else
    current_binding.eval(current_expression)
  end
  rescue Exception => e
    return e.message
end

Notice that we're rescuing from the Exception class. Under normal circumstances, this would be considered bad practice, but in this case, we want to rescue all exceptions and return them to the user.


  def current_binding
    available_bindings[-1]
  end

The current_binding method is a utility method that returns the binding we should be evaluating against. The available_bindings property acts as a stack in which we'll always evaluate expressions against the most recently inserted binding.


$ ruby -r ./tapas_repl.rb -e 'TapasRepl.new.start'

Let's play with this. We'll start our repl...

And inside it let's create a tiny adventure game...

We'll need a Room class with a name and attributes for exits in different directions.


Room = Struct.new(:name, :left, :right, :front, :back)
# => Room

Notice how the REPL displays the object we just defined.

Now let's create our first room, a living room.


living_room = Room.new("Living Room")
=> #

And connected to our living room, will be a bedroom.


living_room.left = Room.new("Bedroom")
=> #

Now let's cd into our living room


cd living_room

Notice how our prompt has changed to


tapas(Room)>

Now we can evaluate the attributes of our living room directly.


left
=> #

And return to the main context when we're done.


exit
=> tapas(main)>

As you can see, Ruby's Binding class is at the center of Ruby's debugging tools. It's easy to assume that great tools like Pry are built entirely on top of magic, but rather, they're cleverly built tools using Ruby's ingenious Binding class.

Responses