HighLine (#29)

When you stop and think about it, methods like gets(), while handy, are still pretty low level. In running Ruby Quiz I'm always seeing solutions with helper methods similar to this:

ruby
# by Markus Koenig

def ask(prompt)
loop do
print prompt, ' '
$stdout.flush
s = gets
exit if s == nil
s.chomp!
if s == 'y' or s == 'yes'
return true
elsif s == 'n' or s == 'no'
return false
else
$stderr.puts "Please answer yes or no."
end
end
end

Surely we can make something like that better! We don't always need Rails or a GUI framework and there's no reason writing a command-line application can't be equally smooth.

This week's Ruby Quiz is to start a module called HighLine (for high-level, line-oriented interface). Ideally this module would eventually cover many aspects of terminal interaction, but for this quiz we'll just focus on getting input.

What I really think we need here is to take a page out of the optparse book. Here are some general ideas:

ruby
age = ask("What is your age?", Integer, :within => 0..105)
num = eval "0b#{ ask( 'Enter a binary number.',
String, :validate => /\A[01_]+\Z/ ) }"


if ask_if("Would you like to continue?") # ...

None of these ideas are etched in stone. Feel free to call your input method prompt() or use a set of classes. Rework the interface any way you like. Just be sure to tell us how to use your system.

The goal is to provide an easy-to-use, yet robust method of requesting input. It should free the programmer of common concerns like calls to chomp() and ensuring valid input.


Quiz Summary

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:

ruby
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:

ruby
#...

#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:

ruby
#...

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:

ruby
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...