Command-line tool in Haskell step-by-step

After finishing Introduction to Functional Programming course on edx (which was mainly focused on Haskell) I was very excited about this language. Concise, powerful type inference system, designed to make functional programming easy to write and read. I have even found out that it has a few concepts that corresponds with quantum physics. And actually I’m not the only one. There is even library called quipper to program quantum computer (which does not exists yet) based on Haskell… but it’s a topic for another article. Then I started to feel that Haskell is awesome but it’s most likely too complex and too academic to be applied for regular tasks. You remember “There is no bad or good languages, tools, technologies, etc. You should choose appropriate technology to solve your problem in most efficient way.” I started to look for proofs and opinions on the Internet to confirm my bad feeling, but instead I found out that Haskell is mature enough to become technology of chose for many applications. And this time we are going to build simple command-line tool on Haskell and see how easy it is.

Step-by-step tutorial:

Step 1. Create any-tool using Stack

I’m going to use stack package manager to create, build and run project. Run the following commands to create project:

$ stack new any-tool simple
$ cd any-tool

any-tool is name of the project and simple is template name. You can check list of templates using stack templates command and choose most appropriate. For example it can be new-template. It will create project with app, library and tests folders with simplest code you can have.

Step 2. Add Turtle library for command-line tools

Turtle is very nice library to write command-line tools in Haskell. Open any-tool.cabal file and add change yaml to have the following:

build-depends: base >= 4.7 && < 5
             , turtle

Make sure you have the following header on top of Main.hs

{-# LANGUAGE OverloadedStrings #-}
module Main where
import Turtle

Step 3. Write first command

We are going to define default behavior when user types any-tool command in terminal

mainSubroutine :: IO ()
mainSubroutine = echo "Any tool just works!"

Then create parser for your subroutine using pure function without any argument or flag-based options. It will run mainSubroutine by default

parseMain :: Parser (IO ())
parseMain = pure mainSubroutine

parser :: Parser (IO ())
parser = parseMain

main :: IO ()
main = do
    cmd <- options "Just any tool you could imagine" parser
    cmd

Let’s build it, run it and see output. If you run your tool using stack as I do you can pass arguments after --. Thus stack exec any-tool -- subcommand --help is equivalent of any-tool subcommand --help.

$ stack build
$ stack exec any-tool
Any tool just works!

$ stack exec any-tool -- --help
Just any tool you could imagine

Usage: any-tool

Available options:
  -h,--help                Show this help text

Output is expected, help text pretty descriptive. Looks OK but not very impressive so far. Let’s extend command-line tool to have some extra functionality.

Step 5. Print version

To print version number of your tool you should import Data.Version module and version function from the Paths_{program_name} file created by Cabal with metadata and module to retrieve version inside.

import Paths_any_tool (version)
import Data.Version (showVersion)

Then you can print version

version' :: IO()
version' = putStrLn (showVersion version)

Step 6. Add subcommand to print version information

Let’s add a new subcommand to print verbose version information any-tool version

verboseVersion :: IO()
verboseVersion = do
                 version'
                 echo "Verbose version information"

and wrap it using Turtle API to print help for new command. Version subcommand does not have any arguments or options so we will use pure function again.

parseVersion :: Parser (IO ())
parseVersion =
    (subcommand "version" "Show the Awesome tool version" (pure verboseVersion))

Last step is to add it to main parser:

parser = parseMain <|> parseVersion

Build and run the tool:

$ any-tool version
0.1.0.0
Verbose version information

$ any-tool version --help
Show any-tool version

Usage: any-tool version

Available options:
  -h,--help                Show this help text

Step 7. Add subcommand with arguments and options

This step-by-step guide would be not complete without subcommand with command line arguments and flag-based options. Let’s build subcommand that prints input text several times. By default it will print text once but we will add flag-based option to specify number of times input text should be printed. For instance: any-tool print --times 5 "text to print".

Maybe monad and pattern matching make implementation of options trivial. Function will accept tuple of Maybe Int as first element with number of times we have to repeat Text passed as second element of the tuple.

printText :: (Maybe Int, Text) -> IO()
printText (Nothing, text) = echo text
printText ((Just i), text) = replicateM_ i (echo text)

Now we need to define wrapper for printText function. We can take a look at Turtle documentation for more information. To create optional --times or -n option we will use optional to convert result of optInt to Maybe monad. To create positional argument we will use argText.

printArgs :: Parser (Maybe Int, Text)
printArgs = (,) <$> optional (optInt "times" 'n' "Number of times")
                <*> (argText "text" "Text to print")

Then combine printText and printArgs with subcommand name and help message

parsePrint :: Parser (IO ())
parsePrint = fmap printText
    (subcommand "print" "Print specified text specified number of times" printArgs)

and last step is to extend main parser

parser = parseMain <|> parseVersion <|> parsePrint

Here we are. Build and run.

$ any-tool print
Usage: any-tool print [-n|--times TIMES] TEXT

We forgot required argument and turtle shows us how to use print subcommand.

$ any-tool print "text to print"
text to print

$ any-tool print --times 3 "it will appear 3 times"
it will appear 3 times
it will appear 3 times
it will appear 3 times

And last but not least take a look at result help for main command and for print subcommand

$ any-tool -h
Just any tool you could imagine

Usage: any-tool ([version] | [print])

Available options:
  -h,--help                Show this help text

Available commands:
  version
  print
$ any-tool print --help
Print specified text specified number of times

Usage: any-tool print [-n|--times TIMES] TEXT

Available options:
  -h,--help                Show this help text
  -n,--times TIMES         Number of times
  TEXT                     Text to print

Step 8. Extend your tool following Turtle tutorial

Strictly speaking it’s better not to mix parsing logic and logic of your application. One of the improvements is to define command data type so parser will return command and then main will execute command. Something like presented below:

data Command = Add Int | Subtract Int | Divide Int | Multiply Int

-- and then have something like this
main = do
    cmd <- options "Just any tool you could imagine" commandParser
    exec cmd

Conclusions

At the matter of fact Turtle is mainly focused on writing shell scripts on Haskell but it also allows us to build pretty advanced command-line tools. If you are interested in more information about Turtle take a look at [official site] and tutorial.

You can find source code from this blog post here on github

Happy coding!

blog comments powered by Disqus