Command Line Interface Application

Programming Command Line Interface applications (CLI applications) is one of the most entertaining tasks a developer may do. So let’s have some fun building our first CLI application in Crystal.

There are two main topics when building a CLI application:

Input

This topic covers all things related to:

Options

It is a very common practice to pass options to the application. For example, we may run crystal -v and Crystal will display:

  1. $ crystal -v
  2. Crystal 1.10.0 [9c011d77d] (2023-10-09)
  3. LLVM: 15.0.7
  4. Default target: x86_64-unknown-linux-gnu

and if we run: crystal -h, then Crystal will show all the accepted options and how to use them.

So now the question would be: do we need to implement an options parser? No need to, Crystal has us covered with the class OptionParser. Let’s build an application using this parser!

At the start our CLI application has two options:

  • -v / --version: it will display the application version.
  • -h / --help: it will display the application help.

help.cr

  1. require "option_parser"
  2. OptionParser.parse do |parser|
  3. parser.banner = "Welcome to The Beatles App!"
  4. parser.on "-v", "--version", "Show version" do
  5. puts "version 1.0"
  6. exit
  7. end
  8. parser.on "-h", "--help", "Show help" do
  9. puts parser
  10. exit
  11. end
  12. end

So, how does all this work? Well … magic! No, it’s not really magic! Just Crystal making our life easy. When our application starts, the block passed to OptionParser#parse gets executed. In that block we define all the options. After the block is executed, the parser will start consuming the arguments passed to the application, trying to match each one with the options defined by us. If an option matches then the block passed to parser#on gets executed!

We can read all about OptionParser in the official API documentation. And from there we are one click away from the source code … the actual proof that it is not magic!

Now, let’s run our application. We have two ways using the compiler:

  1. Build the application and then run it.
  2. Compile and run the application, all in one command.

We are going to use the second way:

  1. $ crystal run ./help.cr -- -h
  2. Welcome to The Beatles App!
  3. -v, --version Show version
  4. -h, --help Show help

Let’s build another fabulous application with the following feature:

By default (i.e. no options given) the application will display the names of the Fab Four. But, if we pass the option -t / --twist it will display the names in uppercase:

twist_and_shout.cr

  1. require "option_parser"
  2. the_beatles = [
  3. "John Lennon",
  4. "Paul McCartney",
  5. "George Harrison",
  6. "Ringo Starr",
  7. ]
  8. shout = false
  9. option_parser = OptionParser.parse do |parser|
  10. parser.banner = "Welcome to The Beatles App!"
  11. parser.on "-v", "--version", "Show version" do
  12. puts "version 1.0"
  13. exit
  14. end
  15. parser.on "-h", "--help", "Show help" do
  16. puts parser
  17. exit
  18. end
  19. parser.on "-t", "--twist", "Twist and SHOUT" do
  20. shout = true
  21. end
  22. end
  23. members = the_beatles
  24. members = the_beatles.map &.upcase if shout
  25. puts ""
  26. puts "Group members:"
  27. puts "=============="
  28. members.each do |member|
  29. puts member
  30. end

Running the application with the -t option will output:

  1. $ crystal run ./twist_and_shout.cr -- -t
  2. Group members:
  3. ==============
  4. JOHN LENNON
  5. PAUL MCCARTNEY
  6. GEORGE HARRISON
  7. RINGO STARR

Parameterized options

Let’s create another application: when passing the option -g / --goodbye_hello, the application will say hello to a given name passed as a parameter to the option.

hello_goodbye.cr

  1. require "option_parser"
  2. the_beatles = [
  3. "John Lennon",
  4. "Paul McCartney",
  5. "George Harrison",
  6. "Ringo Starr",
  7. ]
  8. say_hi_to = ""
  9. option_parser = OptionParser.parse do |parser|
  10. parser.banner = "Welcome to The Beatles App!"
  11. parser.on "-v", "--version", "Show version" do
  12. puts "version 1.0"
  13. exit
  14. end
  15. parser.on "-h", "--help", "Show help" do
  16. puts parser
  17. exit
  18. end
  19. parser.on "-g NAME", "--goodbye_hello=NAME", "Say hello to whoever you want" do |name|
  20. say_hi_to = name
  21. end
  22. end
  23. unless say_hi_to.empty?
  24. puts ""
  25. puts "You say goodbye, and #{the_beatles.sample} says hello to #{say_hi_to}!"
  26. end

