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:
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 efficientText
typeoptparse-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:
ApplicativeDo
to write nicer code for CLI withoptparse-applicative
DerivingStrategies
to be explicit about how we derive typeclassesGeneralizedNewtypeDeriving
to allow deriving of everything fornewtype
sOverloadedStrings
to be able to work withText
easierRecordWildCards
for non-verbose recordsStrictData
to 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
main
function.
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-terminal
library for printing colored messages. Other libraries such astext
are standard for any CLI tool.optparse-applicative
is 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.
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-applicative
documentation for more details.
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 a
has three type parameters, specialise them to your application. Insimple-grep
:cmd
isOptions
: our CLI options record typeenv
is()
: we won't need a custom environment in our simple toola
isa
: a standard type for value inside the monadic context
-
Derive all the necessary typeclasses
- The important one is
MonadReader
because 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 ())
)
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
--help
output - Version of our tool
- CLI parser for our
Options
type
The CliEnvSettings cmd env
type has two type parameters:
cmd
: the CLI command (this is ourOptions
type)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.
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.
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
getFileContent
function works in ourApp
monad - The function takes
FilePath
and returnsText
- We use
liftIO
to run an action of typeIO Text
asApp Text
(this is possible because we derivedMonadIO
forApp
earlier)
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
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
stdout
while 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) <> ": ")
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
Options
and 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 inCliEnv
in theCliEnvApp
. - Parsed CLI options are stored in the
cliEnvCmd
field of the Iris environment. - We get this field by calling the
asksCliEnv
function. Since ourApp
type derivedMonadReader
with the proper arguments, we can extract all the environment fields.
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
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