# question.rb # # Created by James Edward Gray II on 2005-04-26. # Copyright 2005 Gray Productions. All rights reserved. # # This is Free Software. See LICENSE and COPYING for details. require "optparse" require "date" require "pathname" class HighLine # # Question objects contain all the details of a single invocation of # HighLine.ask(). The object is initialized by the parameters passed to # HighLine.ask() and then queried to make sure each step of the input # process is handled according to the users wishes. # class Question # An internal HighLine error. User code does not need to trap this. class NoAutoCompleteMatch < StandardError # do nothing, just creating a unique error type end # # Create an instance of HighLine::Question. Expects a _question_ to ask # (can be "") and an _answer_type_ to convert the answer to. # The _answer_type_ parameter must be a type recognized by # Question.convert(). If given, a block is yielded the new Question # object to allow custom initialization. # def initialize( question, answer_type ) # initialize instance data @question = question.dup @answer_type = answer_type @completion = @answer_type @character = nil @limit = nil @echo = true @readline = false @whitespace = :strip @case = nil @default = nil @validate = nil @above = nil @below = nil @in = nil @confirm = nil @gather = false @verify_match = false @first_answer = nil @directory = Pathname.new(File.expand_path(File.dirname($0))) @glob = "*" @responses = Hash.new @overwrite = false # allow block to override settings yield self if block_given? # finalize responses based on settings build_responses end # The ERb template of the question to be asked. attr_accessor :question # The type that will be used to convert this answer. attr_accessor :answer_type # For Auto-completion attr_accessor :completion # # Can be set to +true+ to use HighLine's cross-platform character reader # instead of fetching an entire line of input. (Note: HighLine's character # reader *ONLY* supports STDIN on Windows and Unix.) Can also be set to # :getc to use that method on the input stream. # # *WARNING*: The _echo_ and _overwrite_ attributes for a question are # ignored when using the :getc method. # attr_accessor :character # # Allows you to set a character limit for input. # # *WARNING*: This option forces a character by character read. # attr_accessor :limit # # Can be set to +true+ or +false+ to control whether or not input will # be echoed back to the user. A setting of +true+ will cause echo to # match input, but any other true value will be treated as a String to # echo for each character typed. # # This requires HighLine's character reader. See the _character_ # attribute for details. # # *Note*: When using HighLine to manage echo on Unix based systems, we # recommend installing the termios gem. Without it, it's possible to type # fast enough to have letters still show up (when reading character by # character only). # attr_accessor :echo # # Use the Readline library to fetch input. This allows input editing as # well as keeping a history. In addition, tab will auto-complete # within an Array of choices or a file listing. # # *WARNING*: This option is incompatible with all of HighLine's # character reading modes and it causes HighLine to ignore the # specified _input_ stream. # attr_accessor :readline # # Used to control whitespace processing for the answer to this question. # See HighLine::Question.remove_whitespace() for acceptable settings. # attr_accessor :whitespace # # Used to control character case processing for the answer to this question. # See HighLine::Question.change_case() for acceptable settings. # attr_accessor :case # Used to provide a default answer to this question. attr_accessor :default # # If set to a Regexp, the answer must match (before type conversion). # Can also be set to a Proc which will be called with the provided # answer to validate with a +true+ or +false+ return. # attr_accessor :validate # Used to control range checks for answer. attr_accessor :above, :below # If set, answer must pass an include?() check on this object. attr_accessor :in # # Asks a yes or no confirmation question, to ensure a user knows what # they have just agreed to. If set to +true+ the question will be, # "Are you sure? " Any other true value for this attribute is assumed # to be the question to ask. When +false+ or +nil+ (the default), # answers are not confirmed. # attr_accessor :confirm # # When set, the user will be prompted for multiple answers which will # be collected into an Array or Hash and returned as the final answer. # # You can set _gather_ to an Integer to have an Array of exactly that # many answers collected, or a String/Regexp to match an end input which # will not be returned in the Array. # # Optionally _gather_ can be set to a Hash. In this case, the question # will be asked once for each key and the answers will be returned in a # Hash, mapped by key. The @key variable is set before each # question is evaluated, so you can use it in your question. # attr_accessor :gather # # When set to +true+ multiple entries will be collected according to # the setting for _gather_, except they will be required to match # each other. Multiple identical entries will return a single answer. # attr_accessor :verify_match # # When set to a non *nil* value, this will be tried as an answer to the # question. If this answer passes validations, it will become the result # without the user ever being prompted. Otherwise this value is discarded, # and this Question is resolved as a normal call to HighLine.ask(). # attr_writer :first_answer # # The directory from which a user will be allowed to select files, when # File or Pathname is specified as an _answer_type_. Initially set to # Pathname.new(File.expand_path(File.dirname($0))). # attr_accessor :directory # # The glob pattern used to limit file selection when File or Pathname is # specified as an _answer_type_. Initially set to "*". # attr_accessor :glob # # A Hash that stores the various responses used by HighLine to notify # the user. The currently used responses and their purpose are as # follows: # # :ambiguous_completion:: Used to notify the user of an # ambiguous answer the auto-completion # system cannot resolve. # :ask_on_error:: This is the question that will be # redisplayed to the user in the event # of an error. Can be set to # :question to repeat the # original question. # :invalid_type:: The error message shown when a type # conversion fails. # :no_completion:: Used to notify the user that their # selection does not have a valid # auto-completion match. # :not_in_range:: Used to notify the user that a # provided answer did not satisfy # the range requirement tests. # :not_valid:: The error message shown when # validation checks fail. # attr_reader :responses # # When set to +true+ the question is asked, but output does not progress to # the next line. The Cursor is moved back to the beginning of the question # line and it is cleared so that all the contents of the line disappear from # the screen. # attr_accessor :overwrite # # Returns the provided _answer_string_ or the default answer for this # Question if a default was set and the answer is empty. # def answer_or_default( answer_string ) if answer_string.length == 0 and not @default.nil? @default else answer_string end end # # Called late in the initialization process to build intelligent # responses based on the details of this Question object. # Also used by Menu#update_responses. # def build_responses(message_source = answer_type, new_hash_wins = false) append_default unless default.nil? choice_error_str_func = lambda do message_source.is_a?(Array) \ ? '[' + message_source.map { |s| "#{s}" }.join(', ') + ']' \ : message_source.inspect end old_hash = @responses new_hash = { :ambiguous_completion => "Ambiguous choice. Please choose one of " + choice_error_str_func.call + '.', :ask_on_error => "? ", :invalid_type => "You must enter a valid #{message_source}.", :no_completion => "You must choose one of " + choice_error_str_func.call + '.', :not_in_range => "Your answer isn't within the expected range " + "(#{expected_range}).", :mismatch => "Your entries didn't match.", :not_valid => "Your answer isn't valid (must match " + "#{@validate.inspect})." } @responses = new_hash_wins ? old_hash.merge(new_hash) : new_hash.merge(old_hash) end # # Returns the provided _answer_string_ after changing character case by # the rules of this Question. Valid settings for whitespace are: # # +nil+:: Do not alter character case. # (Default.) # :up:: Calls upcase(). # :upcase:: Calls upcase(). # :down:: Calls downcase(). # :downcase:: Calls downcase(). # :capitalize:: Calls capitalize(). # # An unrecognized choice (like :none) is treated as +nil+. # def change_case( answer_string ) if [:up, :upcase].include?(@case) answer_string.upcase elsif [:down, :downcase].include?(@case) answer_string.downcase elsif @case == :capitalize answer_string.capitalize else answer_string end end # # Transforms the given _answer_string_ into the expected type for this # Question. Currently supported conversions are: # # [...]:: Answer must be a member of the passed Array. # Auto-completion is used to expand partial # answers. # lambda {...}:: Answer is passed to lambda for conversion. # Date:: Date.parse() is called with answer. # DateTime:: DateTime.parse() is called with answer. # File:: The entered file name is auto-completed in # terms of _directory_ + _glob_, opened, and # returned. # Float:: Answer is converted with Kernel.Float(). # Integer:: Answer is converted with Kernel.Integer(). # +nil+:: Answer is left in String format. (Default.) # Pathname:: Same as File, save that a Pathname object is # returned. # String:: Answer is converted with Kernel.String(). # HighLine::String:: Answer is converted with HighLine::String() # Regexp:: Answer is fed to Regexp.new(). # Symbol:: The method to_sym() is called on answer and # the result returned. # any other Class:: The answer is passed on to # Class.parse(). # # This method throws ArgumentError, if the conversion cannot be # completed for any reason. # def convert( answer_string ) if @answer_type.nil? answer_string elsif [::String, HighLine::String].include?(@answer_type) HighLine::String(answer_string) elsif [Float, Integer, String].include?(@answer_type) Kernel.send(@answer_type.to_s.to_sym, answer_string) elsif @answer_type == Symbol answer_string.to_sym elsif @answer_type == Regexp Regexp.new(answer_string) elsif @answer_type.is_a?(Array) or [File, Pathname].include?(@answer_type) # cheating, using OptionParser's Completion module choices = selection choices.extend(OptionParser::Completion) answer = choices.complete(answer_string) if answer.nil? raise NoAutoCompleteMatch end if @answer_type.is_a?(Array) answer.last elsif @answer_type == File File.open(File.join(@directory.to_s, answer.last)) else Pathname.new(File.join(@directory.to_s, answer.last)) end elsif [Date, DateTime].include?(@answer_type) or @answer_type.is_a?(Class) @answer_type.parse(answer_string) elsif @answer_type.is_a?(Proc) @answer_type[answer_string] end end # Returns an English explanation of the current range settings. def expected_range( ) expected = [ ] expected << "above #{@above}" unless @above.nil? expected << "below #{@below}" unless @below.nil? expected << "included in #{@in.inspect}" unless @in.nil? case expected.size when 0 then "" when 1 then expected.first when 2 then expected.join(" and ") else expected[0..-2].join(", ") + ", and #{expected.last}" end end # Returns _first_answer_, which will be unset following this call. def first_answer( ) @first_answer ensure @first_answer = nil end # Returns true if _first_answer_ is set. def first_answer?( ) not @first_answer.nil? end # # Returns +true+ if the _answer_object_ is greater than the _above_ # attribute, less than the _below_ attribute and include?()ed in the # _in_ attribute. Otherwise, +false+ is returned. Any +nil+ attributes # are not checked. # def in_range?( answer_object ) (@above.nil? or answer_object > @above) and (@below.nil? or answer_object < @below) and (@in.nil? or @in.include?(answer_object)) end # # Returns the provided _answer_string_ after processing whitespace by # the rules of this Question. Valid settings for whitespace are: # # +nil+:: Do not alter whitespace. # :strip:: Calls strip(). (Default.) # :chomp:: Calls chomp(). # :collapse:: Collapses all whitespace runs to a # single space. # :strip_and_collapse:: Calls strip(), then collapses all # whitespace runs to a single space. # :chomp_and_collapse:: Calls chomp(), then collapses all # whitespace runs to a single space. # :remove:: Removes all whitespace. # # An unrecognized choice (like :none) is treated as +nil+. # # This process is skipped for single character input. # def remove_whitespace( answer_string ) if @whitespace.nil? answer_string elsif [:strip, :chomp].include?(@whitespace) answer_string.send(@whitespace) elsif @whitespace == :collapse answer_string.gsub(/\s+/, " ") elsif [:strip_and_collapse, :chomp_and_collapse].include?(@whitespace) result = answer_string.send(@whitespace.to_s[/^[a-z]+/]) result.gsub(/\s+/, " ") elsif @whitespace == :remove answer_string.gsub(/\s+/, "") else answer_string end end # # Returns an Array of valid answers to this question. These answers are # only known when _answer_type_ is set to an Array of choices, File, or # Pathname. Any other time, this method will return an empty Array. # def selection( ) if @completion.is_a?(Array) @completion elsif [File, Pathname].include?(@completion) Dir[File.join(@directory.to_s, @glob)].map do |file| File.basename(file) end else [ ] end end # Stringifies the question to be asked. def to_str( ) @question end # # Returns +true+ if the provided _answer_string_ is accepted by the # _validate_ attribute or +false+ if it's not. # # It's important to realize that an answer is validated after whitespace # and case handling. # def valid_answer?( answer_string ) @validate.nil? or (@validate.is_a?(Regexp) and answer_string =~ @validate) or (@validate.is_a?(Proc) and @validate[answer_string]) end private # # Adds the default choice to the end of question between |...|. # Trailing whitespace is preserved so the function of HighLine.say() is # not affected. # def append_default( ) if @question =~ /([\t ]+)\Z/ @question << "|#{@default}|#{$1}" elsif @question == "" @question << "|#{@default}| " elsif @question[-1, 1] == "\n" @question[-2, 0] = " |#{@default}|" else @question << " |#{@default}|" end end end end