Mike Slinn

Highline Gem and Workalike

Published 2025-09-25.
Time to read: 3 minutes.

This page is part of the ruby collection.

Highline

Highline is a gem that is often found in CLIs that are built with Option­Parser. 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.

my_cli.rb
$ 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.

highline_wrappers.rb
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 session
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.

Shell
$ 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.

Visual Studio Code launch configurations
{
  {
    "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.

playground/nugem_argv.rb
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:

Shell
$ 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:

Visual Studio Code Launch Configurations with STDIO from nugem_argv
{
  {
    "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",
  },
}
* indicates a required field.

Please select the following to receive Mike Slinn’s newsletter:

You can unsubscribe at any time by clicking the link in the footer of emails.

Mike Slinn uses Mailchimp as his marketing platform. By clicking below to subscribe, you acknowledge that your information will be transferred to Mailchimp for processing. Learn more about Mailchimp’s privacy practices.