figment-0.2.0.0: src/Figment/Core.hs
-- | Providers, figments, and loading.
--
-- A t'Figment' is a pure /description/: an ordered sequence of providers,
-- each with a combination 'Order'. Building one performs no IO; 'load'
-- runs every provider, collects every failure, and combines the results.
--
-- Unlike the value-level merge, sequencing figments is a lawful
-- 'Monoid' (it is the free monoid on steps), so library defaults and
-- application overrides compose with '<>'.
module Figment.Core
( -- * Providers
Provider (..)
-- * Figments
, Figment
, new
, from
, merge
, join
, adjoin
, admerge
, combine
, select
, withLimits
, figmentLimits
, figmentSources
-- * Loading
, Loaded
, load
, loadedProfile
, loadedData
, loadedSources
, loadedLimits
, selected
, reselect
, profiles
, findValue
, contains
, findMetadata
, focus
) where
import Control.Exception (SomeAsyncException, SomeException (..), displayException, fromException, throwIO, try)
import Data.Foldable (toList)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Maybe (fromMaybe, isJust)
import Data.Monoid (Last (..))
import Data.Sequence (Seq)
import qualified Data.Sequence as Seq
import qualified Data.Text as Text
import GHC.Stack (HasCallStack, SrcLoc, callStack, getCallStack)
import Figment.Error
import Figment.Limits
import Figment.Merge
import Figment.Metadata
import Figment.Path
import Figment.Profile
import Figment.Value
-- | A source of configuration.
--
-- Implementations return one dictionary per profile. Values may be left
-- untagged: 'load' fills in this provider's 'metadata' and the profile
-- for every value that has none. A provider that learns better metadata
-- while reading (e.g. the resolved path of a searched-for file) may tag
-- values itself; existing tags are never overwritten.
--
-- 'provide' must enforce the given 'Limits' on any untrusted input and
-- should report failures as 'Left'. As a safety net, 'load' converts any
-- synchronous exception that escapes into an 'IoError'.
class Provider p where
metadata :: p -> Metadata
provide :: Limits -> p -> IO (Either ConfigErrors (Map Profile Dict))
-- | A profile this provider asks to be selected, if any.
providerProfile :: p -> Maybe Profile
providerProfile _ = Nothing
data Step = Step
{ stepOrder :: !Order
, stepMeta :: !Metadata
, stepProfile :: !(Maybe Profile)
, stepRun :: Limits -> IO (Either ConfigErrors (Map Profile Dict))
}
-- | A configuration description.
data Figment = Figment
{ figSteps :: !(Seq Step)
, figSelect :: !(Last Profile)
, figLimitsOpt :: !(Last Limits)
}
-- | Steps concatenate; for 'select' and 'withLimits' the right-most
-- setting wins. Associative with identity 'new' by construction.
instance Semigroup Figment where
Figment a s l <> Figment b s' l' = Figment (a <> b) (s <> s') (l <> l')
instance Monoid Figment where
mempty = new
-- | The empty figment.
new :: Figment
new = Figment Seq.empty (Last Nothing) (Last Nothing)
-- | A figment with a single provider.
from :: (HasCallStack, Provider p) => p -> Figment
from p = addStep Merge p new
-- | Add a provider whose values override existing ones.
merge :: (HasCallStack, Provider p) => p -> Figment -> Figment
merge = addStep Merge
-- | Add a provider whose values only fill gaps.
join :: (HasCallStack, Provider p) => p -> Figment -> Figment
join = addStep Join
-- | Like 'join', but arrays are concatenated.
adjoin :: (HasCallStack, Provider p) => p -> Figment -> Figment
adjoin = addStep Adjoin
-- | Like 'merge', but arrays are concatenated.
admerge :: (HasCallStack, Provider p) => p -> Figment -> Figment
admerge = addStep Admerge
-- | Add a provider with an explicit order.
combine :: (HasCallStack, Provider p) => Order -> p -> Figment -> Figment
combine = addStep
addStep :: (HasCallStack, Provider p) => Order -> p -> Figment -> Figment
addStep o p fig = fig {figSteps = figSteps fig Seq.|> step}
where
meta0 = metadata p
meta = case (metaAddedAt meta0, callerLoc) of
(Nothing, Just l) -> withAddedAt l meta0
_ -> meta0
step = Step o meta (providerProfile p) (\lim -> provide lim p)
-- | The outermost call site outside this module (the user's @merge@).
callerLoc :: HasCallStack => Maybe SrcLoc
callerLoc = case reverse (getCallStack callStack) of
((_, l) : _) -> Just l
[] -> Nothing
-- | Select the profile to extract. An explicit selection always wins
-- over profile hints from providers.
select :: Profile -> Figment -> Figment
select p fig = fig {figSelect = Last (Just p)}
-- | Override the parser limits for every provider in this figment.
withLimits :: Limits -> Figment -> Figment
withLimits l fig = fig {figLimitsOpt = Last (Just l)}
-- | The parser limits in effect ('defaultLimits' unless overridden).
figmentLimits :: Figment -> Limits
figmentLimits = fromMaybe defaultLimits . getLast . figLimitsOpt
-- | Metadata of every provider, in order.
figmentSources :: Figment -> [Metadata]
figmentSources = map stepMeta . toList . figSteps
-- | A loaded configuration: every provider has been read and combined.
data Loaded = Loaded
{ loadedProfile :: !Profile
-- ^ the selected profile
, loadedData :: !(Map Profile Dict)
-- ^ the combined data of every profile
, loadedSources :: ![Metadata]
-- ^ every provider, in order
, loadedLimits :: !Limits
-- ^ the limits the sources were read with
}
-- | Run every provider, in order. Failures do not stop the load: all of
-- them are collected and reported together.
load :: Figment -> IO (Either ConfigErrors Loaded)
load fig = do
let lim = figmentLimits fig
steps = toList (figSteps fig)
results <- traverse (runStep lim) steps
let errs = [e | Left e <- results]
oks = [(s, d) | (s, Right d) <- zip steps results]
combined = foldl' (\acc (s, d) -> coalesceData (stepOrder s) acc (retagData (stepMeta s) d)) Map.empty oks
hinted = foldl' (\acc s -> maybe acc (coalesceProfile (stepOrder s) acc) (stepProfile s)) Default steps
chosen = fromMaybe hinted (getLast (figSelect fig))
pure $ case errs of
(e : es) -> Left (foldl' (<>) e es)
[] -> Right (Loaded chosen combined (map stepMeta steps) lim)
runStep :: Limits -> Step -> IO (Either ConfigErrors (Map Profile Dict))
runStep lim s = do
r <- try (stepRun s lim)
case r of
Right (Left es) -> pure (Left (fmap' es))
Right ok -> pure ok
Left (ex :: SomeException)
| isJust (fromException ex :: Maybe SomeAsyncException) -> throwIO ex
| otherwise ->
pure (Left (single (withMetadata (Just (stepMeta s)) (configError (IoError (Text.pack (bare ex)))))))
where
-- attach this step's metadata to errors that have none
fmap' es = case fromErrorList [maybe (e {errorMetadata = Just (stepMeta s)}) (const e) (errorMetadata e) | e <- errorList es] of
Just es' -> es'
Nothing -> es
-- | The exception's message without the backtrace GHC >= 9.10 appends.
bare :: SomeException -> String
bare (SomeException e) = displayException e
retagData :: Metadata -> Map Profile Dict -> Map Profile Dict
retagData m = Map.mapWithKey (\p d -> fmap (retag m p) d)
instance Provider Figment where
metadata _ = named "Figment"
provide _ fig = fmap loadedData <$> load fig
providerProfile = getLast . figSelect
-- | The configuration of the selected profile: 'Default', then the
-- selected profile, then 'Global', each merged over the previous.
selected :: Loaded -> Value
selected l =
let get p = Map.findWithDefault Map.empty p (loadedData l)
prof = loadedProfile l
base = get Default
withSel = if prof == Default || prof == Global then base else coalesceDict Merge base (get prof)
final = coalesceDict Merge withSel (get Global)
in Value untagged {tagProfile = Just prof} (Dict final)
-- | Choose another profile without re-reading any source.
reselect :: Profile -> Loaded -> Loaded
reselect p l = l {loadedProfile = p}
-- | Every profile with data.
profiles :: Loaded -> [Profile]
profiles = Map.keys . loadedData
-- | The value at a path in the selected profile.
findValue :: Path -> Loaded -> Maybe Value
findValue p = lookupPath p . selected
-- | Is there a value at this path in the selected profile?
contains :: Path -> Loaded -> Bool
contains p = isJust . findValue p
-- | The provider that supplied the value at a path.
findMetadata :: Path -> Loaded -> Maybe Metadata
findMetadata p l = findValue p l >>= tagMeta . valueTag
-- | Re-root every profile at the given key path (Rust: @focus@).
-- Profiles in which the path is absent or not a dictionary become empty.
focus :: Path -> Loaded -> Loaded
focus p l = l {loadedData = Map.mapMaybe sub (loadedData l)}
where
sub d = case lookupPath p (mkValue (Dict d)) of
Just (Value _ (Dict d')) -> Just d'
_ -> Nothing