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