packages feed

servant-cli-0.1.0.3: src/Servant/CLI/Internal/PStruct.hs

{-# LANGUAGE DeriveFunctor #-}
{-# LANGUAGE DeriveTraversable #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE KindSignatures #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TemplateHaskell #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE TypeApplications #-}
{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE TypeOperators #-}

-- |
-- Module      : Servant.CLI.PStruct
-- Copyright   : (c) Justin Le 2019
-- License     : BSD3
--
-- Maintainer  : justin@jle.im
-- Stability   : experimental
-- Portability : non-portable
--
-- Internal module providing a data structure for representing structure of
-- command line parsers that can be manipulated as an ADT, as well as
-- functionality to interpret it as a 'Parser' command line argument
-- parser.
module Servant.CLI.Internal.PStruct
  ( OptRead (..),
    Opt (..),
    Arg (..),
    MultiArg (..),
    Captures,
    Endpoint (..),
    EndpointMap (..),
    PStruct (..),
    PStructF (..),
    structParser,
    structParser_,

    -- * Creating
    branch,
    ($:>),
    (%:>),
    (?:>),
    (#:>),
    (##:>),
    note,
    endpoint,
    rawEndpoint,

    -- ** Readers
    orRequired,
    orOptional,
    orSwitch,
  )
where

import Control.Applicative.Backwards
import Control.Applicative.Free
import Data.Foldable
import Data.Function
import Data.Functor
import Data.Functor.Combinator
import Data.Functor.Combinator.Unsafe
import Data.Functor.Foldable
import Data.Functor.Foldable.TH
import Data.Kind
import Data.List.NonEmpty (NonEmpty (..))
import Data.Map (Map)
import qualified Data.Map as M
import Data.Maybe
import Data.Proxy
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Network.HTTP.Types as HTTP
import Options.Applicative
import qualified Options.Applicative.Help.Pretty as O
import System.FilePath

-- | How to "read" an option.
data OptRead :: Type -> Type where
  ORRequired :: ReadM a -> OptRead a
  OROptional :: ReadM a -> OptRead (Maybe a)
  ORSwitch :: OptRead Bool

-- | Query parameters are interpreted as options
data Opt a = Opt
  { optName :: String,
    optDesc :: String,
    optMeta :: String,
    optVals :: Maybe (NonEmpty String),
    optRead :: Coyoneda OptRead a
  }
  deriving (Functor)

-- | Captures are interpreted as arguments
data Arg a = Arg
  { argName :: String,
    argDesc :: String,
    argMeta :: String,
    argRead :: ReadM a
  }
  deriving (Functor)

-- | Interpret an 'Arg' as something that can be given repeatedly an
-- arbitrary number of times.
data MultiArg :: Type -> Type where
  MultiArg :: Arg a -> MultiArg [a]

-- | A map of endpoints associated with methods, paired with an optional
-- "raw" endpoint.
data EndpointMap a = EPM
  { epmGiven :: Map HTTP.Method (Endpoint a),
    epmRaw :: Maybe (Endpoint (HTTP.Method -> a))
  }
  deriving (Functor)

-- | Captures can be a single capture leading to the next level, or
-- a multi-capture leading to an endpoint action.
type Captures =
  Day Arg PStruct
    :+: Day MultiArg EndpointMap

-- | Endpoint arguments and body.
newtype Endpoint a = Endpoint
  {epStruct :: Day (Ap Opt) Parser a}
  deriving (Functor)

-- | Structure for a parser of a given value that may use items from
-- captures and arguments.
data PStruct a = PStruct
  { psInfo :: [String],
    -- | path components
    psComponents :: Map String (PStruct a),
    -- | captures
    psCaptures :: Maybe (Captures a),
    psEndpoints :: EndpointMap a
  }
  deriving (Functor)

-- TODO: Capture vs. Endpoint interplay is a bit weird, when they are at
-- the same level.

makeBaseFunctor ''PStruct

-- | Convert a 'PStruct' into a command line argument parser, from the
-- /optparse-applicative/ library.  It can be run with 'execParser'.
--
-- It takes options on how the top-level prompt is displayed when given
-- @"--help"@; it can be useful for adding a header or program description.
-- Otherwise, just use 'mempty'.
structParser ::
  -- | The 'PStruct' to convert.
  PStruct a ->
  -- | Modify how the top-level prompt is displayed.
  InfoMod a ->
  ParserInfo a
structParser = flip $ \im -> ($ im) . ($ []) . ($ True) . structParser_

-- | Low-level implementation of 'structParser'.
structParser_ ::
  PStruct a ->
  -- | add helper
  Bool ->
  -- | root path
  [String] ->
  -- | modify top level
  InfoMod a ->
  ParserInfo a
structParser_ = cata go
  where
    go ::
      PStructF x (Bool -> [String] -> InfoMod x -> ParserInfo x) ->
      Bool ->
      [String] ->
      InfoMod x ->
      ParserInfo x
    go PStructF {..} toHelp p im =
      info ((subp <|> cap <|> ep) <**> mkHelp) $
        fullDesc
          <> header (joinPath p)
          <> progDescDoc (Just (O.vcat . map O.pretty $ ns))
          <> im
      where
        subs = M.foldMapWithKey (mkCmd p) psComponentsF
        subp
          | M.null psComponentsF = empty
          | otherwise =
              subparser $
                subs
                  <> metavar "COMPONENT"
                  <> commandGroup "Path components:"
        cap =
          unsafePlus (Proxy @Parser) $
            interpret (mkArg p !*! mkArgs) $
              MaybeF psCapturesF
        ep = methodPicker psEndpointsF
        ns = psInfoF
        mkHelp
          | toHelp = helper
          | otherwise = pure id
    mkCmd ::
      [String] ->
      String ->
      (Bool -> [String] -> InfoMod x -> ParserInfo x) ->
      Mod CommandFields x
    mkCmd ps c p = command c (p True (ps ++ [c]) mempty)
    mkArg :: [String] -> Day Arg PStruct x -> Parser x
    mkArg ps (Day a p f) =
      f
        <$> argParser a
        <*> infoParser (structParser_ p False (ps ++ [':' : argName a]) mempty)
    mkArgs :: Day MultiArg EndpointMap x -> Parser x
    mkArgs =
      unsafeApply (Proxy @Parser) $
        forwards
          . ( Backwards . (\case MultiArg a -> many (argParser a))
                !*! Backwards . methodPicker
            )
    argParser :: Arg x -> Parser x
    argParser Arg {..} =
      argument argRead $
        help argDesc
          <> metavar argMeta
    mkOpt :: Opt x -> Parser x
    mkOpt Opt {..} = forI optRead $ \case
      ORRequired r -> option r mods
      OROptional r -> optional $ option r mods
      ORSwitch -> switch $ long optName <> help optDesc
      where
        mods :: Mod OptionFields y
        mods =
          long optName
            <> help optDesc
            <> metavar optMeta
            <> foldMap (completeWith . toList) optVals
    methodPicker :: EndpointMap x -> Parser x
    methodPicker (EPM eps rw) = case M.minView epMap of
      Nothing -> maybe empty mkRaw rw
      Just (m0, ms)
        | M.null ms && isNothing rw -> m0
        | otherwise ->
            subparser $
              M.foldMapWithKey pickMethod epMap
                <> foldMap mkRawCommand rw
                <> metavar "METHOD"
                <> commandGroup "HTTP Methods:"
      where
        epMap = mkEndpoint <$> eps
    mkEndpoint :: Endpoint x -> Parser x
    mkEndpoint =
      unsafeApply (Proxy @Parser) $
        binterpret (interpret mkOpt) id
          . epStruct
    pickMethod :: HTTP.Method -> Parser x -> Mod CommandFields x
    pickMethod m p = command (T.unpack . T.decodeUtf8 $ m) $ info (p <**> helper) mempty
    mkRaw :: Endpoint (HTTP.Method -> x) -> Parser x
    mkRaw e = mkEndpoint e <*> o
      where
        o =
          strOption @HTTP.Method $
            long "method"
              <> help "method for raw request (GET, POST, etc.)"
              <> metavar "METHOD"
              <> completeWith (show <$> [HTTP.GET ..])
    mkRawCommand :: Endpoint (HTTP.Method -> x) -> Mod CommandFields x
    mkRawCommand d = command "RAW" $ info (mkRaw d <**> helper) mempty

-- | Combine two 'EndpointMap's, preferring the left hand side for
-- conflicts.  If the left hand has a raw endpoint, the right hand's
-- endpoints are ignored.
instance Semigroup (EndpointMap a) where
  (<>) = altEPM

instance Monoid (EndpointMap a) where
  mempty = EPM M.empty Nothing

altEPM :: EndpointMap a -> EndpointMap a -> EndpointMap a
altEPM (EPM e1 r1) (EPM e2 r2) = EPM e3 r3
  where
    e3 = case r1 of
      Just _ -> e1
      Nothing -> M.unionWith const e1 e2
    r3 = r1 <|> r2

altPStruct :: PStruct a -> PStruct a -> PStruct a
altPStruct (PStruct ns1 cs1 c1 ep1) (PStruct ns2 cs2 c2 ep2) =
  PStruct ns3 cs3 c3 ep3
  where
    ns3 = ns1 ++ ns2 -- ??
    cs3 = case c1 of
      Just _ -> cs1
      Nothing -> M.unionWith altPStruct cs1 cs2
    c3 = c1 <|> c2
    ep3 = ep1 <> ep2

-- | Combine two 'PStruct's, preferring the left hand side for conflicts.
-- If the left hand has a capture, the right hand's components are ignored.
-- If the left hand has a raw endpoint, the right hand's endpoints are
-- ignored.
instance Semigroup (PStruct a) where
  (<>) = altPStruct

instance Monoid (PStruct a) where
  mempty = PStruct [] M.empty Nothing mempty

-- | Combine two 'PStruct's in an either-or fashion, favoring the left hand
-- side.
branch :: PStruct a -> PStruct b -> PStruct (Either a b)
branch x y = (Left <$> x) `altPStruct` (Right <$> y)

infixr 3 `branch`

-- | Shift by a path component.
($:>) :: String -> PStruct a -> PStruct a
c $:> p = mempty {psComponents = M.singleton c p}

infixr 4 $:>

-- | Add a command-line option to all endpoints.
(?:>) :: Opt a -> PStruct (a -> b) -> PStruct b
o ?:> PStruct ns cs c ep = PStruct ns cs' c' ep'
  where
    cs' = (o ?:>) <$> cs
    c' =
      c <&> \case
        L1 (Day a p f) ->
          let f' x y z = f z x y
           in L1 $ Day a (o ?:> (f' <$> p)) (&)
        R1 (Day a p f) ->
          let f' x y z = f z x y
           in R1 $ Day a (addEPMOpt o (f' <$> p)) (&)
    ep' = addEPMOpt o ep

infixr 4 ?:>

addEndpointOpt :: Opt a -> Endpoint (a -> b) -> Endpoint b
addEndpointOpt o (Endpoint (Day eo eb ef)) =
  Endpoint (Day ((,) <$> inject o <*> eo) eb $ \(x, y) z -> ef y z x)

addEPMOpt :: Opt a -> EndpointMap (a -> b) -> EndpointMap b
addEPMOpt o (EPM e r) = EPM e' r'
  where
    e' = addEndpointOpt o <$> e
    r' = addEndpointOpt o . fmap flip <$> r

-- | Add notes to the beginning of a documentation level.
note :: [String] -> PStruct a -> PStruct a
note ns (PStruct ms cs c ep) = PStruct (ns ++ ms) cs c ep

infixr 4 `note`

-- | Add a single argument praser.
(#:>) :: Arg a -> PStruct (a -> b) -> PStruct b
a #:> p = mempty {psCaptures = Just (L1 (Day a p (&)))}

infixr 4 #:>

-- | Add a repeating argument parser.
(##:>) :: Arg a -> PStruct ([a] -> b) -> PStruct b
a ##:> p =
  mempty
    { psCaptures = Just (R1 (Day (MultiArg a) (psEndpoints p) (&)))
    }

infixr 4 ##:>

-- | Add a request body to all endpoints.
--
-- If done more than once per endpoint, it runs *both* parsers; however,
-- we can only send one request body, so this is undefined behavior as
-- a client.
(%:>) :: Parser a -> PStruct (a -> b) -> PStruct b
b %:> PStruct ns cs c ep = PStruct ns cs' c' ep'
  where
    cs' = (b %:>) <$> cs
    c' =
      c <&> \case
        L1 (Day a p f) ->
          let f' x y z = f z x y
           in L1 $ Day a (b %:> (f' <$> p)) (&)
        R1 (Day a p f) ->
          let f' x y z = f z x y
           in R1 $ Day a (addEPMBody b (f' <$> p)) (&)
    ep' = addEPMBody b ep

infixr 4 %:>

addEndpointBody :: Parser a -> Endpoint (a -> b) -> Endpoint b
addEndpointBody b (Endpoint d) =
  Endpoint (inR b <**> d)

addEPMBody :: Parser a -> EndpointMap (a -> b) -> EndpointMap b
addEPMBody b (EPM e r) = EPM e' r'
  where
    e' = addEndpointBody b <$> e
    r' = addEndpointBody b . fmap flip <$> r

-- | Create an endpoint action.
endpoint :: HTTP.Method -> a -> PStruct a
endpoint m x =
  mempty
    { psEndpoints = EPM (M.singleton m (Endpoint (pure x))) Nothing
    }

-- | Create a raw endpoint.
rawEndpoint :: (HTTP.Method -> a) -> PStruct a
rawEndpoint f =
  mempty
    { psEndpoints = EPM M.empty (Just (Endpoint (pure f)))
    }

-- | Helper to lift a 'ReadM' into something that can be used with 'optRead'.
orRequired :: ReadM a -> Coyoneda OptRead a
orRequired = inject . ORRequired

-- | Helper to lift an optional 'ReadM' into something that can be used
-- with 'optRead'.
orOptional :: ReadM a -> Coyoneda OptRead (Maybe a)
orOptional = inject . OROptional

-- | An 'optRead' that is on-or-off.
orSwitch :: Coyoneda OptRead Bool
orSwitch = inject ORSwitch