Monday, February 11, 2019

Haskell command-line utility using GHC generics

cli-twitter

Today, Justin Woo wrote a post about writing a simple Haskell command-line utility with minimal dependencies. The utility is a small wrapper around the nix-prefetch-git command.

In the post he called out people who recommend overly complex solutions on Twitter:

Nowadays if you read about Haskell on Twitter, you will quickly find that everyone is constantly screaming about some “advanced” techniques and trying to flex on each other

However, I hope to show that we can simplify his original solution by taking advantage of just one feature: Haskell’s support for generating code from data-type definitions. My aim is to convince you that this Haskell feature improves code clarity without increasing the difficulty. If anything, I consider this version less difficult both to read and write.

Without much ado, here is my solution to the same problem (official Twitter edition):

{-# LANGUAGE DeriveAnyClass        #-}
{-# LANGUAGE DeriveGeneric         #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE OverloadedStrings     #-}
{-# LANGUAGE RecordWildCards       #-}

import Data.Aeson (FromJSON, ToJSON)
import Data.Text (Text)
import Options.Generic (Generic, ParseRecord)

import qualified Data.Aeson
import qualified Data.ByteString.Lazy
import qualified Data.Text.Encoding
import qualified Data.Text.IO
import qualified Options.Generic
import qualified Turtle

data Options = Options
    { branch   :: Bool
    , fetchgit :: Bool
    , hashOnly :: Bool
    , owner    :: Text
    , repo     :: Text
    , rev      :: Maybe Text
    } deriving (Generic, ParseRecord)

data NixPrefetchGitOutput = NixPrefetchGitOutput
    { url             :: Text
    , rev             :: Text
    , date            :: Text
    , sha256          :: Text
    , fetchSubmodules :: Bool
    } deriving (Generic, FromJSON)

data GitTemplate = GitTemplate
    { url    :: Text
    , sha256 :: Text
    } deriving (Generic, ToJSON)

data GitHubTemplate = GitHubTemplate
    { owner  :: Text
    , repo   :: Text
    , rev    :: Text
    , sha256 :: Text
    } deriving (Generic, ToJSON)

main :: IO ()
main = do
    Options {..} <- Options.Generic.getRecord "Wrapper around nix-prefetch-git"

    let revisionFlag = case (rev, branch) of
            (Just r , True ) -> "--rev origin/" <> r
            (Just r , False) -> "--rev " <> r
            (Nothing, _    ) -> ""

    let url = "https://github.com/" <> owner <> "/" <> repo <> ".git/"

    let command =
            "GIT_TERMINAL_PROMPT=0 nix-prefetch-git " <> url <> " --quiet " <> revisionFlag

    text <- Turtle.strict (Turtle.inshell command Turtle.empty)

    let bytes = Data.Text.Encoding.encodeUtf8 text

    NixPrefetchGitOutput {..} <- case Data.Aeson.eitherDecodeStrict bytes of
        Left  string -> fail string
        Right result -> return result

    if hashOnly
    then Data.Text.IO.putStrLn sha256
    else if fetchgit
    then Data.ByteString.Lazy.putStr (Data.Aeson.encode (GitTemplate {..}))
    else Data.ByteString.Lazy.putStr (Data.Aeson.encode (GitHubTemplate {..}))

This solution takes advantage of two libraries:

  • optparse-generic

    This is a library I authored which auto-generates a command-line interface (i.e. argument parser) from a Haskell datatype definition.

  • aeson

    This is a library that generates JSON encoders/decoders from Haskell datatype definitions.

Both libraries take advantage of GHC’s support for generating code statically from datatype definitions. This support is known as “GHC generics”. While a bit tricky for a library author to support, it’s very easy for a library user to consume.

All a user has to do is enable two extensions:

… and then they can auto-generate an instance for any typeclass that implements GHC generics support by adding a line like this to the end of their data type:

You can see that in the above example, replacing SomeTypeClass with FromJSON, ToJSON, and ParseRecord.

And that’s it. There’s really not much more to it than that. The result is significantly shorter than the original example (which still omitted quite a bit of code) and (in my opinion) easier to follow because actual program logic isn’t diluted by superficial encoding/decoding concerns.

I will note that the original solution only requires using libraries that are provided as part of a default GHC installation. However, given that the example is a wrapper around nix-prefetch-git then that implies that the user already has Nix installed, so they can obtain the necessary libraries by running this command:

… which is one of the reasons I like to use Nix.