packages feed

wireform-derive-0.2.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 Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as T
import Language.Haskell.TH (Exp, Name, Q, varE)
import Language.Haskell.TH.Syntax (Lift (lift), Quasi, qReifyAnnotations)
import Language.Haskell.TH.Syntax qualified 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
  | -- | Both 'flatten' and 'skip' set on the same name.
    ConflictFlattenSkip
  | 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