module Data.Configifier
where
import Control.Applicative ((<$>), (<*>), (<|>))
import Control.Exception (assert)
import Control.Monad.Error.Class (catchError)
import Data.Aeson (ToJSON, FromJSON, Value(Object, Null), object, toJSON)
import Data.CaseInsensitive (mk)
import Data.Char (toUpper)
import Data.Function (on)
import Data.List (nubBy, intercalate, sort)
import Data.Maybe (catMaybes)
import Data.String.Conversions (ST, SBS, cs, (<>))
import Data.Typeable (Typeable, Proxy(Proxy))
import GHC.TypeLits (Symbol, KnownSymbol, symbolVal)
import Safe (readMay)
import qualified Data.Aeson as Aeson
import qualified Data.Aeson.Types as Aeson
import qualified Data.HashMap.Strict as HashMap
import qualified Data.Set as Set
import qualified Data.Vector as Vector
import qualified Data.Yaml as Yaml
import qualified Text.Regex.Easy as Regex
data (s :: Symbol) :> (t :: *) = L t
deriving (Eq, Ord, Show, Typeable)
infixr 9 :>
data a :>: (s :: Symbol) = D a
deriving (Eq, Ord, Show, Typeable)
infixr 8 :>:
data a :| b = a :| b
deriving (Eq, Ord, Show, Typeable)
infixr 6 :|
class Entry a b where
entry :: a -> b
instance (a ~ b) => Entry a b where
entry = id
instance Entry a b => Entry a (s :> b) where
entry = L . entry
instance Entry a b => Entry a (b :>: s) where
entry = D . entry
data Source =
ConfigFileYaml SBS
| ShellEnv [(String, String)]
| CommandLine [String]
deriving (Eq, Ord, Show, Typeable)
data ConfigFile
data ShellEnv
data CommandLine
data Error =
InvalidJSON String
| ShellEnvNoParse (String, String)
| ShellEnvSegmentNotFound String
| CommandLinePrimitiveParseError String
| CommandLinePrimitiveOther Error
deriving (Eq, Ord, Show, Typeable)
configify :: forall cfg .
( FromJSON cfg
, HasParseConfigFile cfg
, HasParseShellEnv cfg
, HasParseCommandLine cfg
) => [Source] -> Either Error cfg
configify sources = sequence (_get <$> sources) >>= _parse . merge
where
proxy = Proxy :: Proxy cfg
_get :: Source -> Either Error Aeson.Value
_get (ConfigFileYaml sbs) = parseConfigFile proxy sbs
_get (ShellEnv env) = parseShellEnv proxy env
_get (CommandLine args) = parseCommandLine proxy args
_parse :: Aeson.Value -> Either Error cfg
_parse = either (Left . InvalidJSON) (Right) . Aeson.parseEither Aeson.parseJSON
class HasParseConfigFile cfg where
parseConfigFile :: Proxy cfg -> SBS -> Either Error Aeson.Value
instance HasParseConfigFile cfg where
parseConfigFile Proxy sbs = either (Left . InvalidJSON) (Right) (Yaml.decodeEither sbs)
instance (KnownSymbol path, FromJSON v) => FromJSON (path :> v) where
parseJSON = Aeson.withObject "config object" $ \ m ->
let proxy = Proxy :: Proxy path
key = cs $ symbolVal proxy
in L <$> m Aeson..: key
instance (FromJSON o1, FromJSON o2)
=> FromJSON (o1 :| o2) where
parseJSON value = (:|) <$> (Aeson.parseJSON value :: Aeson.Parser o1)
<*> (Aeson.parseJSON value :: Aeson.Parser o2)
instance (KnownSymbol path, KnownSymbol descr, FromJSON v) => FromJSON (path :> v :>: descr) where
parseJSON = Aeson.withObject "config object" $ \ m ->
let key = cs $ symbolVal (Proxy :: Proxy path)
in D . L <$> m Aeson..: key
instance (KnownSymbol path, ToJSON v) => ToJSON (path :> v) where
toJSON (L v) = object [cs (symbolVal (Proxy :: Proxy path)) Aeson..= toJSON v]
instance (ToJSON o1, ToJSON o2) => ToJSON (o1 :| o2) where
toJSON (o1 :| o2) = toJSON o1 <<>> toJSON o2
instance (KnownSymbol path, KnownSymbol descr, ToJSON v) => ToJSON (path :> v :>: descr) where
toJSON (D l) = toJSON l
type Env = [(String, String)]
class HasParseShellEnv a where
parseShellEnv :: Proxy a -> Env -> Either Error Aeson.Value
class HasParseShellEnv' a where
parseShellEnv' :: Proxy a -> Env -> Either Error Aeson.Pair
instance HasParseShellEnv Int where
parseShellEnv Proxy [("", s)] = Aeson.Number <$> _catch (readMay s)
where
_catch = maybe (Left $ ShellEnvNoParse ("Int", s)) Right
instance HasParseShellEnv Bool where
parseShellEnv Proxy [("", s)] = Aeson.Bool <$> _catch (readMay s)
where
_catch = maybe (Left $ ShellEnvNoParse ("Bool", s)) Right
instance HasParseShellEnv ST where
parseShellEnv Proxy [("", s)] = Right . Aeson.String $ cs s
instance HasParseShellEnv a => HasParseShellEnv [a] where
parseShellEnv Proxy = fmap (Aeson.Array . Vector.fromList . (:[])) . parseShellEnv (Proxy :: Proxy a)
instance (KnownSymbol path, HasParseShellEnv v) => HasParseShellEnv (path :> v) where
parseShellEnv Proxy env = do
let key = symbolVal (Proxy :: Proxy path)
env' = catMaybes $ _crop <$> env
_crop :: (String, String) -> Maybe (String, String)
_crop (k, v) = case splitAt (length key) k of
(key', s@"") | mk key == mk key' -> Just (s, v)
(key', '_':s@(_:_)) | mk key == mk key' -> Just (s, v)
_ -> Nothing
if null env'
then Left $ ShellEnvSegmentNotFound key
else do
val :: Aeson.Value <- parseShellEnv (Proxy :: Proxy v) env'
return $ object [cs key Aeson..= val]
instance (KnownSymbol path, HasParseShellEnv v, HasParseShellEnv o) => HasParseShellEnv (path :> v :| o) where
parseShellEnv Proxy env = mergeAndCatch
[ parseShellEnv (Proxy :: Proxy o) env
, parseShellEnv (Proxy :: Proxy (path :> v)) env
]
instance (KnownSymbol path, KnownSymbol descr, HasParseShellEnv v) => HasParseShellEnv (path :> v :>: descr) where
parseShellEnv Proxy = parseShellEnv (Proxy :: Proxy (path :> v))
instance (KnownSymbol path, KnownSymbol descr, HasParseShellEnv v, HasParseShellEnv o) => HasParseShellEnv (path :> v :>: descr :| o) where
parseShellEnv Proxy = parseShellEnv (Proxy :: Proxy (path :> v :| o))
type Args = [String]
class HasParseCommandLine cfg where
parseCommandLine :: Proxy cfg -> [String] -> Either Error Aeson.Value
instance (HasParseShellEnv cfg) => HasParseCommandLine cfg where
parseCommandLine = primitiveParseCommandLine
primitiveParseCommandLine :: (HasParseShellEnv cfg) => Proxy cfg -> [String] -> Either Error Aeson.Value
primitiveParseCommandLine proxy args =
convertParseError (lastWins <$> parseArgs args)
>>= convertShellEnvError . parseShellEnv proxy
where
convertParseError = either (Left . CommandLinePrimitiveParseError) Right
convertShellEnvError = either (Left . CommandLinePrimitiveOther) Right
lastWins = reverse . nubBy ((==) `on` fst) . reverse
parseArgs :: Args -> Either String Env
parseArgs [] = Right []
parseArgs (h:[]) = parseArgsWithEqSign h
parseArgs (h:h':t) = ((++) <$> parseArgsWithEqSign h <*> parseArgs (h':t))
<|> ((++) <$> parseArgsWithSpace h h' <*> parseArgs t)
parseArgsWithEqSign :: String -> Either String Env
parseArgsWithEqSign s = case cs s Regex.=~- "^--([^=]+)=(.*)$" of
[_, k, v] -> Right [(cs k, cs v)]
bad -> Left $ "could not parse last arg: " ++ show (s, bad)
parseArgsWithSpace :: String -> String -> Either String Env
parseArgsWithSpace s v = case cs s Regex.=~- "^--([^=]+)$" of
[_, k] -> Right [(cs k, cs v)]
bad -> Left $ "could not parse long-arg with value: " ++ show (s, v, bad)
type family Val (a :: *) (p :: [Symbol]) :: Maybe * where
Val a '[] = Just a
Val (a :| b) (p ': ps) = OrElse (Val a (p ': ps)) (Val b (p ': ps))
Val (a :>: s) (p ': ps) = Val a (p ': ps)
Val (p :> t) (p ': ps) = Val t ps
Val a p = Nothing
type family OrElse (x :: Maybe k) (y :: Maybe k) :: Maybe k where
OrElse (Just x) y = Just x
OrElse Nothing y = y
data CMaybe (a :: Maybe *) where
CNothing :: CMaybe Nothing
CJust :: a -> CMaybe (Just a)
combine :: CMaybe a -> CMaybe b -> CMaybe (OrElse a b)
combine (CJust x) _ = CJust x
combine CNothing y = y
(>.) :: forall a p r . (Sel a p, ValE a p ~ Done r) => a -> Proxy p -> r
(>.) cfg p = case sel cfg p of
CJust x -> x
_ -> error "inaccessible"
class Sel a p where
sel :: a -> Proxy p -> CMaybe (Val a p)
instance Sel a '[] where
sel x _ = CJust x
instance Sel a (p ': ps) => Sel (a :>: s) (p ': ps) where
sel (D x) _ = sel x (Proxy :: Proxy (p ': ps))
instance Sel t ps => Sel (p :> t) (p ': ps) where
sel (L x) _ = sel x (Proxy :: Proxy ps)
instance (Sel a (p ': ps), Sel b (p ': ps)) => Sel (a :| b) (p ': ps) where
sel (x :| y) _ = combine (sel x (Proxy :: Proxy (p ': ps))) (sel y (Proxy :: Proxy (p ': ps)))
instance (Val a ps ~ Nothing) => Sel a ps where
sel _ _ = CNothing
data Exc a b = Fail a | Done b
data LookupFailed a p
type ValE (a :: *) (p :: [Symbol]) = ToExc (LookupFailed a p) (Val a p)
type family ToExc (a :: k) (x :: Maybe l) :: Exc k l where
ToExc a Nothing = Fail a
ToExc a (Just x) = Done x
(<<>>) :: Aeson.Value -> Aeson.Value -> Aeson.Value
(<<>>) (Object m) (Object m') = object $ f <$> ks
where
ks :: [ST]
ks = Set.toList . Set.fromList $ HashMap.keys m ++ HashMap.keys m'
f :: ST -> Aeson.Pair
f k = k Aeson..=
case (k `HashMap.lookup` m, k `HashMap.lookup` m') of
(Just v, Just v') -> v <<>> v'
(Nothing, Just v') -> v'
(Just v, Nothing) -> v
(Nothing, Nothing) -> assert False $ error "internal error in (<<>>)"
(<<>>) v Null = v
(<<>>) _ v' = v'
merge :: [Aeson.Value] -> Aeson.Value
merge = foldl (<<>>) Aeson.Null
mergeAndCatch :: [Either Error Aeson.Value] -> Either Error Aeson.Value
mergeAndCatch = foldl (\ ev ev' -> (<<>>) <$> c'tcha ev <*> c'tcha ev') (Right Null)
where
c'tcha = (`catchError` \ _ -> return Null)
docs :: ( HasToDoc a
, HasRenderDoc ConfigFile
, HasRenderDoc ShellEnv
, HasRenderDoc CommandLine
) => Proxy a -> ST
docs proxy = renderDoc (Proxy :: Proxy ConfigFile) (toDoc proxy)
<> renderDoc (Proxy :: Proxy ShellEnv) (toDoc proxy)
<> renderDoc (Proxy :: Proxy CommandLine) (toDoc proxy)
data Doc =
DocDict [(String, Maybe String, Doc)]
| DocList Doc
| DocBase String
deriving (Eq, Ord, Show, Read, Typeable)
concatDoc :: Doc -> Doc -> Doc
concatDoc (DocDict xs) (DocDict ys) = DocDict . sort $ xs ++ ys
concatDoc bad bad' = error $ "concatDoc: " ++ show (bad, bad')
class HasToDoc a where
toDoc :: Proxy a -> Doc
_makeDocPair :: (KnownSymbol path, KnownSymbol descr, HasToDoc v)
=> Proxy path -> Maybe (Proxy descr) -> Proxy v -> Doc
_makeDocPair pathProxy descrProxy vProxy = DocDict [(symbolVal pathProxy, symbolVal <$> descrProxy, toDoc vProxy)]
instance (KnownSymbol path, HasToDoc v) => HasToDoc (path :> v) where
toDoc Proxy = _makeDocPair (Proxy :: Proxy path) (Nothing :: Maybe (Proxy path)) (Proxy :: Proxy v)
instance (KnownSymbol path, KnownSymbol descr, HasToDoc v) => HasToDoc (path :> v :>: descr) where
toDoc Proxy = _makeDocPair (Proxy :: Proxy path) (Just (Proxy :: Proxy descr)) (Proxy :: Proxy v)
instance (HasToDoc o1, HasToDoc o2) => HasToDoc (o1 :| o2) where
toDoc Proxy = toDoc (Proxy :: Proxy o1) `concatDoc` toDoc (Proxy :: Proxy o2)
instance HasToDoc a => HasToDoc [a] where
toDoc Proxy = DocList . toDoc $ (Proxy :: Proxy a)
instance HasToDoc ST where
toDoc Proxy = DocBase "string"
instance HasToDoc Int where
toDoc Proxy = DocBase "number"
instance HasToDoc Bool where
toDoc Proxy = DocBase "boolean"
class HasRenderDoc t where
renderDoc :: Proxy t -> Doc -> ST
instance HasRenderDoc ConfigFile where
renderDoc Proxy doc = cs . unlines $
"" :
"Config File" :
"-----------" :
"" :
f doc ++
"" :
[]
where
f :: Doc -> [String]
f (DocDict xs) = concat $ map g xs
f (DocList x) = indent "- " $ f x
f (DocBase base) = [base]
g :: (String, Maybe String, Doc) -> [String]
g (key, Just mDescr, subdoc) = ("# " <> mDescr) : (key <> ":") : indent " " (f subdoc)
g (key, Nothing, subdoc) = (key <> ":") : indent " " (f subdoc)
indent :: String -> [String] -> [String]
indent start = lines . (start <>) . intercalate "\n "
instance HasRenderDoc ShellEnv where
renderDoc Proxy doc = cs . unlines $
"" :
"Shell Environment Variables" :
"---------------------------" :
"" :
(f [] doc) ++
"" :
[]
where
f :: [(String, Maybe String)] -> Doc -> [String]
f acc (DocDict xs) = concat $ map (g acc) xs
f acc (DocList x) = f acc x
f (reverse -> acc) (DocBase base) =
shellvar :
(" type: " ++ base) :
(let xs = catMaybes (mkd <$> acc) in
if null xs then [] else " documented components:" : xs) ++
"" :
[]
where
shellvar :: String
shellvar = map toUpper . intercalate "_" . map fst $ acc
mkd :: (String, Maybe String) -> Maybe String
mkd (_, Nothing) = Nothing
mkd (key, Just descr) = Just $ " " ++ (toUpper <$> key) ++ ": " ++ descr
g :: [(String, Maybe String )] -> (String, Maybe String, Doc) -> [String]
g acc (key, descr, subdoc) = f ((key, descr) : acc) subdoc
instance HasRenderDoc CommandLine where
renderDoc Proxy _ = cs . unlines $
"" :
"Command Line Arguments" :
"----------------------" :
"" :
"See `shell environment`. (Anything you can set with a shell" :
"variable, you can also set with a long arg.)" :
"" :
[]