{-# 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 their 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 @\@splice@
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
