Introduction
March 24, 2023 · View on GitHub
The file contains an introduction tutorial on building a simple CLI tool with Iris.
We're going to build a tool for finding lines that contain a given string in a given file. Like a very simple clone of grep or more modern ripgrep
The tool can be executed using cabal as shown in the following example:
cabal run simple-grep -- --file iris.cabal --search iris
When run, you can expect to see output similar to the following:

Preamble: imports and language extensions
Our simple example uses the following Haskell packages:
base: the Haskell standard librarypretty-terminal: a terminal output colouring libraryiris: a Haskell CLI frameworkmtl: a library with monad transformerstext: a library with the efficientTexttypeoptparse-applicative: a CLI options parser
Since this is a literate Haskell file, we need to specify all our language pragmas and imports upfront.
First, let's opt-in to some Haskell features not enabled by default:
{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE StrictData #-}
We use several extensions for different parts:
ApplicativeDoto write nicer code for CLI withoptparse-applicativeDerivingStrategiesto be explicit about how we derive typeclassesGeneralizedNewtypeDerivingto allow deriving of everything fornewtypesOverloadedStringsto be able to work withTexteasierRecordWildCardsfor non-verbose recordsStrictDatato avoid space leaks
Then, the module header:
module Main (main) where
Our tool is going to be rather small, so it could be put in a single file which needs to export only the single
mainfunction.
Now, imports from external libraries:
import Control.Monad.IO.Class (MonadIO (..))
import Control.Monad.Reader (MonadReader)
import Data.Foldable (traverse_)
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.IO as Text
import qualified Options.Applicative as Opt
import qualified System.Console.Pretty as Pretty
We're writing a simple grep utility, we need here the
pretty-terminallibrary for printing colored messages. Other libraries such astextare standard for any CLI tool.optparse-applicativeis needed here for defining the set of commands that Iris will consume.
✨ Iris is designed for qualified imports. To get access to the entire API,
write the following single line:
import qualified Iris
Finally, we import an autogenerated Paths_* file to get access to our tool
metadata:
import qualified Paths_simple_grep as Autogen
Read more about
Paths_*modules in the Cabal documentation.
CLI
First, let's define CLI for simple-grep.
Our tool takes two arguments:
- Path to the file for our search.
- A string to search for.
These options can be represented as a simple record type:
data Options = Options
{ optionsFile :: FilePath
, optionsSearch :: Text
}
After we defined the options data type, we can write a CLI parser using
optparse-applicative.
optionsP :: Opt.Parser Options
optionsP = do
optionsFile <- Opt.strOption $ mconcat
[ Opt.long "file"
, Opt.short 'f'
, Opt.metavar "FILE_PATH"
, Opt.help "Path to the file"
]
optionsSearch <- Opt.strOption $ mconcat
[ Opt.long "search"
, Opt.short 's'
, Opt.metavar "STRING"
, Opt.help "Substring to find and highlight"
]
pure Options{..}
Refer to the
optparse-applicativedocumentation for more details.
The Application Monad
When using Iris, you're expected to implement your own App monad as a wrapper
around CliApp from iris.
To do this:
-
Create
newtype App a -
CliApp cmd env ahas three type parameters, specialise them to your application. Insimple-grep:cmdisOptions: our CLI options record typeenvis(): we won't need a custom environment in our simple toolaisa: a standard type for value inside the monadic context
-
Derive all the necessary typeclasses
- The important one is
MonadReaderbecause many functions in Iris are polymorphic over monad and deriving this typeclass enables reusing them
- The important one is
In code, it looks like this:
newtype App a = App
{ unApp :: Iris.CliApp Options () a
} deriving newtype
( Functor
, Applicative
, Monad
, MonadIO
, MonadReader (Iris.CliEnv Options ())
)
Settings
To run the application, we need to configure it by providing settings. Iris
has the CliEnvSettings type with multiple configuration
options.
In simple-grep, we want to specify the following:
- Short header description
- Longer program description that appears in the
--helpoutput - Version of our tool
- CLI parser for our
Optionstype
The CliEnvSettings cmd env type has two type parameters:
cmd: the CLI command (this is ourOptionstype)env: custom application environment (again, this is just()as we don't have custom env)
In code, it looks like this:
appSettings :: Iris.CliEnvSettings Options ()
appSettings = Iris.defaultCliEnvSettings
{ -- short description
Iris.cliEnvSettingsHeaderDesc = "Iris usage example"
-- longer description
, Iris.cliEnvSettingsProgDesc = "A simple grep utility - tutorial example"
-- a function to display the tool version
, Iris.cliEnvSettingsVersionSettings =
Just (Iris.defaultVersionSettings Autogen.version)
{ Iris.versionSettingsMkDesc = \v -> "Simple grep utility v" <> v
}
-- our 'Options' CLI parser
, Iris.cliEnvSettingsCmdParser = optionsP
}
Our appSettings are created with the help of defaultCliEnvSettings. This way
you can specify only relevant fields and be forward-compatible in case Iris
introduces new settings options.
Business logic
Now, as we finished configuring our CLI application, we can finally start implementing the main logic of searching the content inside the files.
We need three main parts:
- Read file content.
- Search for lines in the file.
- Output the result.
Reading the input file
First, let's write a helper function for reading the content of the file:
getFileContent :: FilePath -> App Text
getFileContent = liftIO . Text.readFile
A few comments:
- Our
getFileContentfunction works in ourAppmonad - The function takes
FilePathand returnsText - We use
liftIOto run an action of typeIO TextasApp Text(this is possible because we derivedMonadIOforAppearlier)
Searching
We want to output lines of text that contain our given substring as well as the line numbers.
Following the Imperative Shell, Functional Core programming pattern, we can write a pure function that takes an input search term, file content and returns a list of pairs that contain the line number and line text:
search :: Text -> Text -> [(Int, Text)]
The implementation of this function is straightforward in Functional Programming:
search str
= filter (\(_i, line) -> str `Text.isInfixOf` line)
. zip [1 ..]
. Text.lines
Output
Now, once we find our lines, we would like to output the result. To make things more interesting and highlight a few more Iris features, we would like add a few more requirements to our output:
- Lines of text should be printed to
stdoutwhile liner numbers should gostderr. - Line numbers should be coloured and bold.
We're going to use pretty-terminal for colouring. Iris provides helper functions
for handling terminal colouring support and printing coloured output.
Writing this in the code:
output :: [(Int, Text)] -> App ()
output = traverse_ outputLine
where
outputLine :: (Int, Text) -> App ()
outputLine (i, line) = do
outputLineNumber i
liftIO $ Text.putStrLn line
outputLineNumber :: Int -> App ()
outputLineNumber i = Iris.putStderrColoured
(Pretty.color Pretty.Yellow . Pretty.style Pretty.Bold)
(Text.pack (show i) <> ": ")
Putting all together
After we've implemented relevant parts, we're ready to put everything together.
Our main steps are:
- Get the parsed CLI arguments.
- Read the file.
- Search for content.
- Output the result.
For this, we can create the top-level function app and put all steps there:
app :: App ()
app = do
-- 1. Get parsed 'Options' from the environment
Options{..} <- Iris.asksCliEnv Iris.cliEnvCmd
-- 2. Read the file
fileContent <- getFileContent optionsFile
-- 3. Find all lines with numbers
let searchResult = search optionsSearch fileContent
-- 4. Output the result
output searchResult
The only thing left is to run our app function from main.
This can be done by unwrapping Iris.CliApp from our App and providing
settings to the runCliApp function:
main :: IO ()
main = Iris.runCliApp appSettings $ unApp app
A few final notes:
- We've implemented only a parser for
Optionsand specified our parser as a field inappSettings. Iris will run the parser on the start and either throw an exception on parsing errors or parse successfully and provide the result inCliEnvin theCliEnvApp. - Parsed CLI options are stored in the
cliEnvCmdfield of the Iris environment. - We get this field by calling the
asksCliEnvfunction. Since ourApptype derivedMonadReaderwith the proper arguments, we can extract all the environment fields.
Result
Our simple tool is finished! Now, we can see its --help output to make sure
that all the arguments are valid:
$ cabal run simple-grep -- --help
and the output:
Iris usage example
Usage: simple-grep [--version] [--numeric-version] [--no-input]
(-f|--file FILE_PATH) (-s|--search STRING)
[--colour | --no-colour]
A simple grep utility - tutorial example
Available options:
-h,--help Show this help text
--version Show application version
--numeric-version Show only numeric application version
--no-input Enter the terminal in non-interactive mode
-f,--file FILE_PATH Path to the file
-s,--search STRING Substring to find and highlight
--colour Enable colours
--no-colour Disable colours
And we can finally run it to see the result:
$ cabal exec simple-grep -- -f iris.cabal -s iris
2: name: iris
7: See [README.md](https://github.com/chshersh/iris#iris) for more details.
8: homepage: https://github.com/chshersh/iris
9: bug-reports: https://github.com/chshersh/iris/issues
27: location: https://github.com/chshersh/iris.git
107: test-suite iris-test
117: Paths_iris
121: , iris
Bonus challenges
If you wish to hack on this example more, try implementing the following improvements in the tool:
- Add the
[-i | --ignore-case]option to support the case-insensitive mode - Highlight the found part of the string inside the found line
- Search in multiple files inside the directory