Skip to content

Latest commit

 

History

History
407 lines (298 loc) · 11.8 KB

README.md

File metadata and controls

407 lines (298 loc) · 11.8 KB

Introduction

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:

demo-simple-grep

Preamble: imports and language extensions

Our simple example uses the following Haskell packages:

  • base: the Haskell standard library
  • pretty-terminal: a terminal output colouring library
  • iris: a Haskell CLI framework
  • mtl: a library with monad transformers
  • text: a library with the efficient Text type
  • optparse-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 with optparse-applicative
  • DerivingStrategies to be explicit about how we derive typeclasses
  • GeneralizedNewtypeDeriving to allow deriving of everything for newtypes
  • OverloadedStrings to be able to work with Text easier
  • RecordWildCards for non-verbose records
  • StrictData 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 as text 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.

CLI

First, let's define CLI for simple-grep.

Our tool takes two arguments:

  1. Path to the file for our search.
  2. 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.

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 a has three type parameters, specialise them to your application. In simple-grep:

    • cmd is Options: our CLI options record type
    • env is (): we won't need a custom environment in our simple tool
    • a is a: 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

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 --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 our Options 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.

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:

  1. Read file content.
  2. Search for lines in the file.
  3. 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 getFileContent function works in our App monad
  • The function takes FilePath and returns Text
  • We use liftIO to run an action of type IO Text as App Text (this is possible because we derived MonadIO for App earlier)

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:

  1. Lines of text should be printed to stdout while liner numbers should go stderr.
  2. 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:

  1. Get the parsed CLI arguments.
  2. Read the file.
  3. Search for content.
  4. 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 in appSettings. Iris will run the parser on the start and either throw an exception on parsing errors or parse successfully and provide the result in CliEnv in the CliEnvApp.
  • Parsed CLI options are stored in the cliEnvCmd field of the Iris environment.
  • We get this field by calling the asksCliEnv function. Since our App type derived MonadReader with 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