Published 2025-09-25.
Time to read: 3 minutes.
ruby
collection.
Highline
Highline
is a gem that is often found in CLIs that are built with OptionParser
.
It provides a way to ask the user questions and get answers,
similar in syntax to methods that Thor provides for the same purpose.
The agree
method
is particularly useful.
Both the agree
and
ask
methods
have a quirky feature:
putting a space at the end of the question string suppresses the newline between the question and the answer.
The optional character
parameter causes the first character that the user types to be immediately
processed without requiring them to press Enter.
$ irb irb(main):001> require 'highline' => true irb(main):002* begin irb(main):003* printf "Work work work" irb(main):004> end while HighLine.agree "\nAll done! Do you want to do it again? ", character = true Work work work All done! Do you want to do it again? Please enter "yes" or "no". All done! Do you want to do it again? y Work work work All done! Do you want to do it again? y Work work work All done! Do you want to do it again? n => nil irb(main):005>
Workalike
Below is some code that I wrote for
Nugem.
This could be hived into a separate gem, if someone was to ask.
The import
statement
imports the methods of the Highline library directly into the current namespace.
require 'highline/import' module HighlineWrappers def yes_no?(prompt = 'More?', default_value: true) answer_letter = '' suffix = default_value ? '[Y/n]' : '[y/N]' default_letter = default_value ? 'y' : 'n' acceptable_responses = %w[y n] until acceptable_responses.include? answer_letter if $stdin.tty? # Read from terminal answer_letter = ask("#{prompt} #{suffix} ") do |q| q.limit = 1 q.case = :downcase end else $stdin.read # from pipe end answer_letter = default_letter if answer_letter.empty? end answer_letter == 'y' end # Invokes yes_no? with the default answer being 'no' def no?(prompt = 'More?') yes_no? prompt, default: false end # Invokes yes_no? with the default answer being 'yes' def yes?(prompt = 'More?') yes_no? prompt, default: true end end class HighLine alias highline_ask ask def ask(template_or_question, answer_type = nil, &) if $stdin.tty? # highline handles terminal I/O highline_ask(template_or_question, answer_type, &) else read_from_pipe(template_or_question, answer_type, &) end end def read_from_pipe(prompt, answer_type, &) if prompt.end_with? ' ' print prompt else puts prompt end q = HighLine::Question.new(prompt, answer_type, &) q.answer = $stdin.read puts q.answer return q.answer if q.answer == '' # Caller must provide the default value or action unless q.valid_answer? error_message = q.responses[:not_valid] || q.responses[:not_in_range] || 'Validation failed.' puts "Input '#{q.answer}' is invalid: #{error_message}".red exit! 33 end q.answer end end
The above code can be mixed into any class or module.
They ask the user a question and return true
or false
based on the user’s response.
The methods in the code issue a prompt and then read just one character from the user. The user can respond by typing the single character y to answer “yes” or n to answer “no”. If the user presses Space or Enter without typing anything else, the default answer is used.
The following irb
session shows how to use the yes?
and no?
methods.
irb(main):152> yes? 'asdf' asdf [Y/n] Enter or Space => true
irb(main):153> yes? 'asdf' asdf [Y/n] n => false
irb(main):154> no? 'asdf' asdf [y/N] Enter or Space => false
irb(main):155> no? 'asdf' asdf [y/N] n => false
irb(main):156> no? 'asdf' asdf [y/N] y => true
Data Pipelines
highline_wrappers.rb
, above, provides support for data pipelines.
The following is an example of a pipeline that feeds a multiline string with three lines into the STDIN
of a one-line Ruby program.
The Ruby program merely reverses the order of the lines that it receives and sends the result to STDOUT
.
$ printf "a\nb\nc\n" | ruby -e 'puts $stdin.readlines.reverse' c b a
You could use data pipelines to drive a CLI you wrote that uses my version of \
ask
, yes?
and no?
.
Debugging With Visual Studio Code
When Visual Studio Code debugs a Ruby program that it launched, the debugee
does not enjoy the niceties that a shell like Bash would have provided.
For example, there is no way to specify an input stream in a Visual Studio Code launch configuration,
and my experience with attaching to external terminals from rdbg
and ruby_lsp
has not been good.
Using Visual Studio Code to debug a program that performs user dialog is problematic.
{
{
"env": {
"VO_DEBUGGING": "true"
},
"name": "rdbg nugem jekyll test --force --private --tag=tag1",
"script": "${workspaceFolder}/exe/nugem jekyll test --force --private --tag=tag1",
"request": "launch",
"type": "rdbg",
"useBundler": true,
"useTerminal": true,
},
{
"env": {
"VO_DEBUGGING": "true"
},
"name": "ruby_lsp nugem jekyll test --force --private --tag=tag1",
"program": "ruby exe/nugem jekyll test --force --private --tag=tag1",
"request": "launch",
"type": "ruby_lsp",
},
}
This makes debugging a CLI that interacts with a user through dialog difficult. I considered the following possible solutions:
Launch the the program under a monitor, pause execution, and then attach Visual Studio Code to the monitor. That works well, but that can involve a lot of fussing until things work just right.
BTW, when the program first launches, it examines the VO_DEBUGGING
environment variable
to see if it should load the rest of the program from the project directory,
or if it should load the rest of itself from an installed gem.
All my gems work that way.
Redirect STDIN
to read from a pipeline.
This approach works well for scripted environments, but does not help us with debugging via Visual Studio Code.
Redirect STDIN
to read from an environment variable.
This approach works well for all scenarios.
Solution: nugem_argv
The third option is the best choice.
I decided to make the program look for an environment variable called nugem_argv
.
If present, its value would be used as STDIN
.
def maybe_redirect_stdin input_from_env = ENV.fetch('nugem_argv', nil) return unless input_from_env require 'stringio' $stdin = StringIO.new(input_from_env) puts 'Reading STDIN from the nugem_argv environment variable.' end # Define the environment variable ENV['nugem_argv'] = "Hello from Mars\nHello from Venus\nGoodbye\n" maybe_redirect_stdin # Now any code that reads from standard input (like gets) will read from ENV['nugem_argv']. puts gets # => "Hello from Mars" puts gets # => "Hello from Venus" puts gets # => "Goodbye" puts gets # => "" # EOF for STDIN yields nil, which prints as a newline puts gets # => "" puts gets # => ""
We can test the above:
$ ruby playground/nugem_argv.rb Reading STDIN from the nugem_argv environment variable. Hello from Mars Hello from Venus Goodbye
After incorporating the maybe_redirect_stdin
method shown above into nugem
,
we can use the following launch configurations for testing CLI user dialog:
{ { "debugPort": "0", "env": { "nugem_argv": "Hello from Mars\nHello from Venus\nGoodbye\n", "VO_DEBUGGING": "true" }, "name": "Nugem overwrite /tmp/nugem_test", "script": "${workspaceFolder}/exe/nugem ruby test -o /tmp/nugem_test --force", "request": "launch", "type": "rdbg", "useBundler": true, }, { "env": { "nugem_argv": "Hello from Mars\nHello from Venus\nGoodbye\n", "VO_DEBUGGING": "true" }, "name": "ruby_lsp nugem jekyll test --force --private --tag=tag1", "program": "ruby exe/nugem jekyll test --force --private --tag=tag1", "request": "launch", "type": "ruby_lsp", }, }