The Hidden Gems of Ruby's IRB

A deep dive into Ruby's source for IRB

Feb 18, 2021 | Valentino Stoll

BTW, we're ⚡ hiring Infra, SRE, Web, Mobile, and Data engineers at Doximity (see roles) -- find out more about our technical stack.


The Hidden Gems of Ruby's IRB

The hidden gems that make Ruby a delight are not always... gems. There are so many Ruby features built in to the language itself. IRB is a perfect example of this, as one of the most underrated features baked right into Ruby. Whether you're willing to admit it or not, this is where every Rubyist starts their adventure (thank you for showing me the light TryRuby!). The ability to interactively evaluate code before it is "complete" is arguably one of the most important aspects of any language; and even if you deny ever using it, chances are good whatever you were using was, in fact, wrapped up in IRB (here's looking at you Rails console :wink:).

There are so many great articles on using IRB, but this will focus on the bits nestled in the Ruby source. So, out of curiosity, let's crack it open and dig into the details of this wonderful gem nestled inside Ruby.

Feel free to skip to the parts that interest you:

  1. High-Level IRB
  2. How IRB evaluates code
  3. Multiple Sessions
  4. Loading Files With IRB
  5. Tracing execution
  6. New Features in 2.7
  7. New Features in 3.0
  8. Customizing IRB
  9. Easter Egg!
  10. More Built-in Commands

Note: All links provided to the Ruby source from here on out were based on the latest commit at the time of this writing (Jan 28, 2021).

High-Level IRB

Workspace

⬇️

Irb (Session)

⬇️

Context

⬇️

Binding

The IRB Workspace

At the core of IRB is the top-level Workspace. This keeps track of the current Session, the Session keeps track of the current Context, and the Context keeps track of the execution context at a particular place in the program (Binding).

For the most part, you're unlikely to use a Workspace directly. IRB uses workspaces under the hood when starting a session from a file. An interesting feature of workspaces though, is that you can evaluate statements within a higher-level binding.

One thing I found potentially useful with Workspaces is with the use of two IRB extensions: workspaces.rb, and change-ws.rb. Using these two extensions allows you to push objects to their own workspace with irb_push_workspace some_object allowing you to explore from within the object itself. A benefit of this is that you can easily switch between which object you are inside of with irb_change_workspace some_other_object.

irb(main):001:0> self
=> main
irb(main):002:0> irb_workspaces
=> []
irb(main):003:0> slimer = Struct.new(:name).new("Slimer")
=> <struct name="Slimer">
irb(main):004:0> venkman = Struct.new(:name).new("Venkman")
=> #<struct name="Venkman">
irb(main):005:0> irb_push_workspace slimer
=> [main]
irb(<struct name="Slimer">):006:0> self
=> #<struct name="Slimer">
irb(<struct name="Slimer">):007:0> name
=> "Slimer"
irb(<struct name="Slimer">):008:0> irb_push_workspace venkman
=> [main, <struct name="Slimer">]
irb(<struct name="Venkman">):009:0> self
=> #<struct name="Venkman">
irb(<struct name="Venkman">):010:0> name
=> "Venkman"
irb(<struct name="Venkman">):011:0> irb_change_workspace slimer
=> #<struct name="Slimer">
irb(<struct name="Slimer">):012:0> name
=> "Slimer"

The IRB Context - a.k.a. Session(s)

IRB Sessions are the meat and potatoes of it all. The Session houses all the incredible configurations at your disposal. It is in charge of evaluating the IO you provide it (whether it be a File, String, or STDIN).

Before we dive into the magic of handling multiple sessions, let's take a closer look at how the IRB evaluation process works.

How IRB evaluates code

IRB depends on two other included Gems to process, analyze, and evaluate Ruby code in a Session: reline, and ripper.

reline – A pure ruby alternative for GNU Readline

