postmodern/command_kit.rb

View on GitHub
lib/command_kit/interactive.rb

Summary

Maintainability
A
1 hr
Test Coverage
# frozen_string_literal: true

require_relative 'stdio'

module CommandKit
  #
  # Provides methods for asking the user for input.
  #
  # ## Examples
  #
  #     first_name = ask("First name")
  #     last_name = ask("Last name")
  #
  # ### Asking for secret input
  #
  #     password = ask_secret("Password")
  #
  # ### Asking Y/N?
  #
  #     if ask_yes_or_no("Proceed anyways?")
  #       # ...
  #     else
  #       stderr.puts "Aborting!"
  #     end
  #
  # ### Asking multi-choice questions
  #
  #     ask_multiple_choice("Select a flavor", %w[Apple Orange Lemon Lime])
  #     #   1) Apple
  #     #   2) Orange
  #     #   3) Lemon
  #     #   4) Lime
  #     #   Select a flavor: 4
  #     #
  #     # => "Lime"
  #
  module Interactive
    include Stdio

    #
    # Asks the user for input.
    #
    # @param [String] prompt
    #   The prompt that will be printed before reading input.
    #
    # @param [String, nil] default
    #   The default value to return if no input is given.
    #
    # @param [Boolean] required
    #   Requires non-empty input.
    #
    # @return [String]
    #   The user input.
    #
    # @example
    #   first_name = ask("First name")
    #   last_name = ask("Last name")
    #
    # @example Default value:
    #   ask("Country", default: "EU")
    #   # Country [EU]: <enter>
    #   # => "EU"
    #
    # @example Required non-empty input:
    #   ask("Email", required: true)
    #   # Email: <enter>
    #   # Email: bob@example.com<enter>
    #   # => "bob@example.com"
    #
    # @api public
    #
    def ask(prompt, default: nil, required: false)
      prompt = prompt.chomp
      prompt << " [#{default}]" if default
      prompt << ": "

      loop do
        stdout.print(prompt)

        value = stdin.gets(chomp: true)
        value ||= '' # convert nil values (ctrl^D) to an empty String

        if value.empty?
          if required
            next
          else
            return (default || value)
          end
        else
          return value
        end
      end
    end

    #
    # Asks the user a yes or no question.
    #
    # @param [String] prompt
    #   The prompt that will be printed before reading input.
    #
    # @param [true, false,  nil] default
    #
    # @return [Boolean]
    #   Specifies whether the user entered Y/yes.
    #
    # @example
    #   ask_yes_or_no("Proceed anyways?")
    #   # Proceed anyways? (Y/N): Y
    #   # => true
    #
    # @example Default value:
    #   ask_yes_or_no("Proceed anyways?", default: true)
    #   # Proceed anyways? (Y/N) [Y]: <enter>
    #   # => true
    #
    # @api public
    #
    def ask_yes_or_no(prompt, default: nil, **kwargs)
      default = case default
                when true  then 'Y'
                when false then 'N'
                when nil  then nil
                else
                  raise(ArgumentError,"invalid default: #{default.inspect}")
                end

      prompt = "#{prompt} (Y/N)"

      loop do
        answer = ask(prompt, **kwargs, default: default)

        case answer.downcase
        when 'y', 'yes'
          return true
        else
          return false
        end
      end
    end

    #
    # Asks the user to select a choice from a list of options.
    #
    # @param [String] prompt
    #   The prompt that will be printed before reading input.
    #
    # @param [Hash{String => String}, Array<String>] choices
    #   The choices to select from.
    #
    # @param [Hash{Symbol => Object}] kwargs
    #   Additional keyword arguments for {#ask}.
    #
    # @option kwargs [String, nil] default
    #   The default option to fallback to, if no input is given.
    #
    # @option kwargs [Boolean] required
    #   Requires non-empty input.
    #
    # @return [String]
    #   The selected choice.
    #
    # @example Array of choices:
    #   ask_multiple_choice("Select a flavor", %w[Apple Orange Lemon Lime])
    #   #   1) Apple
    #   #   2) Orange
    #   #   3) Lemon
    #   #   4) Lime
    #   #   Select a flavor: 4
    #   #
    #   # => "Lime"
    #
    # @example Hash of choices:
    #   ask_multiple_choice("Select an option", {'A' => 'Foo',
    #                                            'B' => 'Bar',
    #                                            'X' => 'All of the above'})
    #   #   A) Foo
    #   #   B) Bar
    #   #   X) All of the above
    #   #   Select an option: X
    #   #
    #   # => "All of the above"
    #
    # @api public
    #
    def ask_multiple_choice(prompt,choices,**kwargs)
      choices = case choices
                when Array
                  Hash[choices.each_with_index.map { |value,i|
                    [(i+1).to_s, value]
                  }]
                when Hash
                  choices
                else
                  raise(TypeError,"unsupported choices class #{choices.class}: #{choices.inspect}")
                end

      prompt = "#{prompt} (#{choices.keys.join(', ')})"

      loop do
        # print the choices
        choices.each do |choice,value|
          stdout.puts "  #{choice}) #{value}"
        end
        stdout.puts

        # read the choice
        choice = ask(prompt,**kwargs)

        if choices.has_key?(choice)
          # if a valid choice is given, return the value
          return choices[choice]
        else
          stderr.puts "Invalid selection: #{choice}"
        end
      end
    end

    #
    # Asks the user for secret input.
    #
    # @param [String] prompt
    #   The prompt that will be printed before reading input.
    #
    # @param [Boolean] required
    #   Requires non-empty input.
    #
    # @return [String]
    #   The user input.
    #
    # @example
    #   ask_secret("Password")
    #   # Password: 
    #   # => "s3cr3t"
    #
    # @api public
    #
    def ask_secret(prompt, required: true)
      if stdin.respond_to?(:noecho)
        stdin.noecho do
          ask(prompt, required: required)
        end
      else
        ask(prompt, required: required)
      end
    end

  end
end