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:
$ nix-shell --packages \
'haskellPackages.ghcWithPackages (p: [ p.turtle p.optparse-generic p.aeson ])'
… which is one of the reasons I like to use Nix.
This comment has been removed by the author.
ReplyDeleteNice post. Just curious why you wanted a wrapper around nix-prefetch-git.
ReplyDeleteI didn't. It's a rewrite of the example from Justin's post linked at the beginning
DeleteAlso since you are using nix, you can get the dependencies in a hashbang
ReplyDelete```
#!/usr/bin/env nix-shell
#!nix-shell -i runghc -p "haskellPackages.ghcWithPackages (h: [h.turtle h.optparse-generic h.aeson])"
```
Good stuff
ReplyDeletePretty solid flex. Would post on Twitter, if I had one.
ReplyDelete