IRB uses reline for many calculations performed on the input provided to an IRB session. Reline was introduced to IRB for the new pretty multi-line input introduced in Ruby 2.7, but comes with extra benefits, like restoring history on subsequent runs and handling auto-completion tokens. If you're curious how the history works, check out the save-history IRB extension. If you want to dive even further, I recommend checking out the input-method IRB extension. The ReidlineInputMethod class used in the save-history extension essentially wraps input with reline. There are a few other input methods IRB supports in there for handling generic IO and File(s). We'll get into more details on some ways IRB uses these.

This is a screen capture of IRB improved by Reline.

multi-line input using reline

via Reline's README

ripper – A lexical analyzer and AST generator

Ripper comes with a native C-extension that can generate abstract syntax trees or perform simple lexical analysis. In the context of IRB, ripper is mostly used for lexical analysis of the Ruby code you feed it. It does this using the RubyLex class. This class does more than just analyzing Ruby input, but it also handles compiling code blocks via RubyVM::InstructionSequence.compile (or JRuby.compile_ir when using JRuby).

A simple example (taken from the docs) parsing a hello world method:

require 'ripper'
require 'pp'

pp Ripper.sexp('def hello(world) "Hello, #{world}!"; end')
#=> [:program,
     [[:def,
       [:@ident, "hello", [1, 4]],
       [:paren,
        [:params, [[:@ident, "world", [1, 10]]], nil, nil, nil, nil, nil, nil]],
       [:bodystmt,
        [[:string_literal,
          [:string_content,
           [:@tstring_content, "Hello, ", [1, 18]],
           [:string_embexpr, [[:var_ref, [:@ident, "world", [1, 27]]]]],
           [:@tstring_content, "!", [1, 33]]]]],
        nil,
        nil,
        nil]]]]

Multiple Sessions

IRB has a special feature that allows you to manage many sessions at once using it's native multi-irb extension and the subirb command. This works similarly to Unix jobs and allows you access to them in similar ways using the JobManager.

command what it does
jobs lists all subsessions
fg Jump to session
kill Kill subsession
exit Terminate the current subsession
irb(main):001:0> jobs
=> #0->irb on main (<Thread:0x00007fb54387c0d0 run>: running)
irb(main):002:0> irb Struct.new(:name).new("Slimer")
irb(1)(<struct name="Slimer">):001:0> jobs
=>
#0->irb on main (#<Thread:0x00007fb54387c0d0 sleep_forever>: stop)
#1->irb#1 on #<struct name="Slimer"> (#<Thread:0x00007fb53305ffd8 /Users/codenamev/.rubies/ruby-3.0.0/lib/ruby/3.0.0/irb/ext/multi-irb.rb:192 run>: running)
irb(1)(<struct name="Slimer">):002:0> irb Struct.new(:name).new("Venkman")
irb(2)(<struct name="Venkman">):001:0> jobs
=>
#0->irb on main (#<Thread:0x00007fb54387c0d0 sleep_forever>: stop)
#1->irb#1 on #<struct name="Slimer"> (#<Thread:0x00007fb53305ffd8 /Users/codenamev/.rubies/ruby-3.0.0/lib/ruby/3.0.0/irb/ext/multi-irb.rb:192 sleep_forever>: stop)
#2->irb#2 on #<struct name="Venkman"> (#<Thread:0x00007fb54393fbc0 /Users/codenamev/.rubies/ruby-3.0.0/lib/ruby/3.0.0/irb/ext/multi-irb.rb:192 run>: running)
irb(2)(<struct name="Venkman">):002:0> kill 1
=> [1]
irb(2)(<struct name="Venkman">):003:0> exit
=> #<IRB::Irb: @context=#<IRB::Context:0x00007fb5428f2948>, @signal_status=:IN_EVAL, @scanner=#<RubyLex:0x00007fb533037970>>
irb(main):003:0> jobs
=> #0->irb on main (#<Thread:0x00007fb54387c0d0 run>: running)
irb(main):004:0>

Loading Files With IRB

IRB comes with a handy loader extension to make it convenient to load files into an IRB session in different ways.

The loader IRB extension uses the FileInputMethod we talked about earlier for loading files into an IRB session's context and evaluating it. This is especially useful to pre-load other files we want to be available when loading the IRB session or when code you want to interact with is too long to copy/paste. This can be done with IRB's CLI using the -r option.

