In Progress
Unit 1, Lesson 21
In Progress

Command Loop

Video transcript & code

Sooner or later you're going to want to set up a command-line loop for some reason. Whether you're writing a text-based game, or an administration tool, you're going to want to prompt the user for command-line input and act on the results.

There are whole frameworks for building command-line applications, but those may be overkill for your needs. Today I thought we'd go through the basics. The aim today is that, next time you need to write a command-line app, you can just bang out a simple version without having to think too hard about it.

First off, let's talk about collecting input. The fundamental method for collecting some input at the command line is #gets. It's part of the Kernel module, so it's available everywhere.

#gets means "GET String", and when we send it, that's exactly what it does. We can assign the result to a variable, and then use it. Just to demonstrate, let's convert the string to uppercase and output it before exiting.

s = gets
puts s.upcase

When we run this, the command line just sits there, waiting for input. When we type some text and then hit Enter, it uppercases the string and then exits.

$ ruby prog1.rb
foo
FOO

A command line isn't very friendly. We'd like to prompt the user for input somehow. This is where we'd normally turn to puts.

puts "> "
s = gets
puts s.upcase

The trouble with puts is that it always appends a newline to the end. We want our prompt to be on the same line as the input. For that we have to turn to the lesser-used print method instead.

print "> "
s = gets
puts s.upcase

When we run this, we get a nice inviting prompt to type at.

$ ruby prog3.rb
> bar
BAR

Now let's put this code into a loop. Since we want to keep processing commands indefinitely, we'll use the loop method we learned about back in episode #277. We'll also switch from uppercasing our input to just logging it.

loop do
  print "> "
  input = gets
  puts "Input was: #{input.inspect}"
end

We can type a few commands in, and see that #gets is including the trailing newline with each command.

One thing we can't do yet is exit the loop cleanly. We have to hit control-C to interrupt the process, which results in an ugly stack trace.

$ ruby prog4.rb
> hello
Input was: "hello\n"
> goodbye
Input was: "goodbye\n"
> prog4.rb:4:in `gets': Interrupt
    from prog4.rb:4:in `gets'
    from prog4.rb:4:in `block in <main>'
    from prog4.rb:2:in `loop'
    from prog4.rb:2:in `<main>'
interrupt

We might think about adding a special quit command at this point, but let's see if we can keep things even simpler. When we hit Control-D at the command line, what we see is a little different from when we entered strings. We see that gets returned a nil.

$ ruby prog4.rb
> hello
Input was: "hello\n"
> Input was: nil
>

Here's why: assuming we are using a typical UNIX-style terminal, when we hit Ctrl-D, the terminal interprets that as a command to send the "End-Of-File", or EOF, indicator to the process. And gets has special handling for EOF: it returns nil.

Knowing this, we can use the special nil return to break out of the loop when the user hits Ctrl-D. This is standard behavior for UNIX command-line utilities, and something that many users will expect.

loop do
  print "> "
  input = gets
  puts "Input was: #{input.inspect}"
  break if input.nil?
end

Now we can cleanly exit using a Ctrl-D at the command line.

$ ruby prog5.rb
> Input was: nil

But there's an even more compact way to express this. We can combine the input assignment and the loop statement together by replacing our loop block with a while block. Since all strings are considered "truthy" and nil is considered "falsy", this means that the loop will proceed until gets encounters EOF and returns nil.

The only downside is that now we have to print the prompt in two different places.

print "> "
while input = gets do
  puts "Input was: #{input.inspect}"
  print "> "
end

One alternative is to combine both the prompting and the input acquisition into the loop condition using parenthesies and a semicolon. We should probably extract this part of the code out to a method before we think about adding anything else to it.

while (print "> "; input = gets) do
  puts "Input was: #{input.inspect}"
end

As we saw earlier, every time gets returns a string, it returns it with a trailing newline. We probably don't want that newline when we are processing commands.

One thing we could to is just chop off the last character in the string. But will we always have a trailing newline in all possible cases? Or do we run the risk of sometimes cutting off an important character? What about on windows, will we get carriage-return linefeed sequences instead of just a single newline?

We could research all that stuff. Or, we could just #chomp! the string and forget about it. #chomp is a method intended for just this situation: it will cut off any line-ending characters found at the end of a string.

while (print "> "; input = gets) do
  input.chomp!
  puts "Input was: #{input.inspect}"
end

With #chomp we can forget about the details of line endings and just know that any line-ending characters have been removed.

$ ruby prog8.rb
> hello
Input was: "hello"
> goodbye
Input was: "goodbye"

Another kind of input massaging we may want to consider is converting all characters to lowercase. If we don't care about letter casing in this program, downcasing every input string means we don't have to worry about doing case-insensitive matching later on.

while (print "> "; input = gets) do
  input.chomp!.downcase!
  puts "Input was: #{input.inspect}"
end
$ ruby prog9.rb
> HELLO
Input was: "hello"
> Goodbye
Input was: "goodbye"

Once we have normalized our input, it's time to think about putting it to use. For basic command processing, Ruby's case statements are perfect. Let's write a case statement with three branches. One will print the current time. One will explicitly quit the program. And any other command will output an error message.

while (print "> "; input = gets) do
  input.chomp!
  puts "Input was: #{input.inspect}"
  case input
  when "time" then puts Time.now
  when "quit" then break
  else puts "I don't know that command"
  end
end

Let's give it a try. We'll test out the "time" command. Then try a nonexistant command. Finally, we'll quit.

$ ruby prog10.rb
> time
Input was: "time"
2015-04-15 14:19:05 -0400
> hello
Input was: "hello"
I don't know that command
> quit
Input was: "quit"

One of the things that makes Ruby's case statements perfect for command processing is that each branch can match on multiple patterns. So, for instance, we can make "exit" an alias for "quit".

while (print "> "; input = gets) do
  input.chomp!
  puts "Input was: #{input.inspect}"
  case input
  when "time" then puts Time.now
  when "quit", "exit" then break
  else puts "I don't know that command"
  end
end

Let's give that a try.

$ ruby prog11.rb
> exit
Input was: "exit"

Building a bare-bones command-line interface in Ruby is incredibly easy and concise, once you know the little tricks that smooth the way. I hope what you've seen in this episode has inspired you not to be afraid of building little command-line UIs in Ruby. Perhaps in future episodes, we'll talk about tools and strategies for building more elaborate interfaces. Happy hacking!

Responses