Introducing Fir, the Friendly, Interactive Ruby REPL
I haven’t been posting here much because most of my free time has been spent working on a Ruby REPL that I call Fir, which stands for friendly-interactive-Ruby. The Ruby community has no shortage of great REPL’s — the default irb is pretty good, and pry offers a fantastic REPL as well as an interactive debugger that really highlights some of the great features of the Ruby language. In my daily work, I also really enjoy some of the autosuggestion features that the fish shell offers, so much so that I got to thinking about integrating those features into a Ruby REPL. If you’re not familiar with the fish shell, this is what some of those features look like in action:
As you can see, fish offers as you type autocompletion based on your history, programs in your path, and directories on your machine. It’s really quite clever, and anytime I’m using another shell, I tend to miss it. Fir does something similar, by suggesting lines from your .irb_history file:
Fir not only offers suggestions as you type, it also indents and dedents code as you type it. Take a look at how it handles this begin/rescue block:
As you can see, Fir is smart enough to dedent the rescue keyword as soon as you type it. The difference between Fir and a REPL like pry (or most others) is that Fir parses the syntax and re-renders the screen every time it receives a key input, as opposed to only when the program receives select inputs like tab or enter. Since every keystroke triggers a loop and a full re-render, we can continuously update the screen with new indents, suggestions, and in the future, colorization. Like other REPLs, Fir only triggers a full evaluation of the inputted code when it forms a self contained executable block, and the user presses enter.
Implementing as you type updates with raw input
In order to achieve the functionality described above inside our command line programs, we need to leverage “raw” terminal drivers. Terminals and REPLs usually use “cooked”, or canonical, input. With cooked input characters are buffered internally until a carriage return is inputted to the program, at which point the line is processed. Cooked functionality is actually what allows for certain characters to be treated as special, e.g. control sequences or backspace. It is on this system that programs usually base their line editing. If instead we want to manually handle characters as the program receives them, and also prevent them from being echoed to the screen, than we need to force the terminal to use a raw driver. In Ruby we can force stdin or stdout into raw mode as follows:
If you run the above program and start typing, you will see nothing happens on the screen, and thats good! The program is fetching the raw characters, and preventing their default behavior, which in this case, is rendering them to the screen. This is essentially what Fir is based on, fetching raw input, processing it, and then manually drawing to the screen using ANSI escape sequences to do things like erase or move the cursor. It also means that we can handle the characters as we receive them, instead of waiting for a carriage return.
Now as I mentioned before, you may have noticed that trying to ctrl-d or exit the program using the standard process control characters did not work. Sorry about that! You see one of the downsides of raw terminal drivers is that you have to manually handle everything. In this case, those standard process control characters are doing nothing to end the program, so you are going to have to send that runaway Ruby process a kill signal ;).
Capturing escape keys
Certain keys, such as the arrow keys, are prefixed by an “escape” sequence. These escape characters are actually not characters at all, but strings that begin with the escape character “\e”. For instance, the left arrow character is represented by “\e[D”. Let’s modify our raw input example to inspect and display the character we have trapped, and see what a left arrow key looks like.
What happened here? Well if we read the documentation for getc we see that getc “Reads a one-character string from ios”, and this is not a one character string! This posed a substantial challenge for Fir, since the left arrow was broken down into its component characters, my program behaved as if the user had just hit escape, and then left bracket, and then d. Not what we want.
I stumbled into a solution here. The solution is to spawn an additional thread when the program detects an escape key where we call getc on the input twice, and then kill the thread very quickly. If the the escape was the beginning of the long character sequence, as in the case of left arrow, we are able to trap it, and if not the thread is killed and the program isn’t blocked waiting for more input. Here’s what that solution looks like inside the Fir key class.
input.raw do|raw_input| key = raw_input.sysread(1).chr
if key ==“\e” skt =Thread.new { 2.times { key += raw_input.sysread(1).chr } }
skt.join(0.0001)
skt.kill
end key
end