Given a ruby file:

# ./hi.rb
puts "Hi #{`whoami`}!"

Now when we fire up an IRB session loading our hi.rb, we are greeted with a friendly message:

$ irb -r ./hi.rb
Hi codenamev!
irb(main):001:0>

This is a great way to test out pieces of your code in isolation!

Another great feature baked into this loader extension are the line-by-line evaluator helpers irb_load and irb_source.

Let's say we have this super friendly ruby file:

# ./friendly.rb
high_fives = 3
puts "Hello Ruby friend!"
high_fives.times do
  puts "High five!"
end
puts "Nice to meet you."

Now, if we load this in an IRB session we can see the line-by-line execution of the file in our output:

irb_load

Tracing execution

Another handy command built into IRB is tracer. It uses another built-in gem (also named tracer) to output source-level execution trace of a Ruby program. This is useful for seeing the bigger picture for methods that depend on many other objects or methods.

From their example, let's say we have the following Ruby file:

# square.rb
class Calcs
  def square(a)
    return a*a
  end
end

c = Calcs.new
c.square(5)

Now we can turn on tracing in IRB, and load our example file to see the line-by-line execution process.

irb(main):001:0> context.use_tracer = true
=> true
irb(main):002:0" require_relative "square"
0:(irb):2::-: require_relative "square"
0:/Users/codenamev/square.rb:1::-: class Calcs
0:/Users/codenamev/square.rb:1::C: class Calcs
0:/Users/codenamev/square.rb:2::-:   def square(a)
0:/Users/codenamev/square.rb:5::E: end
0:/Users/codenamev/square.rb:7::-: c = Calcs.new
0:/Users/codenamev/square.rb:8::-: c.square(5)
0:/Users/codenamev/square.rb:2:Calcs:>:   def square(a)
0:/Users/codenamev/square.rb:3:Calcs:-:     return a*a
0:/Users/codenamev/square.rb:4:Calcs:<:   end
=> true
irb(main):003:1* Calcs.new.square(3)
0:/Users/codenamev/square.rb:2:Calcs:>:   def square(a)
0:/Users/codenamev/square.rb:3:Calcs:-:     return a*a
0:/Users/codenamev/square.rb:4:Calcs:<:   end
=> 9

New Features in 2.7

Introducing colors!
Tab-completion and Double-tab documentation!

Go ahead and re-enable RDoc downloads in your local .gemrc! Now in an IRB session, when the TAB key is pressed twice you'll get a lovely display of documentation for the object under the cursor.

tab completion example

Details of how tab completion works can be found in the following sources:

New Features in 3.0

Measure – Output processing time

The new measure IRB extension allows you to easily toggle the output of how long things take to process.

irb(main):001:0> measure
TIME is added.
=> nil

irb(main):002:0> sleep 1
processing time: 1.004886s
=> 1

irb(main):003:0> puts "Measure me."
Measure me.
processing time: 0.000043s
=> nil

irb(main):004:0> measure :off
=> nil

If it will be a while before you're on 3.0, don't fret, you can get similar results using Ruby's native benchmark.

Customizing IRB

IRB is super duper extensible. Out of the box, it comes with many customizations. And while we won't cover it explicitly here, you can even configure how and where your history is saved.

Much like that of your shell, the IRB prompt can be customized as well. At Doximity, we have a few customizations I'd like to share that make working with IRB a little more enjoyable.

But first, let's start with the basics: customizing the prompt.

If you don't supply a file to load from, IRB will default to loading the a global .irbrc file located in your home directory. The great thing about the RC file is that we can write any Ruby we want to load by default when IRB starts. Let's say we make the following:

# ~/.irbrc
# This gem needs to installed
require 'rainbow'

def me; `whoami`; end

def app_prompt
  Rainbow("⚡").yellow
end

def env_prompt
  "#{Rainbow("%N").yellow}.#{Rainbow("%m").bright}"
end

prompt = "#{app_prompt}#{env_prompt}"

