There weren't a lot of solutions this week, but they all had interesting
elements. I had a really hard time selecting what to show in the summary, but
the interests of time and space demand that I choose one.
Let's have a look at Mark Sparshatt's solution. We'll jump right into the ask()
method, which was really the main thrust of this quiz:
module HighLine
# prompt = text to display
# type can be one of :string, :integer, :float, :bool or a proc
# if it's a proc then it is called with the entered string. If the
# input cannot be converted then it should throw an exception
# if type == :bool then y,yes are converted to true. n,no are
# converted to false. All other values are rejected.
···
#
# options should be a hash of validation options
# :validate => regular expresion or proc
# if validate is a regular expression then the input is matched
# against it
# if it's a proc then the proc is called and the input is accepted
# if it returns true
# :between => range
# the input is checked if it lies within the range
# :above => value
# the input is checked if it is above the value
# :below => value
# the input is checked if it is less than the value
# :default => string
# if the user doesn't enter a value then the default value
# is returned
# :base => [b, o, d, x]
# when asking for integers this will take a number in binary,
# octal, decimal or hexadecimal
def ask(prompt, type, options=nil)
begin
valid = true
default = option(options, :default)
if default
defaultstr = " |#{default}|"
else
defaultstr = ""
end
base = option(options, :base)
print prompt, "#{defaultstr} "
$stdout.flush
input = gets.chomp
if default && input == ""
input = default
end
#comvert the input to the correct type
input = case type
when :string: input
when :integer: convert(input, base) rescue valid = false
when :float: Float(input) rescue valid = false
when :bool
valid = input =~ /^(y|n|yes|no)$/
input[0] == ?y
when Proc: input = type.call(input) rescue valid = false
end
#validate the input
valid &&= validate(options, :validate) do |test|
case test
when Regexp: input =~ test
when Proc: test.call(input)
end
end
valid &&= validate(options, :within) { |range| range === input}
valid &&= validate(options, :above) { |value| input > value}
valid &&= validate(options, :below) { |value| input < value}
puts "Not a valid value" unless valid
end until valid
return input
end
# ...
The comment above the method explains what it expects to be passed, in nice
detail. You can see that Mark added several options to those suggested in the
quiz. Mark also hit on a fun feature: Allow the type parameter to be a Proc.
My own solution used this trick and I was surprised at the flexibility it lended
to the method. Let's move on to the code itself.
The method starts off by calling a helper option() to fetch :default and :base.
We haven't seen the code for that yet, but it's easy to assume what it does at
this point and we can mentally translate option(options, :default) to
options[:default] for now. Note that if a :default is given, the code sets up a
string to display it to the user.
The next little chunk of code displays the prompt (with trailing :default
string). flush() is called right after that, to be sure the output is not
buffered. Then a line is read from the keyboard. If the line of input was
empty and a :default was set, the next if statement makes the switch.
The following case statement reassigns input, based on the type of conversion
requested. :string gets no translation, :integer calls the helper method
convert() we'll examine later, :float uses Float(), :bool has a clever check
that returns true if the first character is a ?y, and finally if type is a Proc
object it is called with the input.
There are a two other points of interest in this chunk of code. First there are
a lot of colons used in there, thanks to some new Ruby syntax. when ... : is
the same as when ... then, which is the older way to stuff the condition and
result on the same line. This works for if statements now too.
The other point of interest is that the code is constantly updating the valid
variable. If and exception is thrown or a :bool question was given something
other than "y", "n", "yes", or "no", valid is set to false. If you glance back
at the top of the method you'll see that valid started out true, but it may not
be when we're done here. We'll see the effects of that in a bit.
Next up we have a bunch of calls to another helper called validate(). It seems
to take some code and return a true or false response based on how the code
executed. If you reread the initial comment at this point, you'll see that it
explains all those blocks and what they are checking for. The neat trick here
is that all of these results are &&=ed with valid. && requires two truths each
time it is evaluated, so valid will only stay true if it was true when we got
here and every single validate() call returns true. This made for a pretty
clean process, I though.
We now see that we get a warning if we didn't provide valid input (by any
required condition). We also find the end of a begin ... end until valid
construct, which is a rare Ruby loop that is similar to do ... until in other
languages. When input is returned outside that loop, we know it must be valid.
Here's the other quiz suggested method:
#...
#asks a yes/no question
def ask_if(prompt)
ask(prompt, :bool)
end
#...
Obviously, that's just a simplification of ask().
Let's get to those helper methods now:
#...
private
#extracts a key from the options hash
def option(options, key)
result = nil
if options && options.key?(key)
result = options[key]
end
result
end
#helper function for validation
def validate(options, key)
result = true
if options && options.key?(key)
result = yield options[key]
end
result
end
#converts a string to an integer
#input = the value to convert
#base = the numeric base of the value b,o,d,x
def convert(input, base)
if base
if ["b", "o", "d", "x"].include?(base)
input = "0#{base}#{input}"
value = Integer(input)
else
value = Integer(input)
end
else
value = Integer(input)
end
value
end
end
# ...
option() simply checks that options were provided and that they included the
requested key. If so, the matching value is returned. Otherwise, nil is
returned. validate() is nearly identical, save that it yields the value to a
provided block and returns the result of that block. convert() just reality
checks the provided base and calls Integer().
Finally, here are some simple tests showing the method calls:
if __FILE__ == $0
include HighLine
#string input using a regexp to validate, returns test as the
# default value
p ask( "enter a string, (all lower case)", :string,
:validate => /^[a-z]*$/, :default => "test" )
#string input using a proc to validate
p ask( "enter a string, (between 3 and 6 characters)", :string,
:validate => proc { |input| (3..6) === input.length} )
#integer intput using :within
p ask("enter an integer, (0-10)", :integer, :within => 0..10)
#float input using :above
p ask("enter a float, (> 6)", :float, :above => 6)
#getting a binary value
p ask("enter a binary number", :integer, :base => "b")
#using a proc to convert the a comma seperated list into an array
p ask("enter a comma seperated list", proc { |x| x.split(/,/)})
p ask_if("do you want to continue?")
end
Be sure and look over examples two and six, which use Procs for validation and
type. It's crazy how powerful you can make something when you open it up to
extension by Ruby code.
Ryan Leavengood's solution is class based, instead of using a module. That
allows you to assign the input and output streams, for working with sockets
perhaps. That adds an object construction step though. In my solution, also
class based, I solved that by allowing an option to import top level shortcuts
for casual usage.
Ryan used a custom MockIO object and the ability to redirect his streams to
create a nice set of unit tests. I did the same thing using the standard
StringIO library.
Finally, do look over the list() method in Ryan's code, that provides simple
menu selection. Neat stuff.
My thanks to Ryan, Mark, Sean, and Dave for jumping right in and working yet
another wacky idea of mine.
Tomorrow Gavin Kistner is back with a second submitted quiz topic! (That makes
all of you who haven't submitted even one yet look really bad.) It's a fun
topic too, guaranteed to be a Barrel of Monkeys...