config-value-0.8: src/Config/Macro.hs
{-# LANGUAGE Safe, OverloadedStrings, DeriveTraversable, RankNTypes #-}
{-|
Module : Config.Macro
Description : Configuration pre-processor adding support for aliases and common sections
Copyright : (c) Eric Mertens, 2020
License : ISC
Maintainer : emertens@gmail.com
This module provides assigns meaning to atoms and section names that start with @\@@
and @$@. It provides processing pass for configuration to use local variables and
inclusion to better structure configuration.
= Sigils
* @$@ starts a variable.
* @\@@ starts a directive.
Merge key-value mappings using @\@splice@.
Load external configuration with @\@load@.
= Variables
Variables are atoms that start with a @$@ sigil. Variables are defined by
setting a variable as a section name. This variable will remain in
scope for the remainder of the sections being defined.
Variables used in a value position will be replaced with their previously
defined values.
@
$example: 42
field1: $example
field2: [0, $example]
@
expands to
@
field1: 42
field2: [0, 42]
@
Later variable definitions will shadow earlier definitions.
@
{ $x: 1, $x: 2, k: $x }
@
expands to
@
{ k: 2 }
@
Scoping examples:
@
top1:
a: $x -- BAD: $x not defined yet
$x: 42 -- $x is now defined to be 42
b: $x -- OK: $x was defined above
c: {sub1: $x, sub2: [$x]} -- OK: $x in scope in subsections
-- note: $x now goes out of scope
top2: $x -- BAD: $x no longer in scope
@
Macros are expanded at there definition site. All variables are resolved before
adding the new variable into the environment. Variables are lexically scoped
rather than dynamically scoped.
Allowed:
@
$x: 1
$y: $x -- OK, y is now 1
@
Not allowed:
@
$y: $x -- BAD: $x was not in scope
$x: 1
z: $y
@
= Sections splicing
One sections value can be spliced into another sections value using the @\@spilce@
directive. It is an error to splice a value that is not a key-value sections.
@
$xy: { x: 0, y: 1 }
example:
\@splice: $xy
z: 2
@
expands to
@
example:
x: 0
y: 1
z: 2
@
= File loading
The @\@load@ directive is intended including configuration from other sources.
'loadFileWithMacros' provides an interpretation of this directive that loads
other files. An arbitrary interpretation can be defined with 'expandMacros''
To load a value define a key-value mapping with a single @\@load@ key with a
value specifying the location to load from.
@
x: @load: "fourty-two.cfg"
@
could expand to
@
x: 42
@
-}
module Config.Macro (
-- * Macro expansion primitives
MacroError(..),
expandMacros,
expandMacros',
-- * File loader with inclusion
LoadFileError(..),
FilePosition(..),
loadFileWithMacros
) where
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.IO as Text
import Control.Exception
import Config
import Data.Map (Map)
import Data.Typeable (Typeable)
import qualified Data.Map as Map
-- | Errors from macro expansion annotated with the 'valueAnn' from
-- the 'Value' nearest to the problem (typically a file position).
data MacroError a
= UndeclaredVariable a Text -- ^ Variable used before its defintion
| UnknownDirective a Text -- ^ Unknown directive
| BadSplice a -- ^ Incorrect use of @\@splice@
| BadLoad a -- ^ Incorrect use of @\@load@
deriving
(Eq, Read, Show, Functor, Foldable, Traversable)
instance (Typeable a, Show a) => Exception (MacroError a)
data Special = Plain | Variable Text | Splice | Load
processAtom :: a -> Text -> Either (MacroError a) Special
processAtom a txt =
case Text.uncons txt of
Just ('@',"splice") -> Right Splice
Just ('@',"load" ) -> Right Load
Just ('@',t ) -> Left (UnknownDirective a t)
Just ('$',t ) -> Right (Variable t)
_ -> Right Plain
-- | Expand macros in a configuration value.
--
-- @\@load@ not supported and results in a 'BadLoad' error.
expandMacros :: Value a -> Either (MacroError a) (Value a)
expandMacros = expandMacros' Left (Left . BadLoad . valueAnn) Map.empty
-- | Expand macros in a configuration value using a pre-populated environment.
expandMacros' ::
Monad m =>
(forall b. MacroError a -> m b) {- ^ failure -} ->
(Value a -> m (Value a)) {- ^ @\@load@ implementation -} ->
Map Text (Value a) {- ^ variable environment -} ->
Value a {- ^ value to expand -} ->
m (Value a) {- ^ expanded value -}
expandMacros' failure load = go
where
proc a txt = either failure pure (processAtom a txt)
go env v =
case v of
Number a x -> pure (Number a x)
Text a x -> pure (Text a x)
List a x -> List a <$> traverse (go env) x
Sections _ [Section _ "@load" arg] -> load =<< go env arg
Sections a x -> Sections a <$> elaborateSections env x
Atom a x ->
do x' <- proc a (atomName x)
case x' of
Plain -> pure (Atom a x)
Splice -> failure (BadSplice a)
Load -> failure (BadLoad a)
Variable var ->
case Map.lookup var env of
Nothing -> failure (UndeclaredVariable a var)
Just y -> pure y
elaborateSections _ [] = pure []
elaborateSections env (Section a k v : xs) =
do special <- proc a k
v' <- go env v
case special of
Load -> failure (BadLoad a)
Variable var -> elaborateSections (Map.insert var v' env) xs
Plain -> (Section a k v' :) <$> elaborateSections env xs
Splice ->
case v' of
Sections _ ys -> (ys++) <$> elaborateSections env xs
_ -> failure (BadSplice a)
-- | A pair of filepath and position
data FilePosition = FilePosition FilePath Position
deriving (Read, Show, Ord, Eq)
-- | Errors thrown by 'loadFileWithMacros'
data LoadFileError
= LoadFileParseError FilePath ParseError -- ^ failure to parse a file
| LoadFileMacroError (MacroError FilePosition) -- ^ failure to expand macros
deriving (Eq, Read, Show)
instance Exception LoadFileError
-- | Load a configuration value from a given file path.
--
-- @\@load@ will compute included file path from the given function given the
-- load argument and current configuration file path.
--
-- Valid @\@load@ arguments are string literals use as arguments to
-- the path resolution function.
--
-- Throws `IOError` from file loads and `LoadFileError`
loadFileWithMacros ::
(Text -> FilePath -> IO FilePath) {- ^ inclusion path resolution -} ->
FilePath {- ^ starting file path -} ->
IO (Value FilePosition) {- ^ macro-expanded config value -}
loadFileWithMacros findPath = go
where
go path =
do txt <- Text.readFile path
v1 <- case parse txt of
Left e -> throwIO (LoadFileParseError path e)
Right v -> pure v
let v2 = FilePosition path <$> v1
let loadImpl pathVal =
case pathVal of
Text _ str -> go =<< findPath str path
_ -> throwIO (LoadFileMacroError (BadLoad (valueAnn pathVal)))
expandMacros' (throwIO . LoadFileMacroError) loadImpl Map.empty v2