IRB.conf[:PROMPT][:BANG] = {
  PROMPT_I: "#{prompt} ⟩ ", # normal prompt
  PROMPT_S: "#{prompt} ✌️  ", # prompt for continuing strings
  PROMPT_C: "#{Rainbow("%02n").bold} … ", # Prompt for continuing statement
  RETURN: "✨ %s\n" # Format to return value 📣
}
IRB.conf[:PROMPT_MODE] = :BANG

Now when we open up an IRB session, we'll get our new bangin' prompt!

Custom IRB Prompt

Making it your own: A Custom Rails Console

Under the hood, Rails extends IRB in a somewhat similar way. At Doximity, we've added some of our own customizations:

  1. A label in the prompt for which environment the console has open.
  2. Automatically mark any data changes as made by the logged in user.

We keep these changes in an initializer:

module IRBExtension
  def start
    configure_irb
    configure_auditing
    super
  end

  def configure_irb
    IRB.conf[:PROMPT] ||= {}
    prompt = "#{irb_env}:%03n:%i"
    IRB[:PROMPT][:CUSTOM] = {
      PROMPT_I: "#{prompt}> ",
      PROMPT_N: "#{prompt}> ",
      PROMPT_S: "#{prompt}%l ",
      PROMPT_S: "#{prompt}* ",
      RETURN: "=> %s\n"
    }
  end

  def irb_env
    case Rails.env
    when development
      Rainbow("development").green
    when "production"
      Rainbow("production").red
    else
      Rainbow(Rails.env.to_s).yellow
    end
  end

  def configure_auditing
    warn(audit_setup_instructions) and return if audit_login.blank? || audit_user_id.blank?

    warn "User Auditing now setup to assign all changes made to a user with you: #{audit_login}@example.com."
    Thread.current.thread_variable_set(:audit_user_id, audit_user_id)
  end

  def audit_user_id
    @audit_user_id ||= User.select(:id).where(login: "#{audit_login}@example.com").id
  end

  def audit_login
    @audit_login ||= ENV.fetch("LOGGED_IN_USER", nil)
  end

  def audit_setup_instructions
    <<~INSTRUCTIONS
    In order to ensure changes you make are properly associated with your account,
    please run the following command with your User's id in place of <user_id>:
    Thread.current.thread_variable_set :audit_user_id, <user_id>
    INSTRUCTIONS
  end
end

module Rails
  class Console
    prepend IRBExtension
  end
end

With this lovely magic, we're able to accomplish our two goals. First, we signal to developers what environment they're working in. Sometimes there's a need to jump into production, or a staging environment, and this is a great way to quickly tell you, "Hey! Be careful, you're DOING IT LIVE!"

Second, we establish accountability for production data changes. In order to make this work, we have a few pre-requisite assumptions:

  1. All engineers with access to the console have a User with a login
  2. The above login is the same used to log into the machine hosting the console
  3. The above login is also accessible from an environment variable.

With those assumptions, we're able to pre-fetch a user in our application based on the user that is logged into the machine running the Rails console. We then assign this user's id in a threaded variable that we can then use elsewhere in our code to audit any changes made to the database.

If you're looking for more Rails Console fun, check out Amy Hoy's great article, "Secrets of the Rails Console Ninjas".

Easter Egg!

easter egg

More Built-in Commands

If by now you still haven't gotten enough, there's more! While not too much more, it might be worth checkout out some of these other built-in commands to see if you have a use for them:

  • inspector.rb – Build your own Inspector to use in your sessions.
  • notifier.rb – This is the output formatter used internally by the lexer, but could be used to extend your own.
  • xmp.rb – An example printer for IRB similar to PrettyPrint.
  • magic-file.rb – ??

Community Cheers

The following articles were instrumental in many aspects of making this post come true:

Wrapping it up

If you've take anything away from this, it should be that the Ruby source is not so scary! It's not all C code. There's quite a lot of Ruby code. And while it may be nice to reach for those external gems and tools like Pry, chances are you're missing out on what's already there.


Be sure to follow @doximity_tech if you'd like to be notified about new blog posts.