mirror of
https://github.com/TracksApp/tracks.git
synced 2026-02-18 05:08:09 +01:00
462 lines
18 KiB
Ruby
462 lines
18 KiB
Ruby
#!/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 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 <tt>""</tt>) 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
|
|
|
|
@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 <tt>:getc</tt> to use that method on the input stream.
|
|
#
|
|
# *WARNING*: The _echo_ and _overwrite_ attributes for a question are
|
|
# ignored when using the <tt>:getc</tt> 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 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 <tt>@key</tt> 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
|
|
# <tt>Pathname.new(File.expand_path(File.dirname($0)))</tt>.
|
|
#
|
|
attr_accessor :directory
|
|
#
|
|
# The glob pattern used to limit file selection when File or Pathname is
|
|
# specified as an _answer_type_. Initially set to <tt>"*"</tt>.
|
|
#
|
|
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:
|
|
#
|
|
# <tt>:ambiguous_completion</tt>:: Used to notify the user of an
|
|
# ambiguous answer the auto-completion
|
|
# system cannot resolve.
|
|
# <tt>:ask_on_error</tt>:: This is the question that will be
|
|
# redisplayed to the user in the event
|
|
# of an error. Can be set to
|
|
# <tt>:question</tt> to repeat the
|
|
# original question.
|
|
# <tt>:invalid_type</tt>:: The error message shown when a type
|
|
# conversion fails.
|
|
# <tt>:no_completion</tt>:: Used to notify the user that their
|
|
# selection does not have a valid
|
|
# auto-completion match.
|
|
# <tt>:not_in_range</tt>:: Used to notify the user that a
|
|
# provided answer did not satisfy
|
|
# the range requirement tests.
|
|
# <tt>:not_valid</tt>:: 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 one of #{@answer_type.inspect}.",
|
|
:ask_on_error =>
|
|
"? ",
|
|
:invalid_type =>
|
|
"You must enter a valid #{@answer_type}.",
|
|
:no_completion =>
|
|
"You must choose one of " +
|
|
"#{@answer_type.inspect}.",
|
|
:not_in_range =>
|
|
"Your answer isn't within the expected range " +
|
|
"(#{expected_range}).",
|
|
:not_valid =>
|
|
"Your answer isn't valid (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.)
|
|
# <tt>:up</tt>:: Calls upcase().
|
|
# <tt>:upcase</tt>:: Calls upcase().
|
|
# <tt>:down</tt>:: Calls downcase().
|
|
# <tt>:downcase</tt>:: Calls downcase().
|
|
# <tt>:capitalize</tt>:: Calls capitalize().
|
|
#
|
|
# An unrecognized choice (like <tt>:none</tt>) 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:
|
|
#
|
|
# <tt>[...]</tt>:: Answer must be a member of the passed Array.
|
|
# Auto-completion is used to expand partial
|
|
# answers.
|
|
# <tt>lambda {...}</tt>:: 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.
|
|
# <i>any other Class</i>:: The answer is passed on to
|
|
# <tt>Class.parse()</tt>.
|
|
#
|
|
# This method throws ArgumentError, if the conversion cannot be
|
|
# completed for any reason.
|
|
#
|
|
def convert( 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
|
|
|
|
# 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 )
|
|
(@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.
|
|
# <tt>:strip</tt>:: Calls strip(). (Default.)
|
|
# <tt>:chomp</tt>:: Calls chomp().
|
|
# <tt>:collapse</tt>:: Collapses all whitspace runs to a
|
|
# single space.
|
|
# <tt>:strip_and_collapse</tt>:: Calls strip(), then collapses all
|
|
# whitspace runs to a single space.
|
|
# <tt>:chomp_and_collapse</tt>:: Calls chomp(), then collapses all
|
|
# whitspace runs to a single space.
|
|
# <tt>:remove</tt>:: Removes all whitespace.
|
|
#
|
|
# An unrecognized choice (like <tt>:none</tt>) 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 )
|
|
@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 <tt>|...|</tt>.
|
|
# 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
|