In this case, the block receives a parameter that represents the parameter passed to the option.

Let’s try it!

  1. $ crystal run ./hello_goodbye.cr -- -g "Penny Lane"
  2. You say goodbye, and Ringo Starr says hello to Penny Lane!

Great! These applications look awesome! But, what happens when we pass an option that is not declared? For example -n

  1. $ crystal run ./hello_goodbye.cr -- -n
  2. Unhandled exception: Invalid option: -n (OptionParser::InvalidOption)
  3. from ...

Oh no! It’s broken: we need to handle invalid options and invalid parameters given to an option! For these two situations, the OptionParser class has two methods: #invalid_option and #missing_option

So, let’s add this option handler and merge all these CLI applications into one fabulous CLI application!

All My CLI: The complete application

Here’s the final result, with invalid/missing options handling, plus other new options:

all_my_cli.cr

  1. require "option_parser"
  2. the_beatles = [
  3. "John Lennon",
  4. "Paul McCartney",
  5. "George Harrison",
  6. "Ringo Starr",
  7. ]
  8. shout = false
  9. say_hi_to = ""
  10. strawberry = false
  11. option_parser = OptionParser.parse do |parser|
  12. parser.banner = "Welcome to The Beatles App!"
  13. parser.on "-v", "--version", "Show version" do
  14. puts "version 1.0"
  15. exit
  16. end
  17. parser.on "-h", "--help", "Show help" do
  18. puts parser
  19. exit
  20. end
  21. parser.on "-t", "--twist", "Twist and SHOUT" do
  22. shout = true
  23. end
  24. parser.on "-g NAME", "--goodbye_hello=NAME", "Say hello to whoever you want" do |name|
  25. say_hi_to = name
  26. end
  27. parser.on "-r", "--random_goodbye_hello", "Say hello to one random member" do
  28. say_hi_to = the_beatles.sample
  29. end
  30. parser.on "-s", "--strawberry", "Strawberry fields forever mode ON" do
  31. strawberry = true
  32. end
  33. parser.missing_option do |option_flag|
  34. STDERR.puts "ERROR: #{option_flag} is missing something."
  35. STDERR.puts ""
  36. STDERR.puts parser
  37. exit(1)
  38. end
  39. parser.invalid_option do |option_flag|
  40. STDERR.puts "ERROR: #{option_flag} is not a valid option."
  41. STDERR.puts parser
  42. exit(1)
  43. end
  44. end
  45. members = the_beatles
  46. members = the_beatles.map &.upcase if shout
  47. puts "Strawberry fields forever mode ON" if strawberry
  48. puts ""
  49. puts "Group members:"
  50. puts "=============="
  51. members.each do |member|
  52. puts "#{strawberry ? "🍓" : "-"} #{member}"
  53. end
  54. unless say_hi_to.empty?
  55. puts ""
  56. puts "You say goodbye, and I say hello to #{say_hi_to}!"
  57. end

Request for user input

Sometimes, we may need the user to input a value. How do we read that value? Easy, peasy! Let’s create a new application: the Fab Four will sing with us any phrase we want. When running the application, it will request a phrase to the user and the magic will happen!

let_it_cli.cr

  1. puts "Welcome to The Beatles Sing-Along version 1.0!"
  2. puts "Enter a phrase you want The Beatles to sing"
  3. print "> "
  4. user_input = gets
  5. puts "The Beatles are singing: 🎵#{user_input}🎶🎸🥁"

The method gets will pause the execution of the application until the user finishes entering the input (pressing the Enter key). When the user presses Enter, then the execution will continue and user_input will have the user value.

But what happens if the user doesn’t enter any value? In that case, we would get an empty string (if the user only presses Enter) or maybe a Nil value (if the input stream is closed, e.g. by pressing Ctrl+D). To illustrate the problem let’s try the following: we want the input entered by the user to be sung loudly:

