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'
# Augment Highline
module HighlineWrappers
def self.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
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
# Monkey patch Highline
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
unless q.answer
puts 'EOF encountered.'.red
exit! 1
end
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'].
# Echo STDIN to STDOUT line by line until EOF.
# Do not call $stdio.eof? to test EOF because it will block until the process exits
# if STDIN is a pipe or socket.
# Instead, detect when STDIN reaches EOF by gets returning nil
while line = gets # rubocop:disable Lint/AssignmentInCondition
puts line
end
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",
},
}