paint-brush
Getting the User’s Opinion: Options in Haskellby@james_32022
358 reads
358 reads

Getting the User’s Opinion: Options in Haskell

by James BowenJune 26th, 2017
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

GUI’s are hard to create. Luckily for us, we can often get away with making our code available through a command line interface. As you start writing more Haskell programs, you’ll probably have to do this at some point.

People Mentioned

Mention Thumbnail

Coin Mentioned

Mention Thumbnail
featured image - Getting the User’s Opinion: Options in Haskell
James Bowen HackerNoon profile picture

GUI’s are hard to create. Luckily for us, we can often get away with making our code available through a command line interface. As you start writing more Haskell programs, you’ll probably have to do this at some point.

This article will go over some of the ins and outs of CLI’s. In particular, we’ll look at the basics of handling options. Then we’ll see some nifty techniques for actually testing the behavior of our CLI.

A Simple Sample

To motivate the examples in this article, let’s design a simple program We’ll have the user input a message. Then we’ll print the message to a file a certain number of times and list the user’s name as the author at the top. We’ll also allow them to uppercase the message if they want. So we’ll get five pieces of input from the user:

  1. The filename they want
  2. Their name to place at the top
  3. Whether they want to uppercase or not
  4. The message
  5. The repetition number

We’ll use arguments and options for the first three pieces of information. Then we’ll have a command line prompt for the other two. For instance, we’ll insist the user pass the expected file name as an argument. Then we’ll take an option for the name the user wants to put at the top. Finally, we’ll take a flag for whether the user wants the message upper-cased. So here are a few different invocations of the program.

>> run-cli “myfile.txt” -n “John Doe”What message do you want in the file?Sample MessageHow many times should it be repeated?5

This will print the following output to myfile.txt:

From: John DoeSample MessageSample MessageSample MessageSample MessageSample Message

Here’s another run, this time with an error in the input:

>> run-cli “myfile2.txt” -n “Jane Doe” -uWhat message do you want in the file?A new messageHow many times should it be repeated?asdfSorry, that isn't a valid number. Please enter a number.3

This file will look like:

From: Jane DoeA NEW MESSAGEA NEW MESSAGEA NEW MESSAGE

Finally, if we don’t get the right arguments, we should get a usage error:

>> run-cliMissing: FILENAME -n USERNAME

Usage: CLIPractice-exe FILENAME -n USERNAME [-u]  Comand Line Sample Program

Getting the Input

So the most important aspect of the program is getting the message and repetitions. We’ll ignore the options for now. We’ll print a couple messages, and then use the getLine function to get their input. There’s no way for them to give us a bad message, so this section is easy.

getMessage :: IO StringgetMessage = do  putStrLn "What message do you want in the file?"  getLine

But they might try to give us a number we can’t actually parse. So for this task, we’ll have to set up a loop where we keep asking the user for a number until they give us a good value. This will be recursive in the failure case. If the user won’t enter a valid number, they’ll have no choice but to terminate the program by other means.

getRepetitions :: IO IntgetRepetitions = do  putStrLn "How many times should it be repeated?"  getNumber

getNumber :: IO IntgetNumber = do  rep <- getLine  case readMaybe rep of    Nothing -> do      putStrLn "Sorry, that isn't a valid number. Please enter a number."      getNumber    Just i -> return i

Once we’re doing reading the input, we’ll print the output to a file. In this instance, we hard-code all the options for now. Here’s the full program.

import Data.Char (toUpper)import System.IO (writeFile)import Text.Read (readMaybe)

runCLI :: IO ()runCLI = do  let fileName = "myfile.txt"  let userName = "John Doe"  let isUppercase = False  message <- getMessage  reps <- getRepetitions  writeFile fileName (fileContents userName message reps isUppercase)

fileContents :: String -> String -> Int -> Bool -> StringfileContents userName message repetitions isUppercase = unlines $  ("From: " ++ userName) :  (replicate repetitions finalMessage)  where    finalMessage = if isUppercase      then map toUpper message     else message

Parsing Options

Now we have to deal with the question of how we actually parse the different options. We can do this by hand with the getArgs function, but this is somewhat error prone. A better option in general is to use the Options.Applicative library. We’ll explore the different possibilities this library allows. We’ll use three different helper functions for the three pieces of input we need.

The first thing we’ll do is build a data structure to hold the different options we want. We want to know the file name to store at, the name at the top, and the uppercase status.

data CommandOptions = CommandOptions  { fileName :: FilePath  , userName :: String  , isUppercase :: Bool }

Now we need to parse each of these. We’ll start with the uppercase value. The most simple parser we have is the flagfunction. It tells us if a particular flag (we’ll call it -u) is present, we’ll uppercase the message, otherwise not. It gets coded like this with the Options library:

uppercaseParser :: Parser BooluppercaseParser = flag False True (short 'u')

Notice we use short in the final argument to denote the flag character. We could also use the switch function, since this flag is only a boolean, but this version is more general.

Now we’ll move on to the argument for the filename. This uses the argument helper function. We’ll use a string parser (str) to ensure we get the actual string. We won’t worry about the filename having a particular format here. Notice we add some metadata to this argument. This tells the user what they are missing if they don’t use the proper format.

fileNameParser :: Parser StringfileNameParser = argument str (metavar "FILENAME")

Finally, we’ll deal with the option of what name will go at the top. We could also do this as an argument, but let’s see what the option is like. An argument is a required positional parameter. An option on the other hand comes after a particular flag. We also add metadata here for a better error message as well. The short piece of our metadata ensures it will use the option character we want.

