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.
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:
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
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
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 flag
function. 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!
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.
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 stdin
and 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.