let_it_cli.cr

  1. puts "Welcome to The Beatles Sing-Along version 1.0!"
  2. puts "Enter a phrase you want The Beatles to sing"
  3. print "> "
  4. user_input = gets
  5. puts "The Beatles are singing: 🎵#{user_input.upcase}🎶🎸🥁"

When running the example, Crystal will reply:

  1. $ crystal run ./let_it_cli.cr
  2. Showing last frame. Use --error-trace for full trace.
  3. In let_it_cli.cr:5:46
  4. 5 | puts "The Beatles are singing: 🎵#{user_input.upper_case}
  5. ^---------
  6. Error: undefined method 'upper_case' for Nil (compile-time type is (String | Nil))

Ah! We should have known better: the type of the user input is the union type String | Nil. So, we have to test for Nil and for empty and act naturally for each case:

let_it_cli.cr

  1. puts "Welcome to The Beatles Sing-Along version 1.0!"
  2. puts "Enter a phrase you want The Beatles to sing"
  3. print "> "
  4. user_input = gets
  5. exit if user_input.nil? # Ctrl+D
  6. default_lyrics = "Na, na, na, na-na-na na" \
  7. " / " \
  8. "Na-na-na na, hey Jude"
  9. lyrics = user_input.presence || default_lyrics
  10. puts "The Beatles are singing: 🎵#{lyrics.upcase}🎶🎸🥁"

Output

Now, we will focus on the second main topic: our application’s output. For starters, our applications already display information but (I think) we could do better. Let’s add more life (i.e. colors!) to the outputs.

And to accomplish this, we will be using the Colorize module.

Let’s build a really simple application that shows a string with colors! We will use a yellow font on a black background:

yellow_cli.cr

  1. require "colorize"
  2. puts "#{"The Beatles".colorize(:yellow).on(:black)} App"

Great! That was easy! Now imagine using this string as the banner for our All My CLI application, it’s easy if you try:

  1. parser.banner = "#{"The Beatles".colorize(:yellow).on(:black)} App"

For our second application, we will add a text decoration (blinkin this case):

let_it_cli.cr

  1. require "colorize"
  2. puts "Welcome to The Beatles Sing-Along version 1.0!"
  3. puts "Enter a phrase you want The Beatles to sing"
  4. print "> "
  5. user_input = gets
  6. exit if user_input.nil? # Ctrl+D
  7. default_lyrics = "Na, na, na, na-na-na na" \
  8. " / " \
  9. "Na-na-na na, hey Jude"
  10. lyrics = user_input.presence || default_lyrics
  11. puts "The Beatles are singing: #{"🎵#{lyrics}🎶🎸🥁".colorize.mode(:blink)}"

Let’s try the renewed application … and hear the difference!! Now we have two fabulous apps!!

You may find a list of available colors and text decorations in the API documentation.

Testing

As with any other application, at some point, we would like to write tests for the different features.

Right now the code containing the logic of each of the applications always gets executed with the OptionParser, i.e. there is no way to include that file without running the whole application. So first we would need to refactor the code, separating the code necessary for parsing options from the logic. Once the refactoring is done, we could start testing the logic and including the file with the logic in the testing files we need. We leave this as an exercise for the reader.

Using Readline and NCurses

In case we want to build richer CLI applications, there are libraries that can help us. Here we will name two well-known libraries: Readline and NCurses.

As stated in the documentation for the GNU Readline Library, Readline is a library that provides a set of functions for use by applications that allow users to edit command lines as they are typed in. Readline has some great features: filename autocompletion out of the box; custom auto-completion method; keybinding, just to mention a few. If we want to try it then the crystal-lang/crystal-readline shard will give us an easy API to use Readline.

On the other hand, we have NCurses(New Curses). This library allows developers to create graphical user interfaces in the terminal. As its name implies, it is an improved version of the library named Curses, which was developed to support a text-based dungeon-crawling adventure game called Rogue! As you can imagine, there are already a couple of shards in the ecosystem that will allow us to use NCurses in Crystal!

And so we have reached The End 😎🎶