Rails Console-like Environment for a Plain Ruby Project

I've been working on a Ruby project without Rails. It's a CLI tool that simulates retirement drawdown strategies for Canadians (I'll write a future blog post with more details on that). While building it, I found myself missing the convenience of the Rails console (bin/rails console
), which loads all application code, for interactive exploration and debugging.
In my project, for instance, I have both a tax calculator and a reverse tax calculator. Beyond running unit tests, it's often helpful to poke at these classes interactively. I also have market return sequence generators that I sometimes want to explore directly. A console makes this kind of exploratory coding easy, not a substitute for tests, but a fast way to validate ideas.
This post will explain how I setup a Rails-like console environment, for a plain Ruby project.
Project Structure
Here is my project structure (ignoring documentation and test folders). Aside from the main.rb
entrypoint, all the code is organized in the lib
folder, with sub-folders under that:
.
├── Gemfile
├── Gemfile.lock
├── README.md
├── lib
│ ├── account.rb
│ ├── app_config.rb
│ ├── first_year_cash_flow.rb
│ ├── numeric_formatter.rb
│ ├── output
│ │ ├── console_plotter.rb
│ │ └── console_printer.rb
│ ├── return_sequences
│ │ ├── base_sequence.rb
│ │ ├── constant_return_sequence.rb
│ │ ├── geometric_brownian_motion_sequence.rb
│ │ ├── mean_return_sequence.rb
│ │ └── sequence_selector.rb
│ ├── run
│ │ ├── app_runner.rb
│ │ ├── simulation_detailed.rb
│ │ └── simulation_success_rate.rb
│ ├── simulation
│ │ ├── simulation_evaluator.rb
│ │ └── simulator.rb
│ ├── strategy
│ │ ├── rrif_withdrawal_calculator.rb
│ │ ├── rrsp_to_taxable_to_tfsa.rb
│ │ └── withdrawal_planner.rb
│ ├── tax
│ │ ├── income_tax_calculator.rb
│ │ └── reverse_income_tax_calculator.rb
│ ├── withdrawal_amounts.rb
│ └── withdrawal_rate_calculator.rb
└── main.rb
Before getting into the automated solution, let's cover how you could load and run an individual class from a project manually.
In any Ruby project (Rails or otherwise), you can start an interactive Ruby session with irb. Since irb
ships with Ruby itself, there’s nothing extra to install - if you have Ruby, you have irb
. Out of the box, though, it doesn't know anything about your project's code, even if you launch it from the project's root directory. For example:
# Try to instantiate the tax calculator
irb(main):004> tx = Tax::IncomeTaxCalculator.new
# (irb):1:in `<main>': uninitialized constant Tax (NameError)
# tx = Tax::IncomeTaxCalculator.new
# ^^^
# from <internal:kernel>:187:in `loop'
This could be resolved by running require_relative ...
in the irb session, for one specific file.:
irb(main):002> require_relative "lib/tax/income_tax_calculator"
=> true
# Now the tax calculator class can be used
tx = Tax::IncomeTaxCalculator.new
# => <Tax::IncomeTaxCalculator:0x000000011def7270
# ...
But this is tedious to have to do every time you want to experiment with some of your project code. It would be nice if all the project code was always available, any time you ran irb
from your project root.
The next sections will walk through how to set this up.
Define config/environment.rb
The first step is to create a config
directory, and an environment.rb
file in that directory:
mkdir config
touch config/environment.rb
Edit config/environment.rb
to load all project dependencies from the Gemfile, and all source files in the lib
directory:
# frozen_string_literal: true
# Load all dependencies from Gemfile or standard Ruby library
require "descriptive_statistics"
require "tty-progressbar"
require "yaml"
# ...
# Load all project source files from lib dir and its subdirectories
Dir.glob(File.expand_path("../lib/**/*.rb", __dir__)) { |file| require file }
That one line Dir.glob(File.expand_path("../lib/**/*.rb", __dir__)).each { |file| require file }
is doing all the heavy lifting of loading the project source.
Explanation:
__dir__ is a built-in method provided by the Kernel module. It returns the absolute path of the directory containing the current file. Since it's being called in the config/environment.rb
file, it's value will be /path/to/project/config
.
File.expand_path converts a pathname to an absolute pathname. When given an optional dir_string
argument, which we're doing here by passing in __dir__
, it uses the dir_string
as a starting point. But the first argument we're passing in says to go up one directory. So this will return /path/to/project/lib/**/*.rb
.
Dir.glob expands it's first argument, which in our case is a pattern string /path/to/project/lib/**/*.rb
. It returns an array of all matching file names, which in this case will be all Ruby files contained in the lib
directory and all of its subdirectories.
Finally, when given a block, Dir.glob
will execute that block for each file matching the pattern. In our case, we pass in a block to require
the file. When this code runs, all Ruby files in the project's lib
directory will be available in memory.
You don’t have to place this file in config
or name it environment.rb
- this is just a suggestion for organizing code. Feel free to name it and place it wherever makes sense for your project.
Create Project Specific .irbrc
Now that we have the potential to load all project dependencies from a single file, the next step is to ensure this file is always run when starting an irb session. To achieve this, create a .irbrc
file in the project directory. This provides an opportunity to customize the behaviour of irb
when started from the project root.
Edit the project level .irbrc
file so it has the following. Note that any valid Ruby can be placed in this file:
# Load all the project source
require_relative "config/environment"
puts "Loaded application"
# Optionally customize the prompt
IRB.conf[:PROMPT][:APP] = {
PROMPT_I: "drawdown_simulator> ", # Standard input prompt
PROMPT_N: "drawdown_simulator* ", # Multiline input
PROMPT_S: "drawdown_simulator%l ", # String continuation
PROMPT_C: "drawdown_simulator? ", # Indentation level
RETURN: "=> %s\n" # Format of return value
}
IRB.conf[:PROMPT_MODE] = :APP # Set custom prompt
Now when you run irb
at the terminal, it will run all the code in .irbrc
in the project root. If you chose to customize the prompt, it will display the project name instead of the default irb(main)>
prompt.
All project classes will be loaded, so you can now interact with them, without having to explicitly load them. Additionally, irb's built-in autocomplete will help you quickly explore your project's classes and methods. For example:
Optionally, you can add a reload!
method to the .irbrc
file. If you're used to this behavior from Rails, it's not part of IRB:
# .irbrc
# other code...
def reload!
exec "irb"
end
Now if you make any changes to application code, rather than being forced to exit and run irb
again, running reload!
will refresh the code loaded in memory.
Optional Bin Script
If rather than running irb
, you're used to the "muscle memory" of running a bin
script, as has become standard on Rails projects, you can layer that on with the irb customization as follows:
Create a bin
directory and a console
file in that directory, which needs to be executable:
mkdir bin
touch bin/console
chmod +x bin/console
Edit bin/console
as follow:
#!/usr/bin/env ruby
require "irb"
# This will use project level `.irbrc` so no need to load config/environment here.
IRB.start
Now, you can start an interactive session with:
bin/console
It will behave the same as having run irb
.
Alternative
If for whatever reason you don't want to commit a custom .irbrc
file into the project root - maybe it conflicts with other custom settings in your home ~/.irbrc
or each team member prefers to maintain their own version, an alternative approach is to load the config/environment.rb
in bin/console
like this:
#!/usr/bin/env ruby
# frozen_string_literal: true
require "irb"
require_relative "../config/environment"
IRB.start
Now when running bin/console
, it will load all the project source files.
Other Uses For config/environment.rb
A benefit of having taken the time to construct config/environment.rb
is that it can be used in the project entrypoint and in a test helper. This avoids manually requiring lists of files in multiple places throughout the project. For example, main.rb
:
require_relative "config/environment"
# Whatever code starts your project...
Run::AppRunner.new("inputs.yml", ARGV[0]).run
If using RSpec for testing, then spec/spec_helper.rb
can require the config right after loading rspec
:
require "rspec"
require_relative "../config/environment"
RSpec.configure do |config|
# ...
end
Summary
This post has covered several approaches for providing a Rails-like console experience for any Ruby project, making interactive debugging and exploration just as easy as when using Rails. We started by defining config/environment.rb
to load all project dependencies in one place. Then covered options for a project-specific .irbrc
file and/or a bin
script. If you’re working on a CLI tool or any non-Rails Ruby project, consider adding a console to your workflow.