userNameParser :: Parser FilePathuserNameParser = option str (short 'n' <> metavar "USERNAME")

Now we have to combine these different parsers and add a little more info about our program.

import Options.Applicative (execParser, info, helper, Parser, fullDesc,   progDesc, short, metavar, flag, argument, str, option)

parseOptions :: IO CommandOptionsparseOptions = execParser $ info (helper <*> commandOptsParser) commandOptsInfo  where    commandOptsParser = CommandOptions <$> fileNameParser <*> userNameParser <*> uppercaseParser    commandOptsInfo = fullDesc <> progDesc "Command Line Sample Program"

-- Revamped to take optionsrunCLI :: CommandOptions -> IO ()runCLI commandOptions = do  let file = fileName commandOptions  let user = userName commandOptions  let uppercase = isUppercase commandOptions  message <- getMessage  reps <- getRepetitions  writeFile file (fileContents user message reps uppercase)

And now we’re done! We build our command object using these three different parsers. We chain the operations together using applicatives! Then we pass the result to our main program. If you aren’t too familiar with functors, and applicatives, we went over these a while ago on the blog. Refresh your memory!

IO Testing

Now we have our program working, we need to ask ourselves how we test its behavior. We can do manual command line tests ourselves, but it would be nice to have an automated solution. The key to this is the Handle abstraction.

Let’s first look at some basic file handling types.

openFile :: FilePath -> IO HandlehGetLine :: Handle -> IO StringhPutStrLn :: Handle -> IO ()hClose :: Handle -> IO ()

Normally when we write something to a file, we open a handle for it. We use the handle (instead of the string literal name) for all the different operations. When we’re done, we close the handle.

The good news is that the stdin and stdout streams are actually the exact same Handle type under the hood!

stdin :: Handlestdout :: Handle

How does this help us test? The first step is to abstract away the handles we’re working with. Instead of using print and getLine, we’ll want to use hGetLine and hPutStrLn. Then we take these parameters as arguments to our program and functions. Let’s look at our reading functions:

getMessage :: Handle -> Handle -> IO StringgetMessage inHandle outHandle = do  hPutStrLn outHandle "What message do you want in the file?"  hGetLine inHandle

getRepetitions :: Handle -> Handle -> IO IntgetRepetitions inHandle outHandle = do  hPutStrLn outHandle "How many times should it be repeated?"  getNumber inHandle outHandle

getNumber :: Handle -> Handle -> IO IntgetNumber inHandle outHandle = do  rep <- hGetLine inHandle  case readMaybe rep of    Nothing -> do      hPutStrLn outHandle "Sorry, that isn't a valid number. Please enter a number."      getNumber inHandle outHandle    Just i -> return i

Once we’ve done this, we can make the input and output handles parameters to our program as follows. Our wrapper executable will pass stdin and stdout:

-- Library File:runCLI :: Handle -> Handle -> CommandOptions -> IO ()runCLI inHandle outHandle commandOptions = do  let file = fileName commandOptions  let user = userName commandOptions  let uppercase = isUppercase commandOptions  message <- getMessage inHandle outHandle  reps <- getRepetitions inHandle outHandle  writeFile file (fileContents user message reps uppercase)

-- Executable Filemain :: IO ()main = do  options <- parseOptions  runCLI stdin stdout options

Now our library API takes the handles as parameters. This means in our testing code, we can pass whatever handle we want to test the code. And, as you may have guessed, we’ll do this with files, instead of stdin and stdout. We’ll make one file with our expected terminal output:

What message do you want in the file?How many times should it be repeated?

We’ll make another file with our input:

Sample Message5

And then the file we expect to be created:

From: John DoeSample MessageSample MessageSample MessageSample MessageSample Message

Now we can write a test calling our library function. It will pass the expected arguments object as well as the proper file handles. Then we can compare the output of our test file and the output file.

import Lib

import System.IOimport Test.HUnit

main :: IO ()main = do  inputHandle <- openFile "input.txt" ReadMode  outputHandle <- openFile "terminal_output.txt" WriteMode  runCLI inputHandle outputHandle options  hClose inputHandle  hClose outputHandle  expectedTerminal <- readFile "expected_terminal.txt"  actualTerminal <- readFile "terminal_output.txt"  expectedFile <- readFile "expected_output.txt"  actualFile <- readFile "testOutput.txt"  assertEqual "Terminal Output Should Match" expectedTerminal actualTerminal  assertEqual "Output File Should Match" expectedFile actualFile

options :: CommandOptionsoptions = CommandOptions "testOutput.txt" "John Doe" False

And that’s it! We can also use this process to add tests around the error cases, like when the user enters invalid numbers.

Summary

Writing a command line interface isn’t always the easiest task. Getting a user’s input sometimes requires creating loops if they won’t give you the information you want. Then dealing with arguments can be a major pain. The Options.Applicative library contains many option parsing tools. It helps you deal with flags, options, and arguments. When you're ready to test your program, you'll want to abstract the file handles away. You can use stdinand stdout from your main executable. But then when you test, you can use files as your input and output handles.

Want to try writing a CLI but don’t know Haskell yet? No sweat! Download our Getting Started Checklist and get going learning the language!

When you’re making a full project with executables and test suites, you need to keep organized! Take our FREE Stack mini-course to learn how to organize your Haskell with Stack.