packages feed

figment-0.2.0.0: src/Figment/Provider/Cli.hs

-- | Command-line arguments as a provider.
--
-- Rules (ADR 010, revised):
--
-- * @--key=value@ sets @key@; the value is parsed like an environment
--   variable (numbers, booleans, arrays, ...; original text kept).
-- * @--flag@ sets @flag = true@; @--no-flag@ sets @flag = false@.
-- * Dots nest (@--db.port=5432@) and dashes become underscores
--   (@--max-connections@ is @max_connections@); keys are not
--   lower-cased.
-- * @--@ ends option parsing. Anything else (including @-x@ short
--   options) is positional and returned by 'parseArgs'; the provider
--   ignores it.
-- * @--key value@ is /not/ supported: without a schema it is ambiguous
--   with a flag followed by a positional argument. For rich CLIs, parse
--   with optparse-applicative and feed the result in via
--   'Figment.Aeson.defaults'.
-- * Repeating a key: the last occurrence wins. A malformed option
--   (@--=x@, @--a..b=1@) is an error, not silently ignored.
module Figment.Provider.Cli
  ( Cli
  , cli
  , cliFromProcess
  , keyMap
  , unparsedCli
  , cliInProfile
  , parseArgs
  , Arg (..)
  ) where

import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as Text
import System.Environment (getArgs)

import Figment.Core
import Figment.Error
import Figment.Internal.Nest
import Figment.Internal.ValueParser (parseValue)
import Figment.Limits
import Figment.Metadata
import Figment.Path
import Figment.Profile
import Figment.Value

-- | Command-line arguments as a configuration source (see the module header for the rules).
data Cli = Cli
  { cliArgs :: [Text]
  , cliKeyMap :: Text -> Text
  , cliParsed :: Bool
  , cliProfile :: Profile
  }

-- | A CLI provider over the given arguments.
cli :: [Text] -> Cli
cli args = Cli args (Text.replace "-" "_") True Default

-- | The arguments of the running process.
cliFromProcess :: IO Cli
cliFromProcess = cli . map Text.pack <$> getArgs

-- | How each key segment is mapped (default: dashes to underscores).
keyMap :: (Text -> Text) -> Cli -> Cli
keyMap f c = c {cliKeyMap = f}

-- | Do not parse values: every value is a string.
unparsedCli :: Cli -> Cli
unparsedCli c = c {cliParsed = False}

-- | Put the values in a profile other than 'Default'.
cliInProfile :: Profile -> Cli -> Cli
cliInProfile p c = c {cliProfile = p}

-- | One classified argument.
data Arg
  = Option !Text ![Text] !(Maybe Text)
    -- ^ original argument, key segments, value (@Nothing@: a flag)
  | NegatedFlag !Text ![Text]
  | Positional !Text
  | Malformed !Text
  deriving stock (Eq, Show)

-- | Classify every argument.
parseArgs :: Cli -> [Arg]
parseArgs c = go (cliArgs c)
  where
    go [] = []
    go ("--" : rest) = map Positional rest
    go (a : rest) = classify a : go rest
    classify a = case Text.stripPrefix "--" a of
      Nothing -> Positional a
      Just body ->
        let (k, eqv) = Text.breakOn "=" body
            v = if Text.null eqv then Nothing else Just (Text.drop 1 eqv)
        in case keySegs k of
             Nothing -> Malformed a
             Just segs -> case (v, Text.stripPrefix "no-" k) of
               (Nothing, Just positive) | Just segs' <- keySegs positive -> NegatedFlag a segs'
               _ -> Option a segs v
    keySegs k
      | Text.null k = Nothing
      | otherwise =
          let segs = map (cliKeyMap c) (Text.splitOn "." k)
          in if any Text.null segs then Nothing else Just segs

instance Provider Cli where
  metadata _ =
    withInterpolater (\_ p -> "--" <> Text.intercalate "." [Text.replace "_" "-" k | Key k <- segments p]) $
      named "command-line arguments"
  provide lim c = pure $
    let args = parseArgs c
        malformed = [ atPath root (configError (SyntaxError ("malformed option `" <> a <> "`"))) | Malformed a <- args ]
        entries = concatMap (entry lim c) args
        failures = malformed ++ [e | Left e <- entries]
    in case fromErrorList failures of
         Just es -> Left es
         Nothing -> Map.singleton (cliProfile c) <$> nest LastWins [x | Right x <- entries]

entry :: Limits -> Cli -> Arg -> [Either ConfigError (Text, [Text], Value)]
entry lim c = \case
  Option a segs (Just v)
    | cliParsed c -> [(a, segs, ) <$> parseValue lim v]
    | otherwise -> [Right (a, segs, Value untagged {tagRaw = Just v} (String v))]
  Option a segs Nothing -> [Right (a, segs, flag True)]
  NegatedFlag a segs -> [Right (a, segs, flag False)]
  _ -> []
  where
    flag b = Value untagged {tagRaw = Just (if b then "true" else "false")} (Bool b)