#!/usr/local/bin/ruby -w # 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 errors. User code does not need to trap this. class NoAutoCompleteMatch < StandardError # do nothing, just creating a unique error type end class NotEnoughAnswers < 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 recongnized by # Question.convert(). If given, a block is yeilded the new Question # object to allow custom initializaion. # def initialize( question, answer_type ) # initialize instance data @question = question @answer_type = answer_type @many_answers = nil @at_least = 1 @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 @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 # # 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 read many answers # # # *WARNING*: This option forces a character by character read. # attr_accessor :many_answers # # Allows you to set a character limit for input. # attr_accessor :at_least # # Specify minimum number of answers that user has to give. It # should be used only with :many_answers = true # 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 to 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 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. # def build_responses( ) ### WARNING: This code is quasi-duplicated in ### ### Menu.update_responses(). Check there too when ### ### making changes! ### append_default unless default.nil? @responses = { :ambiguous_completion => "Ambiguous choice. " + "Please choose from #{@answer_type.inspect}.", :ask_on_error => "? ", :invalid_type => "You must enter a valid #{@answer_type}.", :no_completion => "You must choose from " + "#{@answer_type.inspect}.", :not_enough_answers => "You must give at least #{@at_least} answers", :not_in_range => "Your answer isn't within the expected range " + "(#{expected_range}).", :not_valid => "Your answer isn't valid" + (@validate.is_a?(Regexp) ? " (must match #{@validate.inspect})." : "") }.merge(@responses) ### WARNING: This code is quasi-duplicated in ### ### Menu.update_responses(). Check there too when ### ### making changes! ### 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 # This method converts answers to proper type def convert( answer_string ) if @many_answers answer = [] answer_string.split.each do | single_answer | answer << convert_single_answer(single_answer) end if answer.size < @at_least raise NotEnoughAnswers end else answer = convert_single_answer(answer_string) end answer end # Returns a english explination 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 included?()ed in the # _in_ attribute. Otherwise, +false+ is returned. Any +nil+ attributes # are not checked. # def in_range?( answer_object ) if !@many_answers answer_object = [answer_object] end answer_object.each do | single_answer_object | return false unless (@above.nil? or single_answer_object > @above) and (@below.nil? or single_answer_object < @below) and (@in.nil? or @in.include?(single_answer_object)) end return true 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 whitspace runs to a # single space. # :strip_and_collapse:: Calls strip(), then collapses all # whitspace runs to a single space. # :chomp_and_collapse:: Calls chomp(), then collapses all # whitspace 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 @answer_type.is_a?(Array) @answer_type elsif [File, Pathname].include?(@answer_type) 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 ) return true if @validate.nil? answers = [] if @many_answers answers = answer_string.split else answers << answer_string end answers.each do |single_answer_string| return false unless (@validate.is_a?(Regexp) and single_answer_string =~ @validate) or (@validate.is_a?(Proc) and @validate[single_answer_string]) end return true end private # Convert single answer # 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(). # 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_single_answer( answer_string ) if @answer_type.nil? 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 # # 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