packages feed

wireform-derive-0.1.0.0: src/Wireform/Derive/ModifierInfo.hs

{-# LANGUAGE TemplateHaskell #-}

-- | Backend-aware folding of @[Modifier]@ annotations into a typed
-- 'ModifierInfo' record.
--
-- A 'ModifierInfo' is the input each per-format deriver consumes. It
-- has been resolved against a single 'Backend' so the deriver does
-- not need to think about per-backend overrides itself.
--
-- == Resolution strategy
--
-- Modifiers are folded in two passes:
--
-- 1. /Global pass/ — every modifier that is not 'modifierIsBackendScoped'
--    is folded in. Conflicts within the global pass raise a
--    'ModifierError' (e.g. two distinct global renames).
-- 2. /Backend pass/ — modifiers wrapped in 'ModForBackends' /
--    'ModBackendOnly' / 'ModBackendDisable' that target the active
--    'Backend' are then folded in /on top of/ the global result. This
--    pass shadows global directives without raising a conflict, which
--    is the whole point of per-backend overrides.
module Wireform.Derive.ModifierInfo
  ( -- * Folded representation
    ModifierInfo (..)
  , RenameSpec (..)
  , emptyModifierInfo

    -- * Resolution
  , foldModifiers
  , reifyModifierInfoFor
  , reifyModifierInfo

    -- * Errors
  , ModifierError (..)

    -- * Wire-key rendering
  , renderRenameKey
  , renderWireKey

    -- * Helpers
  , defaultRenameForBackend
  ) where

import Control.Exception (Exception)
import qualified Data.Map.Strict as Map
import Data.Map.Strict (Map)
import Data.Text (Text)
import qualified Data.Text as T
import Language.Haskell.TH (Exp, Name, Q, varE)
import Language.Haskell.TH.Syntax (Lift (lift), Quasi, qReifyAnnotations)
import qualified Language.Haskell.TH.Syntax as TH

import Wireform.Derive.Backend
import Wireform.Derive.Modifier
import Wireform.Derive.NameStyle

-- ---------------------------------------------------------------------------
-- Folded ModifierInfo
-- ---------------------------------------------------------------------------

-- | A field's resolved rename spec.
--
-- * 'RenameSpecLiteral' — a fully-baked 'Text' (from 'RenameTo'). Used
--   verbatim as the wire key.
-- * 'RenameSpecStyle' — a 'NameStyle' to apply to the field's
--   selector base name at render time (so 'Idiomatic' can be resolved
--   against the deriver's active backend).
-- * 'RenameSpecApply' — a @Text -> Text@ function to call at /runtime/
--   on the selector base name.
data RenameSpec
  = RenameSpecLiteral !Text
  | RenameSpecStyle   !NameStyle
  | RenameSpecApply   !Name
  deriving (Eq, Show)

-- | A fully resolved view of the modifiers attached to a single
-- 'Name', filtered for one active 'Backend'.
data ModifierInfo = ModifierInfo
  { miBackend       :: !Backend
    -- ^ The backend this 'ModifierInfo' was resolved for.
  , miRename        :: !(Maybe RenameSpec)
    -- ^ The wire key, if explicitly customised. When 'Nothing' the
    -- deriver should fall back to its default policy (typically
    -- 'defaultRenameForBackend').
  , miCoerce        :: !(Maybe Name)
  , miFlatten       :: !Bool
  , miSkip          :: !Bool
  , miDefaults      :: !(Maybe Name)
  , miTag           :: !(Maybe Int)
  , miRequired      :: !(Maybe Bool)
    -- ^ 'Just True' = required; 'Just False' = optional; 'Nothing' =
    -- format default.
  , miWireOverride  :: !(Maybe WireOverride)
  , miMapKey        :: !(Maybe MapKeyScalar)
    -- ^ Proto map key scalar (proto deriver only).
  , miOneof         :: !(Maybe Text)
    -- ^ Name of the proto @oneof@ this field belongs to (proto
    -- deriver only).
  , miCustom        :: !(Map Text [Modifier])
    -- ^ All 'ModCustom' payloads grouped by tag. Backends can scan
    -- their own tag and ignore the rest.
  } deriving (Eq, Show)

-- | The empty 'ModifierInfo' for a given backend.
emptyModifierInfo :: Backend -> ModifierInfo
emptyModifierInfo b = ModifierInfo
  { miBackend       = b
  , miRename        = Nothing
  , miCoerce        = Nothing
  , miFlatten       = False
  , miSkip          = False
  , miDefaults      = Nothing
  , miTag           = Nothing
  , miRequired      = Nothing
  , miWireOverride  = Nothing
  , miMapKey        = Nothing
  , miOneof         = Nothing
  , miCustom        = Map.empty
  }

-- ---------------------------------------------------------------------------
-- Errors
-- ---------------------------------------------------------------------------

-- | Conflicts surfaced during 'foldModifiers'.
data ModifierError
  = ConflictRename       !RenameSpec !RenameSpec
  | ConflictCoerce       !Name !Name
  | ConflictDefaults     !Name !Name
  | ConflictTag          !Int !Int
  | ConflictRequired     !Bool !Bool
  | ConflictWireOverride !WireOverride !WireOverride
  | ConflictMapKey       !MapKeyScalar !MapKeyScalar
  | ConflictOneof        !Text !Text
  | ConflictFlattenSkip
    -- ^ Both 'flatten' and 'skip' set on the same name.
  | InvalidRenameFnArity !Name !Int
  deriving (Eq, Show)

instance Exception ModifierError

-- ---------------------------------------------------------------------------
-- foldModifiers
-- ---------------------------------------------------------------------------

-- | Resolve a list of modifiers against a backend. Conflicts within
-- the global pass yield 'Left'; per-backend overrides cleanly shadow
-- global directives.
foldModifiers :: Backend -> [Modifier] -> Either ModifierError ModifierInfo
foldModifiers b ms = do
  let (globals, scoped) = partitionScope ms
  globalInfo <- foldList b (emptyModifierInfo b) globals
  -- Per-backend pass: do not raise on conflict; just shadow.
  let active = expandScoped b scoped
  pure (foldShadow active globalInfo)

-- ---------------------------------------------------------------------------
-- reifyModifierInfo / reifyModifierInfoFor
-- ---------------------------------------------------------------------------

-- | Reify all 'Modifier' annotations attached to a 'Name', resolved
-- for the given 'Backend'. Failure short-circuits the splice with
-- 'fail'.
reifyModifierInfoFor :: Quasi m => Backend -> Name -> m ModifierInfo
reifyModifierInfoFor b n = do
  (ms :: [Modifier]) <- qReifyAnnotations (TH.AnnLookupName n)
  case foldModifiers b ms of
    Left err ->
      let msg = "Wireform.Derive.ModifierInfo: invalid modifier "
             ++ "annotations on " ++ show n ++ ": " ++ show err
      in TH.qReport True msg >> fail msg
    Right mi -> pure mi

-- | Convenience: reify modifiers for every 'Backend' the deriver may
-- consult, returning a 'Map'. Useful when a single deriver wants to
-- emit instances for several formats from one annotation pass.
reifyModifierInfo
  :: Quasi m
  => [Backend]
  -> Name
  -> m (Map Backend ModifierInfo)
reifyModifierInfo backends n = do
  pairs <- mapM (\b -> (b,) <$> reifyModifierInfoFor b n) backends
  pure (Map.fromList pairs)

-- ---------------------------------------------------------------------------
-- Wire-key rendering
-- ---------------------------------------------------------------------------

-- | Splice a 'RenameSpec' into a Haskell expression of type 'Text'.
--
-- 'RenameSpecLiteral' bakes a string literal. 'RenameSpecStyle'
-- resolves any 'Idiomatic' inside the style against the supplied
-- 'Backend' and bakes the post-style 'Text' as a literal — zero
-- runtime cost. 'RenameSpecApply' splices a runtime function call
-- against the selector base name.
renderRenameKey :: Backend -> RenameSpec -> Text -> Q Exp
renderRenameKey b spec selBase = case spec of
  RenameSpecLiteral t  -> lift t
  RenameSpecStyle s    -> lift (applyStyle (resolveIdiomatic b s) selBase)
  RenameSpecApply  fn  -> [| $(varE fn) (T.pack $(lift (T.unpack selBase))) |]

-- | High-level wire-key renderer. If 'miRename' is set, defers to
-- 'renderRenameKey'; otherwise applies the backend's idiomatic style
-- (see 'idiomaticFor' / 'defaultRenameForBackend') to the selector
-- base name and bakes the result in as a literal.
renderWireKey :: ModifierInfo -> Text -> Q Exp
renderWireKey mi selBase = case miRename mi of
  Just spec -> renderRenameKey (miBackend mi) spec selBase
  Nothing   -> lift (defaultRenameForBackend (miBackend mi) selBase)

-- | The default wire key for a backend when no 'rename' modifier was
-- supplied: applies the backend's idiomatic style to the selector
-- base name.
defaultRenameForBackend :: Backend -> Text -> Text
defaultRenameForBackend b = applyStyle (resolveIdiomatic b (idiomaticFor b))

-- ---------------------------------------------------------------------------
-- Internals: scope partitioning
-- ---------------------------------------------------------------------------

-- | Split modifiers into global (apply to every backend) vs.
-- backend-scoped.
partitionScope :: [Modifier] -> ([Modifier], [Modifier])
partitionScope = foldr step ([], [])
  where
    step m (g, s)
      | modifierIsBackendScoped m = (g, m : s)
      | otherwise                 = (m : g, s)

-- | Expand backend-scoped wrappers into a flat list of modifiers
-- that apply to the active backend.
expandScoped :: Backend -> [Modifier] -> [Modifier]
expandScoped b = concatMap unwrap
  where
    unwrap (ModForBackends bs ms)
      | b `elem` bs = concatMap unwrap ms
      | otherwise   = []
    unwrap (ModBackendOnly bs m)
      | b `elem` bs = unwrap m
      | otherwise   = []
    unwrap (ModBackendDisable bs)
      | b `elem` bs = [ModSkip]
      | otherwise   = []
    -- Nested non-scoped modifier — should not occur post-partition,
    -- but if it does, treat it as global for this backend.
    unwrap m = [m]

-- ---------------------------------------------------------------------------
-- Internals: folding
-- ---------------------------------------------------------------------------

-- | Strict left fold raising on conflict.
foldList
  :: Backend
  -> ModifierInfo
  -> [Modifier]
  -> Either ModifierError ModifierInfo
foldList _ acc []     = pure acc
foldList b acc (m:ms) = do
  acc' <- mergeOne acc m
  foldList b acc' ms

-- | Strict left fold that silently shadows existing fields. Used for
-- per-backend overrides.
foldShadow :: [Modifier] -> ModifierInfo -> ModifierInfo
foldShadow ms acc0 = foldr (flip shadowOne) acc0 (reverse ms)

-- | Merge a single modifier into a 'ModifierInfo', raising on
-- conflict.
mergeOne :: ModifierInfo -> Modifier -> Either ModifierError ModifierInfo
mergeOne mi = \case
  ModRename r ->
    let new = renameSpec r
    in case miRename mi of
         Just old | old /= new -> Left (ConflictRename old new)
         _                     -> pure mi { miRename = Just new }

  ModCoerce n ->
    case miCoerce mi of
      Just old | old /= n -> Left (ConflictCoerce old n)
      _                   -> pure mi { miCoerce = Just n }

  ModFlatten
    | miSkip mi -> Left ConflictFlattenSkip
    | otherwise -> pure mi { miFlatten = True }

  ModSkip
    | miFlatten mi -> Left ConflictFlattenSkip
    | otherwise    -> pure mi { miSkip = True }

  ModDefaults n ->
    case miDefaults mi of
      Just old | old /= n -> Left (ConflictDefaults old n)
      _                   -> pure mi { miDefaults = Just n }

  ModTag t ->
    case miTag mi of
      Just old | old /= t -> Left (ConflictTag old t)
      _                   -> pure mi { miTag = Just t }

  ModRequired ->
    case miRequired mi of
      Just False -> Left (ConflictRequired False True)
      _          -> pure mi { miRequired = Just True }

  ModOptional ->
    case miRequired mi of
      Just True -> Left (ConflictRequired True False)
      _         -> pure mi { miRequired = Just False }

  ModWireOverride wo ->
    case miWireOverride mi of
      Just old | old /= wo -> Left (ConflictWireOverride old wo)
      _                    -> pure mi { miWireOverride = Just wo }

  ModMapKey k ->
    case miMapKey mi of
      Just old | old /= k -> Left (ConflictMapKey old k)
      _                   -> pure mi { miMapKey = Just k }

  ModOneof o ->
    case miOneof mi of
      Just old | old /= o -> Left (ConflictOneof old o)
      _                   -> pure mi { miOneof = Just o }

  m@(ModCustom tagName _) ->
    pure mi { miCustom = Map.insertWith (++) tagName [m] (miCustom mi) }

  -- Backend-scoped wrappers should not appear in the global pass; if
  -- they do (e.g. through nesting), shadow rather than raise.
  ModForBackends   _ _ -> pure mi
  ModBackendOnly   _ _ -> pure mi
  ModBackendDisable _  -> pure mi

-- | Like 'mergeOne' but unconditionally overwrites; used for
-- per-backend overrides.
shadowOne :: ModifierInfo -> Modifier -> ModifierInfo
shadowOne mi = \case
  ModRename r       -> mi { miRename       = Just (renameSpec r) }
  ModCoerce n       -> mi { miCoerce       = Just n }
  ModFlatten        -> mi { miFlatten      = True, miSkip = False }
  ModSkip           -> mi { miSkip         = True, miFlatten = False }
  ModDefaults n     -> mi { miDefaults     = Just n }
  ModTag t          -> mi { miTag          = Just t }
  ModRequired       -> mi { miRequired     = Just True }
  ModOptional       -> mi { miRequired     = Just False }
  ModWireOverride w -> mi { miWireOverride = Just w }
  ModMapKey k       -> mi { miMapKey       = Just k }
  ModOneof o        -> mi { miOneof        = Just o }
  m@(ModCustom tagName _) ->
    mi { miCustom = Map.insertWith (++) tagName [m] (miCustom mi) }
  ModForBackends   _ _ -> mi
  ModBackendOnly   _ _ -> mi
  ModBackendDisable _  -> mi

-- | Convert a 'Rename' modifier into a 'RenameSpec'. 'RenameStyle'
-- entries keep their 'NameStyle' verbatim so per-backend resolution
-- of 'Idiomatic' (and application against the selector base name)
-- can happen at render time in 'renderRenameKey'.
renameSpec :: Rename -> RenameSpec
renameSpec = \case
  RenameTo t    -> RenameSpecLiteral t
  RenameStyle s -> RenameSpecStyle s
  RenameFn n    -> RenameSpecApply n